diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..6cebfff --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,22 @@ +name: python + +on: [push] + +jobs: + tox: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [2.7, 3.6, 3.7, 3.8, 3.9] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox diff --git a/aprsd/__init__.py b/aprsd/__init__.py index ecc74b1..0863171 100644 --- a/aprsd/__init__.py +++ b/aprsd/__init__.py @@ -14,6 +14,4 @@ import pbr.version - -__version__ = pbr.version.VersionInfo( - 'aprsd').version_string() +__version__ = pbr.version.VersionInfo("aprsd").version_string() diff --git a/aprsd/fake_aprs.py b/aprsd/fake_aprs.py index f29e141..3472feb 100644 --- a/aprsd/fake_aprs.py +++ b/aprsd/fake_aprs.py @@ -1,33 +1,27 @@ import argparse import logging +import socketserver import sys import time -import socketserver - from logging.handlers import RotatingFileHandler from aprsd import utils # command line args parser = argparse.ArgumentParser() -parser.add_argument("--loglevel", - default='DEBUG', - choices=['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'], - help="The log level to use for aprsd.log") -parser.add_argument("--quiet", - action='store_true', - help="Don't log to stdout") +parser.add_argument( + "--loglevel", + default="DEBUG", + choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], + help="The log level to use for aprsd.log", +) +parser.add_argument("--quiet", action="store_true", help="Don't log to stdout") -parser.add_argument("--port", - default=9099, - type=int, - help="The port to listen on .") -parser.add_argument("--ip", - default='127.0.0.1', - help="The IP to listen on ") +parser.add_argument("--port", default=9099, type=int, help="The port to listen on .") +parser.add_argument("--ip", default="127.0.0.1", help="The IP to listen on ") CONFIG = None -LOG = logging.getLogger('ARPSSERVER') +LOG = logging.getLogger("ARPSSERVER") # Setup the logging faciility @@ -36,22 +30,19 @@ LOG = logging.getLogger('ARPSSERVER') def setup_logging(args): global LOG levels = { - 'CRITICAL': logging.CRITICAL, - 'ERROR': logging.ERROR, - 'WARNING': logging.WARNING, - 'INFO': logging.INFO, - 'DEBUG': logging.DEBUG} + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + } log_level = levels[args.loglevel] LOG.setLevel(log_level) - log_format = ("%(asctime)s [%(threadName)-12.12s] [%(levelname)-5.5s]" - " %(message)s") - date_format = '%m/%d/%Y %I:%M:%S %p' - log_formatter = logging.Formatter(fmt=log_format, - datefmt=date_format) - fh = RotatingFileHandler('aprs-server.log', - maxBytes=(10248576 * 5), - backupCount=4) + log_format = "%(asctime)s [%(threadName)-12.12s] [%(levelname)-5.5s]" " %(message)s" + date_format = "%m/%d/%Y %I:%M:%S %p" + log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) + fh = RotatingFileHandler("aprs-server.log", maxBytes=(10248576 * 5), backupCount=4) fh.setFormatter(log_formatter) LOG.addHandler(fh) @@ -62,7 +53,6 @@ def setup_logging(args): class MyAPRSTCPHandler(socketserver.BaseRequestHandler): - def handle(self): # self.request is the TCP socket connected to the client self.data = self.request.recv(1024).strip() @@ -81,8 +71,8 @@ def main(): CONFIG = utils.parse_config(args) - ip = CONFIG['aprs']['host'] - port = CONFIG['aprs']['port'] + ip = CONFIG["aprs"]["host"] + port = CONFIG["aprs"]["port"] LOG.info("Start server listening on %s:%s" % (args.ip, args.port)) with socketserver.TCPServer((ip, port), MyAPRSTCPHandler) as server: diff --git a/aprsd/fuzzyclock.py b/aprsd/fuzzyclock.py index 8738c57..19f105b 100644 --- a/aprsd/fuzzyclock.py +++ b/aprsd/fuzzyclock.py @@ -19,37 +19,49 @@ import time def fuzzy(hour, minute, degree=1): - '''Implements the fuzzy clock. + """Implements the fuzzy clock. returns the the string that spells out the time - hour:minute Supports two degrees of fuzziness. Set with degree = 1 or degree = 2 When degree = 1, time is in quantum of 5 minutes. - When degree = 2, time is in quantum of 15 minutes.''' + When degree = 2, time is in quantum of 15 minutes.""" if degree <= 0 or degree > 2: - print('Please use a degree of 1 or 2. Using fuzziness degree=1') + print("Please use a degree of 1 or 2. Using fuzziness degree=1") degree = 1 - begin = 'It\'s ' + begin = "It's " - f0 = 'almost ' - f1 = 'exactly ' - f2 = 'around ' + f0 = "almost " + f1 = "exactly " + f2 = "around " - b0 = ' past ' - b1 = ' to ' + b0 = " past " + b1 = " to " - hourList = ('One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', - 'Nine', 'Ten', 'Eleven', 'Twelve') + hourlist = ( + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", + "Twelve", + ) - s1 = s2 = s3 = s4 = '' + s1 = s2 = s3 = s4 = "" base = 5 if degree == 1: base = 5 - val = ('Five', 'Ten', 'Quarter', 'Twenty', 'Twenty-Five', 'Half') + val = ("Five", "Ten", "Quarter", "Twenty", "Twenty-Five", "Half") elif degree == 2: base = 15 - val = ('Quarter', 'Half') + val = ("Quarter", "Half") # to find whether we have to use 'almost', 'exactly' or 'around' dmin = minute % base @@ -74,20 +86,20 @@ def fuzzy(hour, minute, degree=1): if minute <= base / 2: # Case like "It's around/exactly Ten" - s2 = s3 = '' - s4 = hourList[hour - 12 - 1] + s2 = s3 = "" + s4 = hourlist[hour - 12 - 1] elif minute >= 60 - base / 2: # Case like "It's almost Ten" - s2 = s3 = '' - s4 = hourList[hour - 12] + s2 = s3 = "" + s4 = hourlist[hour - 12] else: # Other cases with all words, like "It's around Quarter past One" if minute > 30: s3 = b1 # to - s4 = hourList[hour - 12] + s4 = hourlist[hour - 12] else: s3 = b0 # past - s4 = hourList[hour - 12 - 1] + s4 = hourlist[hour - 12 - 1] return begin + s1 + s2 + s3 + s4 @@ -102,17 +114,17 @@ def main(): try: deg = int(sys.argv[1]) except Exception: - print('Please use a degree of 1 or 2. Using fuzziness degree=1') + print("Please use a degree of 1 or 2. Using fuzziness degree=1") if len(sys.argv) >= 3: - tm = sys.argv[2].split(':') + tm = sys.argv[2].split(":") try: h = int(tm[0]) m = int(tm[1]) if h < 0 or h > 23 or m < 0 or m > 59: raise Exception except Exception: - print('Bad time entered. Using the system time.') + print("Bad time entered. Using the system time.") h = stm.tm_hour m = stm.tm_min print(fuzzy(h, m, deg)) diff --git a/aprsd/main.py b/aprsd/main.py index 7dc1003..9442862 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -23,38 +23,36 @@ # python included libs import datetime import email +import imaplib import json import logging import os -import socket import pprint import re import signal -import six import smtplib +import socket import subprocess import sys -# import telnetlib import threading import time -#import urllib -import requests - +from email.mime.text import MIMEText +from logging.handlers import RotatingFileHandler import click import click_completion -from email.mime.text import MIMEText import imapclient -import imaplib -from logging.handlers import RotatingFileHandler +import requests +import six +import yaml # local imports here import aprsd -from aprsd.fuzzyclock import fuzzy from aprsd import utils +from aprsd.fuzzyclock import fuzzy # setup the global logger -LOG = logging.getLogger('APRSD') +LOG = logging.getLogger("APRSD") # global for the config yaml CONFIG = None @@ -85,22 +83,22 @@ ack_dict = {} message_number = 0 # global telnet connection object -- not needed anymore -#tn = None +# tn = None -### set default encoding for python, so body.decode doesn't blow up in email thread -#reload(sys) -#sys.setdefaultencoding('utf8') +# ## set default encoding for python, so body.decode doesn't blow up in email thread +# reload(sys) +# sys.setdefaultencoding('utf8') -#import locale -#def getpreferredencoding(do_setlocale = True): +# import locale +# def getpreferredencoding(do_setlocale = True): # return "utf-8" -#locale.getpreferredencoding = getpreferredencoding -### default encoding failed attempts.... +# locale.getpreferredencoding = getpreferredencoding +# ## default encoding failed attempts.... def custom_startswith(string, incomplete): """A custom completion match that supports case insensitive matching.""" - if os.environ.get('_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE'): + if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"): string = string.lower() incomplete = incomplete.lower() return string.startswith(incomplete) @@ -115,8 +113,11 @@ Available shell types: \b %s Default type: auto -""" % "\n ".join('{:<12} {}'.format(k, click_completion.core.shells[k]) for k in sorted( - click_completion.core.shells.keys())) +""" % "\n ".join( + "{:<12} {}".format(k, click_completion.core.shells[k]) + for k in sorted(click_completion.core.shells.keys()) +) + @click.group(help=cmd_help) @click.version_option() @@ -125,24 +126,48 @@ def main(): @main.command() -@click.option('-i', '--case-insensitive/--no-case-insensitive', help="Case insensitive completion") -@click.argument('shell', required=False, type=click_completion.DocumentedChoice(click_completion.core.shells)) +@click.option( + "-i", "--case-insensitive/--no-case-insensitive", help="Case insensitive completion" +) +@click.argument( + "shell", + required=False, + type=click_completion.DocumentedChoice(click_completion.core.shells), +) def show(shell, case_insensitive): """Show the click-completion-command completion code""" - extra_env = {'_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE': 'ON'} if case_insensitive else {} + extra_env = ( + {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} + if case_insensitive + else {} + ) click.echo(click_completion.core.get_code(shell, extra_env=extra_env)) @main.command() -@click.option('--append/--overwrite', help="Append the completion code to the file", default=None) -@click.option('-i', '--case-insensitive/--no-case-insensitive', help="Case insensitive completion") -@click.argument('shell', required=False, type=click_completion.DocumentedChoice(click_completion.core.shells)) -@click.argument('path', required=False) +@click.option( + "--append/--overwrite", help="Append the completion code to the file", default=None +) +@click.option( + "-i", "--case-insensitive/--no-case-insensitive", help="Case insensitive completion" +) +@click.argument( + "shell", + required=False, + type=click_completion.DocumentedChoice(click_completion.core.shells), +) +@click.argument("path", required=False) def install(append, case_insensitive, shell, path): """Install the click-completion-command completion""" - extra_env = {'_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE': 'ON'} if case_insensitive else {} - shell, path = click_completion.core.install(shell=shell, path=path, append=append, extra_env=extra_env) - click.echo('%s completion installed in %s' % (shell, path)) + extra_env = ( + {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} + if case_insensitive + else {} + ) + shell, path = click_completion.core.install( + shell=shell, path=path, append=append, extra_env=extra_env + ) + click.echo("%s completion installed in %s" % (shell, path)) def setup_connection(): @@ -153,23 +178,23 @@ def setup_connection(): try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(300) - sock.connect((CONFIG['aprs']['host'], 14580)) + sock.connect((CONFIG["aprs"]["host"], 14580)) connected = True - LOG.debug("Connected to server: " + CONFIG['aprs']['host']) - sock_file = sock.makefile(mode='r') - #sock_file = sock.makefile(mode='r', encoding=None, errors=None, newline=None) - #sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # disable nagle algorithm - #sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 512) # buffer size + LOG.debug("Connected to server: " + CONFIG["aprs"]["host"]) + sock_file = sock.makefile(mode="r") + # sock_file = sock.makefile(mode='r', encoding=None, errors=None, newline=None) + # sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # disable nagle algorithm + # sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 512) # buffer size except Exception as e: LOG.error("Unable to connect to APRS-IS server.\n") print(str(e)) time.sleep(5) continue # os._exit(1) - user = CONFIG['aprs']['login'] - password = CONFIG['aprs']['password'] + user = CONFIG["aprs"]["login"] + password = CONFIG["aprs"]["password"] LOG.debug("Logging in to APRS-IS with user '%s'" % user) - msg = ("user {} pass {} vers aprsd {}\n".format(user, password, aprsd.__version__)) + msg = "user {} pass {} vers aprsd {}\n".format(user, password, aprsd.__version__) sock.send(msg.encode()) @@ -177,11 +202,13 @@ def signal_handler(signal, frame): LOG.info("Ctrl+C, exiting.") # sys.exit(0) # thread ignores this os._exit(0) + + # end signal_handler def parse_email(msgid, data, server): - envelope = data[b'ENVELOPE'] + envelope = data[b"ENVELOPE"] # email address match # use raw string to avoid invalid escape secquence errors r"string here" f = re.search(r"([\.\w_-]+@[\.\w_-]+)", str(envelope.from_[0])) @@ -190,15 +217,21 @@ def parse_email(msgid, data, server): else: from_addr = "noaddr" LOG.debug("Got a message from '{}'".format(from_addr)) - m = server.fetch([msgid], ['RFC822']) - msg = email.message_from_string(m[msgid][b'RFC822'].decode(errors='ignore')) + m = server.fetch([msgid], ["RFC822"]) + msg = email.message_from_string(m[msgid][b"RFC822"].decode(errors="ignore")) if msg.is_multipart(): text = "" html = None # default in case body somehow isn't set below - happened once body = "* unreadable msg received" - for part in msg.get_payload(): # FIXME this uses the last text or html part in the email, want the first, reverse order somehow? - if part.get_content_charset() is None: # or BREAK when we hit a text or html? + for ( + part + ) in ( + msg.get_payload() + ): # FIXME this uses the last text or html part in the email, want the first, reverse order somehow? + if ( + part.get_content_charset() is None + ): # or BREAK when we hit a text or html? # We cannot know the character set, # so return decoded "something" text = part.get_payload(decode=True) @@ -206,16 +239,15 @@ def parse_email(msgid, data, server): charset = part.get_content_charset() - if part.get_content_type() == 'text/plain': + if part.get_content_type() == "text/plain": text = six.text_type( - part.get_payload(decode=True), str(charset), - "ignore").encode('utf8', 'replace') + part.get_payload(decode=True), str(charset), "ignore" + ).encode("utf8", "replace") - if part.get_content_type() == 'text/html': + if part.get_content_type() == "text/html": html = six.text_type( - part.get_payload(decode=True), - str(charset), - "ignore").encode('utf8', 'replace') + part.get_payload(decode=True), str(charset), "ignore" + ).encode("utf8", "replace") if text is not None: # strip removes white space fore and aft of string @@ -226,78 +258,72 @@ def parse_email(msgid, data, server): # email.uscc.net sends no charset, blows up unicode function below if msg.get_content_charset() is None: text = six.text_type( - msg.get_payload(decode=True), - 'US-ASCII', - 'ignore').encode('utf8', 'replace') + msg.get_payload(decode=True), "US-ASCII", "ignore" + ).encode("utf8", "replace") else: text = six.text_type( - msg.get_payload(decode=True), - msg.get_content_charset(), - 'ignore').encode('utf8', 'replace') + msg.get_payload(decode=True), msg.get_content_charset(), "ignore" + ).encode("utf8", "replace") body = text.strip() # FIXED: UnicodeDecodeError: 'ascii' codec can't decode byte 0xf0 in position 6: ordinal not in range(128) # decode with errors='ignore'. be sure to encode it before we return it below, also with errors='ignore' try: - body = body.decode(errors='ignore') + body = body.decode(errors="ignore") except Exception as e: LOG.error("Unicode decode failure: " + str(e)) LOG.error("Unidoce decode failed: " + str(body)) body = "Unreadable unicode msg" # strip all html tags - body = re.sub('<[^<]+?>', '', body) + body = re.sub("<[^<]+?>", "", body) # strip CR/LF, make it one line, .rstrip fails at this body = body.replace("\n", " ").replace("\r", " ") - #ascii might be out of range, so encode it, removing any error characters - body = body.encode(errors='ignore') - return(body, from_addr) + # ascii might be out of range, so encode it, removing any error characters + body = body.encode(errors="ignore") + return (body, from_addr) + + # end parse_email def _imap_connect(): - imap_port = CONFIG['imap'].get('port', 143) - use_ssl = CONFIG['imap'].get('use_ssl', False) - host = CONFIG['imap']['host'] - msg = ("{}{}:{}".format( - 'TLS ' if use_ssl else '', - host, - imap_port - )) -# LOG.debug("Connect to IMAP host {} with user '{}'". -# format(msg, CONFIG['imap']['login'])) + imap_port = CONFIG["imap"].get("port", 143) + use_ssl = CONFIG["imap"].get("use_ssl", False) + host = CONFIG["imap"]["host"] + msg = "{}{}:{}".format("TLS " if use_ssl else "", host, imap_port) + # LOG.debug("Connect to IMAP host {} with user '{}'". + # format(msg, CONFIG['imap']['login'])) try: - server = imapclient.IMAPClient(CONFIG['imap']['host'], port=imap_port, - use_uid=True, ssl=use_ssl) + server = imapclient.IMAPClient( + CONFIG["imap"]["host"], port=imap_port, use_uid=True, ssl=use_ssl + ) except Exception: LOG.error("Failed to connect IMAP server") return -# LOG.debug("Connected to IMAP host {}".format(msg)) + # LOG.debug("Connected to IMAP host {}".format(msg)) try: - server.login(CONFIG['imap']['login'], CONFIG['imap']['password']) + server.login(CONFIG["imap"]["login"], CONFIG["imap"]["password"]) except (imaplib.IMAP4.error, Exception) as e: - msg = getattr(e, 'message', repr(e)) + msg = getattr(e, "message", repr(e)) LOG.error("Failed to login {}".format(msg)) return -# LOG.debug("Logged in to IMAP, selecting INBOX") - server.select_folder('INBOX') + # LOG.debug("Logged in to IMAP, selecting INBOX") + server.select_folder("INBOX") return server def _smtp_connect(): - host = CONFIG['smtp']['host'] - smtp_port = CONFIG['smtp']['port'] - use_ssl = CONFIG['smtp'].get('use_ssl', False) - msg = ("{}{}:{}".format( - 'SSL ' if use_ssl else '', - host, - smtp_port - )) - LOG.debug("Connect to SMTP host {} with user '{}'". - format(msg, CONFIG['imap']['login'])) + host = CONFIG["smtp"]["host"] + smtp_port = CONFIG["smtp"]["port"] + use_ssl = CONFIG["smtp"].get("use_ssl", False) + msg = "{}{}:{}".format("SSL " if use_ssl else "", host, smtp_port) + LOG.debug( + "Connect to SMTP host {} with user '{}'".format(msg, CONFIG["imap"]["login"]) + ) try: if use_ssl: @@ -311,7 +337,7 @@ def _smtp_connect(): LOG.debug("Connected to smtp host {}".format(msg)) try: - server.login(CONFIG['smtp']['login'], CONFIG['smtp']['password']) + server.login(CONFIG["smtp"]["login"], CONFIG["smtp"]["password"]) except Exception: LOG.error("Couldn't connect to SMTP Server") return @@ -323,7 +349,7 @@ def _smtp_connect(): def validate_email(): """function to simply ensure we can connect to email services. - This helps with failing early during startup. + This helps with failing early during startup. """ LOG.info("Checking IMAP configuration") imap_server = _imap_connect() @@ -339,12 +365,12 @@ def validate_email(): def resend_email(count, fromcall): global check_email_delay date = datetime.datetime.now() - month = date.strftime("%B")[:3] # Nov, Mar, Apr + month = date.strftime("%B")[:3] # Nov, Mar, Apr day = date.day year = date.year today = "%s-%s-%s" % (day, month, year) - shortcuts = CONFIG['shortcuts'] + shortcuts = CONFIG["shortcuts"] # swap key/value shortcuts_inverted = dict([[v, k] for k, v in shortcuts.items()]) @@ -354,15 +380,15 @@ def resend_email(count, fromcall): LOG.exception("Failed to Connect to IMAP. Cannot resend email ", e) return - messages = server.search(['SINCE', today]) - #LOG.debug("%d messages received today" % len(messages)) + messages = server.search(["SINCE", today]) + # LOG.debug("%d messages received today" % len(messages)) msgexists = False messages.sort(reverse=True) - del messages[int(count):] # only the latest "count" messages + del messages[int(count) :] # only the latest "count" messages for message in messages: - for msgid, data in list(server.fetch(message, ['ENVELOPE']).items()): + for msgid, data in list(server.fetch(message, ["ENVELOPE"]).items()): # one at a time, otherwise order is random (body, from_addr) = parse_email(msgid, data, server) # unset seen flag, will stay bold in email client @@ -384,9 +410,11 @@ def resend_email(count, fromcall): # thinking this is a duplicate message. # The FT1XDR pretty much ignores the aprs message number in this # regard. The FTM400 gets it right. - reply = "No new msg %s:%s:%s" % (str(h).zfill(2), - str(m).zfill(2), - str(s).zfill(2)) + reply = "No new msg %s:%s:%s" % ( + str(h).zfill(2), + str(m).zfill(2), + str(s).zfill(2), + ) send_message(fromcall, reply) # check email more often since we're resending one now @@ -402,7 +430,7 @@ def check_email_thread(): LOG.debug("FIXME initial email delay is 10 seconds") check_email_delay = 10 while True: -# LOG.debug("Top of check_email_thread.") + # LOG.debug("Top of check_email_thread.") time.sleep(check_email_delay) @@ -412,12 +440,12 @@ def check_email_thread(): check_email_delay += 1 LOG.debug("check_email_delay is " + str(check_email_delay) + " seconds") - shortcuts = CONFIG['shortcuts'] + shortcuts = CONFIG["shortcuts"] # swap key/value shortcuts_inverted = dict([[v, k] for k, v in shortcuts.items()]) date = datetime.datetime.now() - month = date.strftime("%B")[:3] # Nov, Mar, Apr + month = date.strftime("%B")[:3] # Nov, Mar, Apr day = date.day year = date.year today = "%s-%s-%s" % (day, month, year) @@ -431,26 +459,29 @@ def check_email_thread(): if not server: continue - messages = server.search(['SINCE', today]) - #LOG.debug("{} messages received today".format(len(messages))) + messages = server.search(["SINCE", today]) + # LOG.debug("{} messages received today".format(len(messages))) - for msgid, data in server.fetch(messages, ['ENVELOPE']).items(): - envelope = data[b'ENVELOPE'] - #LOG.debug('ID:%d "%s" (%s)' % (msgid, envelope.subject.decode(), envelope.date)) - f = re.search(r"'([[A-a][0-9]_-]+@[[A-a][0-9]_-\.]+)", - str(envelope.from_[0])) + for msgid, data in server.fetch(messages, ["ENVELOPE"]).items(): + envelope = data[b"ENVELOPE"] + # LOG.debug('ID:%d "%s" (%s)' % (msgid, envelope.subject.decode(), envelope.date)) + f = re.search( + r"'([[A-a][0-9]_-]+@[[A-a][0-9]_-\.]+)", str(envelope.from_[0]) + ) if f is not None: from_addr = f.group(1) else: from_addr = "noaddr" - #LOG.debug("Message flags/tags: " + str(server.get_flags(msgid)[msgid])) - #if "APRS" not in server.get_flags(msgid)[msgid]: - #in python3, imap tags are unicode. in py2 they're strings. so .decode them to handle both - taglist=[x.decode(errors='ignore') for x in server.get_flags(msgid)[msgid]] + # LOG.debug("Message flags/tags: " + str(server.get_flags(msgid)[msgid])) + # if "APRS" not in server.get_flags(msgid)[msgid]: + # in python3, imap tags are unicode. in py2 they're strings. so .decode them to handle both + taglist = [ + x.decode(errors="ignore") for x in server.get_flags(msgid)[msgid] + ] if "APRS" not in taglist: # if msg not flagged as sent via aprs - server.fetch([msgid], ['RFC822']) + server.fetch([msgid], ["RFC822"]) (body, from_addr) = parse_email(msgid, data, server) # unset seen flag, will stay bold in email client server.remove_flags(msgid, [imapclient.SEEN]) @@ -459,10 +490,10 @@ def check_email_thread(): # reverse lookup of a shortcut from_addr = shortcuts_inverted[from_addr] - reply = "-" + from_addr + " " + body.decode(errors='ignore') - send_message(CONFIG['ham']['callsign'], reply) + reply = "-" + from_addr + " " + body.decode(errors="ignore") + send_message(CONFIG["ham"]["callsign"], reply) # flag message as sent via aprs - server.add_flags(msgid, ['APRS']) + server.add_flags(msgid, ["APRS"]) # unset seen flag, will stay bold in email client server.remove_flags(msgid, [imapclient.SEEN]) # check email more often since we just received an email @@ -470,33 +501,33 @@ def check_email_thread(): server.logout() + # end check_email() def send_ack_thread(tocall, ack, retry_count): tocall = tocall.ljust(9) # pad to nine chars - line = ("{}>APRS::{}:ack{}\n".format( - CONFIG['aprs']['login'], tocall, ack)) + line = "{}>APRS::{}:ack{}\n".format(CONFIG["aprs"]["login"], tocall, ack) for i in range(retry_count, 0, -1): LOG.info("Sending ack __________________ Tx({})".format(i)) - LOG.info("Raw : {}".format(line.rstrip('\n'))) + LOG.info("Raw : {}".format(line.rstrip("\n"))) LOG.info("To : {}".format(tocall)) LOG.info("Ack number : {}".format(ack)) sock.send(line.encode()) # aprs duplicate detection is 30 secs? # (21 only sends first, 28 skips middle) time.sleep(31) - return() + return () # end_send_ack_thread def send_ack(tocall, ack): retry_count = 3 - thread = threading.Thread(target=send_ack_thread, - name="send_ack", - args=(tocall, ack, retry_count)) + thread = threading.Thread( + target=send_ack_thread, name="send_ack", args=(tocall, ack, retry_count) + ) thread.start() - return() + return () # end send_ack() @@ -504,20 +535,25 @@ def send_message_thread(tocall, message, this_message_number, retry_count): global ack_dict # line = (CONFIG['aprs']['login'] + ">APRS::" + tocall + ":" + message # + "{" + str(this_message_number) + "\n") - #line = ("{}>APRS::{}:{}{{{}\n".format( CONFIG['aprs']['login'], tocall, message.encode(errors='ignore'), str(this_message_number),)) - line = ("{}>APRS::{}:{}{{{}\n".format( CONFIG['aprs']['login'], tocall, message, str(this_message_number),)) + # line = ("{}>APRS::{}:{}{{{}\n".format( CONFIG['aprs']['login'], tocall, message.encode(errors='ignore'), str(this_message_number),)) + line = "{}>APRS::{}:{}{{{}\n".format( + CONFIG["aprs"]["login"], + tocall, + message, + str(this_message_number), + ) for i in range(retry_count, 0, -1): LOG.debug("DEBUG: send_message_thread msg:ack combos are: ") LOG.debug(pprint.pformat(ack_dict)) if ack_dict[this_message_number] != 1: - LOG.info("Sending message_______________ {}(Tx{})" - .format( - str(this_message_number), - str(i) - )) - LOG.info("Raw : {}".format(line.rstrip('\n'))) + LOG.info( + "Sending message_______________ {}(Tx{})".format( + str(this_message_number), str(i) + ) + ) + LOG.info("Raw : {}".format(line.rstrip("\n"))) LOG.info("To : {}".format(tocall)) - #LOG.info("Message : {}".format(message.encode(errors='ignore'))) + # LOG.info("Message : {}".format(message.encode(errors='ignore'))) LOG.info("Message : {}".format(message)) # tn.write(line) sock.send(line.encode()) @@ -534,19 +570,21 @@ def send_message(tocall, message): global message_number global ack_dict retry_count = 3 - if message_number > 98: # global + if message_number > 98: # global message_number = 0 message_number += 1 if len(ack_dict) > 90: # empty ack dict if it's really big, could result in key error later - LOG.debug("DEBUG: Length of ack dictionary is big at %s clearing." % - len(ack_dict)) + LOG.debug( + "DEBUG: Length of ack dictionary is big at %s clearing." % len(ack_dict) + ) ack_dict.clear() LOG.debug(pprint.pformat(ack_dict)) - LOG.debug("DEBUG: Cleared ack dictionary, ack_dict length is now %s." % - len(ack_dict)) - ack_dict[message_number] = 0 # clear ack for this message number - tocall = tocall.ljust(9) # pad to nine chars + LOG.debug( + "DEBUG: Cleared ack dictionary, ack_dict length is now %s." % len(ack_dict) + ) + ack_dict[message_number] = 0 # clear ack for this message number + tocall = tocall.ljust(9) # pad to nine chars # max? ftm400 displays 64, raw msg shows 74 # and ftm400-send is max 64. setting this to @@ -554,25 +592,26 @@ def send_message(tocall, message): # feature req: break long ones into two msgs message = message[:67] # We all miss George Carlin - message = re.sub('fuck|shit|cunt|piss|cock|bitch', '****', message) + message = re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message) thread = threading.Thread( target=send_message_thread, name="send_message", - args=(tocall, message, message_number, retry_count)) + args=(tocall, message, message_number, retry_count), + ) thread.start() - return() + return () # end send_message() def process_message(line): - f = re.search('^(.*)>', line) + f = re.search("^(.*)>", line) fromcall = f.group(1) - searchstring = '::%s[ ]*:(.*)' % CONFIG['aprs']['login'] + searchstring = "::%s[ ]*:(.*)" % CONFIG["aprs"]["login"] # verify this, callsign is padded out with spaces to colon m = re.search(searchstring, line) fullmessage = m.group(1) - ack_attached = re.search('(.*){([0-9A-Z]+)', fullmessage) + ack_attached = re.search("(.*){([0-9A-Z]+)", fullmessage) # ack formats include: {1, {AB}, {12 if ack_attached: # "{##" suffix means radio wants an ack back @@ -599,12 +638,12 @@ def send_email(to_addr, content): global check_email_delay LOG.info("Sending Email_________________") - shortcuts = CONFIG['shortcuts'] + shortcuts = CONFIG["shortcuts"] if to_addr in shortcuts: LOG.info("To : " + to_addr) to_addr = shortcuts[to_addr] LOG.info(" (" + to_addr + ")") - subject = CONFIG['ham']['callsign'] + subject = CONFIG["ham"]["callsign"] # content = content + "\n\n(NOTE: reply with one line)" LOG.info("Subject : " + subject) LOG.info("Body : " + content) @@ -613,20 +652,20 @@ def send_email(to_addr, content): check_email_delay = 60 msg = MIMEText(content) - msg['Subject'] = subject - msg['From'] = CONFIG['smtp']['login'] - msg['To'] = to_addr + msg["Subject"] = subject + msg["From"] = CONFIG["smtp"]["login"] + msg["To"] = to_addr server = _smtp_connect() if server: try: - server.sendmail(CONFIG['smtp']['login'], [to_addr], msg.as_string()) + server.sendmail(CONFIG["smtp"]["login"], [to_addr], msg.as_string()) except Exception as e: - msg = getattr(e, 'message', repr(e)) + msg = getattr(e, "message", repr(e)) LOG.error("Sendmail Error!!!! '{}'", msg) server.quit() - return(-1) + return -1 server.quit() - return(0) + return 0 # end send_email @@ -634,24 +673,22 @@ def send_email(to_addr, content): # to disable logging to stdout, but still log to file # use the --quiet option on the cmdln def setup_logging(loglevel, quiet): - global LOG levels = { - 'CRITICAL': logging.CRITICAL, - 'ERROR': logging.ERROR, - 'WARNING': logging.WARNING, - 'INFO': logging.INFO, - 'DEBUG': logging.DEBUG} + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + } log_level = levels[loglevel] LOG.setLevel(log_level) - log_format = ("%(asctime)s [%(threadName)-12s] [%(levelname)-5.5s]" - " %(message)s") - date_format = '%m/%d/%Y %I:%M:%S %p' - log_formatter = logging.Formatter(fmt=log_format, - datefmt=date_format) - fh = RotatingFileHandler(CONFIG['aprs']['logfile'], - maxBytes=(10248576 * 5), - backupCount=4) + log_format = "%(asctime)s [%(threadName)-12s] [%(levelname)-5.5s]" " %(message)s" + date_format = "%m/%d/%Y %I:%M:%S %p" + log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) + fh = RotatingFileHandler( + CONFIG["aprs"]["logfile"], maxBytes=(10248576 * 5), backupCount=4 + ) fh.setFormatter(log_formatter) LOG.addHandler(fh) @@ -661,32 +698,38 @@ def setup_logging(loglevel, quiet): LOG.addHandler(sh) - - @main.command() def sample_config(): """This dumps the config to stdout.""" - print(utils.example_config) + click.echo(yaml.dump(utils.DEFAULT_CONFIG_DICT)) # main() ### @main.command() -@click.option("--loglevel", - default='DEBUG', - show_default=True, - type=click.Choice(['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'], - case_sensitive=False), - show_choices=True, - help="The log level to use for aprsd.log") -@click.option("--quiet", - is_flag=True, - default=False, - help="Don't log to stdout") -def server(loglevel, quiet): +@click.option( + "--loglevel", + default="DEBUG", + show_default=True, + type=click.Choice( + ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], case_sensitive=False + ), + show_choices=True, + help="The log level to use for aprsd.log", +) +@click.option("--quiet", is_flag=True, default=False, help="Don't log to stdout") +@click.option( + "-c", + "--config", + "config_file", + show_default=True, + default=utils.DEFAULT_CONFIG_FILE, + help="The aprsd config file to use for options.", +) +def server(loglevel, quiet, config_file): """Start the aprsd server process.""" global CONFIG - CONFIG = utils.parse_config() + CONFIG = utils.parse_config(config_file) signal.signal(signal.SIGINT, signal_handler) setup_logging(loglevel, quiet) LOG.info("APRSD Started version: {}".format(aprsd.__version__)) @@ -698,19 +741,17 @@ def server(loglevel, quiet): LOG.error("Failed to validate email config options") sys.exit(-1) - user = CONFIG['aprs']['login'] - password = CONFIG['aprs']['password'] - - #LOG.debug("LOGIN to APRSD with user '%s'" % user) - #msg = ("user {} pass {} vers aprsd {}\n".format(user, password, aprsd.__version__)) - #sock.send(msg.encode()) - + user = CONFIG["aprs"]["login"] + # password = CONFIG["aprs"]["password"] + # LOG.debug("LOGIN to APRSD with user '%s'" % user) + # msg = ("user {} pass {} vers aprsd {}\n".format(user, password, aprsd.__version__)) + # sock.send(msg.encode()) time.sleep(2) - checkemailthread = threading.Thread(target=check_email_thread, - name="check_email", - args=() ) # args must be tuple + checkemailthread = threading.Thread( + target=check_email_thread, name="check_email", args=() + ) # args must be tuple checkemailthread.start() LOG.debug("reset empty line counter") @@ -718,13 +759,13 @@ def server(loglevel, quiet): while True: LOG.debug("Main loop start") - time.sleep(1) # prevent tight loop if something goes awry + time.sleep(1) # prevent tight loop if something goes awry line = "" try: line = sock_file.readline().strip() LOG.info("APRS-IS: " + line) # is aprs message to us? not beacon, status, empty line, etc - searchstring = '::%s' % user + searchstring = "::%s" % user if re.search(searchstring, line): LOG.debug("main: found message addressed to us begin process_message") (fromcall, message, ack) = process_message(line) @@ -734,31 +775,36 @@ def server(loglevel, quiet): # LOG.debug("Noise: " + line) # detect closed socket, getting lots of empty lines if len(line.strip()) == 0: - LOG.debug("Zero line length received. Consecutive empty line count: " + str(empty_line_rx)) + LOG.debug( + "Zero line length received. Consecutive empty line count: " + + str(empty_line_rx) + ) empty_line_rx += 1 if empty_line_rx >= 30: - LOG.debug("Excessive empty lines received, socket likely CLOSED_WAIT. Reconnecting.") + LOG.debug( + "Excessive empty lines received, socket likely CLOSED_WAIT. Reconnecting." + ) empty_line_rx = 0 raise Exception("closed_socket") continue # line is something we don't care about # ACK (ack##) - if re.search('^ack[0-9]+', message): + if re.search("^ack[0-9]+", message): LOG.debug("ACK") # put message_number:1 in dict to record the ack - a = re.search('^ack([0-9]+)', message) + a = re.search("^ack([0-9]+)", message) ack_dict.update({int(a.group(1)): 1}) - continue # break out of this so we don't ack an ack at the end + continue # break out of this so we don't ack an ack at the end # EMAIL (-) # is email command - elif re.search('^-.*', message): + elif re.search("^-.*", message): LOG.debug("EMAIL") - searchstring = '^' + CONFIG['ham']['callsign'] + '.*' + searchstring = "^" + CONFIG["ham"]["callsign"] + ".*" # only I can do email if re.search(searchstring, fromcall): # digits only, first one is number of emails to resend - r = re.search('^-([0-9])[0-9]*$', message) + r = re.search("^-([0-9])[0-9]*$", message) if r is not None: resend_email(r.group(1), fromcall) # -user@address.com body of email @@ -769,16 +815,18 @@ def server(loglevel, quiet): to_addr = a.group(1) content = a.group(2) # send recipient link to aprs.fi map - if content == 'mapme': + if content == "mapme": content = ( - "Click for my location: http://aprs.fi/{}". - format(CONFIG['ham']['callsign'])) + "Click for my location: http://aprs.fi/{}".format( + CONFIG["ham"]["callsign"] + ) + ) too_soon = 0 now = time.time() # see if we sent this msg number recently if ack in email_sent_dict: timedelta = now - email_sent_dict[ack] - if (timedelta < 300): # five minutes + if timedelta < 300: # five minutes too_soon = 1 if not too_soon or ack == 0: send_result = send_email(to_addr, content) @@ -786,113 +834,175 @@ def server(loglevel, quiet): send_message(fromcall, "-" + to_addr + " failed") else: # send_message(fromcall, "-" + to_addr + " sent") - if len(email_sent_dict) > 98: # clear email sent dictionary if somehow goes over 100 - LOG.debug("DEBUG: email_sent_dict is big (" + str(len(email_sent_dict)) + ") clearing out.") + if ( + len(email_sent_dict) > 98 + ): # clear email sent dictionary if somehow goes over 100 + LOG.debug( + "DEBUG: email_sent_dict is big (" + + str(len(email_sent_dict)) + + ") clearing out." + ) email_sent_dict.clear() email_sent_dict[ack] = now else: - LOG.info("Email for message number " + ack + " recently sent, not sending again.") + LOG.info( + "Email for message number " + + ack + + " recently sent, not sending again." + ) else: send_message(fromcall, "Bad email address") # TIME (t) - elif re.search('^[tT]', message): + elif re.search("^[tT]", message): LOG.debug("TIME") stm = time.localtime() h = stm.tm_hour m = stm.tm_min cur_time = fuzzy(h, m, 1) - reply = cur_time + " (" + str(h) + ":" + str(m).rjust(2, '0') + "PDT)" + " (" + message.rstrip() + ")" - thread = threading.Thread(target=send_message, - name="send_message", - args=(fromcall, reply)) + reply = ( + cur_time + + " (" + + str(h) + + ":" + + str(m).rjust(2, "0") + + "PDT)" + + " (" + + message.rstrip() + + ")" + ) + thread = threading.Thread( + target=send_message, name="send_message", args=(fromcall, reply) + ) thread.start() # FORTUNE (f) - elif re.search('^[fF]', message): + elif re.search("^[fF]", message): LOG.debug("FORTUNE") - process = subprocess.Popen(['/usr/games/fortune', '-s', '-n 60'], stdout=subprocess.PIPE) + process = subprocess.Popen( + ["/usr/games/fortune", "-s", "-n 60"], stdout=subprocess.PIPE + ) reply = process.communicate()[0] - #send_message(fromcall, reply.rstrip()) - reply = reply.decode(errors='ignore') + # send_message(fromcall, reply.rstrip()) + reply = reply.decode(errors="ignore") send_message(fromcall, reply.rstrip()) # PING (p) - elif re.search('^[pP]', message): + elif re.search("^[pP]", message): LOG.debug("PING") stm = time.localtime() h = stm.tm_hour m = stm.tm_min s = stm.tm_sec - reply = "Pong! " + str(h).zfill(2) + ":" + str(m).zfill(2) + ":" + str(s).zfill(2) + reply = ( + "Pong! " + + str(h).zfill(2) + + ":" + + str(m).zfill(2) + + ":" + + str(s).zfill(2) + ) send_message(fromcall, reply.rstrip()) # LOCATION (l) "8 Miles E Auburn CA 1771' 38.91547,-120.99500 0.1h ago" - elif re.search('^[lL]', message): + elif re.search("^[lL]", message): LOG.debug("LOCATION") # get last location of a callsign, get descriptive name from weather service try: - a = re.search(r"^.*\s+(.*)", message) # optional second argument is a callsign to search + a = re.search( + r"^.*\s+(.*)", message + ) # optional second argument is a callsign to search if a is not None: searchcall = a.group(1) searchcall = searchcall.upper() else: - searchcall = fromcall # if no second argument, search for calling station - url = "http://api.aprs.fi/api/get?name=" + searchcall + "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json" - #response = urllib.urlopen(url) + searchcall = fromcall # if no second argument, search for calling station + url = ( + "http://api.aprs.fi/api/get?name=" + + searchcall + + "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json" + ) response = requests.get(url) - #aprs_data = json.loads(response.read()) + # aprs_data = json.loads(response.read()) aprs_data = json.loads(response.text) - lat = aprs_data['entries'][0]['lat'] - lon = aprs_data['entries'][0]['lng'] + lat = aprs_data["entries"][0]["lat"] + lon = aprs_data["entries"][0]["lng"] try: # altitude not always provided - alt = aprs_data['entries'][0]['altitude'] + alt = aprs_data["entries"][0]["altitude"] except Exception: alt = 0 altfeet = int(alt * 3.28084) - aprs_lasttime_seconds = aprs_data['entries'][0]['lasttime'] - aprs_lasttime_seconds = aprs_lasttime_seconds.encode('ascii', errors='ignore') # unicode to ascii + aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"] + aprs_lasttime_seconds = aprs_lasttime_seconds.encode( + "ascii", errors="ignore" + ) # unicode to ascii delta_seconds = time.time() - int(aprs_lasttime_seconds) delta_hours = delta_seconds / 60 / 60 - url2 = "https://forecast.weather.gov/MapClick.php?lat=" + str(lat) + "&lon=" + str(lon) + "&FcstType=json" - #response2 = urllib.urlopen(url2) + url2 = ( + "https://forecast.weather.gov/MapClick.php?lat=" + + str(lat) + + "&lon=" + + str(lon) + + "&FcstType=json" + ) response2 = requests.get(url2) - #wx_data = json.loads(response2.read()) + # wx_data = json.loads(response2.read()) wx_data = json.loads(response2.text) - reply = searchcall + ": " + wx_data['location']['areaDescription'] + " " + str(altfeet) + "' " + str(lat) + "," + str(lon) + " " + str("%.1f" % round(delta_hours, 1)) + "h ago" - #reply = reply.encode('ascii', errors='ignore') # unicode to ascii + reply = ( + searchcall + + ": " + + wx_data["location"]["areaDescription"] + + " " + + str(altfeet) + + "' " + + str(lat) + + "," + + str(lon) + + " " + + str("%.1f" % round(delta_hours, 1)) + + "h ago" + ) + # reply = reply.encode('ascii', errors='ignore') # unicode to ascii send_message(fromcall, reply.rstrip()) except Exception as e: LOG.debug("Locate failed with: " + "%s" % str(e)) - reply = "Unable to find station " + searchcall + ". Sending beacons?" + reply = ( + "Unable to find station " + searchcall + ". Sending beacons?" + ) send_message(fromcall, reply.rstrip()) # WEATHER (w) "42F(68F/48F) Haze. Tonight, Haze then Chance Rain." - elif re.search('^[wW]', message): + elif re.search("^[wW]", message): LOG.debug("WEATHER") # get my last location from aprsis then get weather from # weather service try: - url = ("http://api.aprs.fi/api/get?" "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json" "&name=%s" % fromcall) - #response = urllib.urlopen(url) + url = ( + "http://api.aprs.fi/api/get?" + "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json" + "&name=%s" % fromcall + ) response = requests.get(url) - #aprs_data = json.loads(response.read()) + # aprs_data = json.loads(response.read()) aprs_data = json.loads(response.text) - lat = aprs_data['entries'][0]['lat'] - lon = aprs_data['entries'][0]['lng'] - url2 = ("https://forecast.weather.gov/MapClick.php?lat=%s" "&lon=%s&FcstType=json" % (lat, lon)) - #response2 = urllib.urlopen(url2) + lat = aprs_data["entries"][0]["lat"] + lon = aprs_data["entries"][0]["lng"] + url2 = ( + "https://forecast.weather.gov/MapClick.php?lat=%s" + "&lon=%s&FcstType=json" % (lat, lon) + ) response2 = requests.get(url2) - #wx_data = json.loads(response2.read()) + # wx_data = json.loads(response2.read()) wx_data = json.loads(response2.text) reply = "%sF(%sF/%sF) %s. %s, %s." % ( - wx_data['currentobservation']['Temp'], - wx_data['data']['temperature'][0], - wx_data['data']['temperature'][1], - wx_data['data']['weather'][0], - wx_data['time']['startPeriodName'][1], - wx_data['data']['weather'][1]) + wx_data["currentobservation"]["Temp"], + wx_data["data"]["temperature"][0], + wx_data["data"]["temperature"][1], + wx_data["data"]["weather"][0], + wx_data["time"]["startPeriodName"][1], + wx_data["data"]["weather"][1], + ) LOG.debug("reply: " + reply.rstrip()) send_message(fromcall, reply.rstrip()) except Exception as e: @@ -915,7 +1025,12 @@ def server(loglevel, quiet): except Exception as e: LOG.error("Error in mainline loop:") LOG.error("%s" % str(e)) - if ( str(e) == "closed_socket" or str(e) == "timed out" or str(e) == "Temporary failure in name resolution" or str(e) == "Network is unreachable"): + if ( + str(e) == "closed_socket" + or str(e) == "timed out" + or str(e) == "Temporary failure in name resolution" + or str(e) == "Network is unreachable" + ): LOG.error("Attempting to reconnect.") sock_file.close() sock.shutdown(0) @@ -925,7 +1040,7 @@ def server(loglevel, quiet): LOG.error("Unexpected error: " + str(e)) LOG.error("Continuing anyway.") time.sleep(5) - continue # don't know what failed, so wait and then continue main loop again + continue # don't know what failed, so wait and then continue main loop again LOG.debug("Main loop end") # end while True diff --git a/aprsd/utils.py b/aprsd/utils.py index c1ade66..347fdb8 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -1,40 +1,44 @@ """Utilities and helper functions.""" -import logging +import errno import os import sys + +import click import yaml # an example of what should be in the ~/.aprsd/config.yml -example_config = ''' -ham: - callsign: KFART +DEFAULT_CONFIG_DICT = { + "ham": {"callsign": "KFART"}, + "aprs": { + "login": "someusername", + "password": "somepassword", + "host": "noam.aprs2.net", + "port": 14580, + "logfile": "/tmp/arsd.log", + }, + "shortcuts": { + "aa": "5551239999@vtext.com", + "cl": "craiglamparter@somedomain.org", + "wb": "555309@vtext.com", + }, + "smtp": { + "login": "something", + "password": "some lame password", + "host": "imap.gmail.com", + "port": 465, + "use_ssl": False, + }, + "imap": { + "login": "imapuser", + "password": "something here too", + "host": "imap.gmail.com", + "port": 993, + "use_ssl": True, + }, +} -aprs: - login: someusername - password: password - host: noam.aprs2.net - port: 14580 - logfile: /tmp/aprsd.log - -shortcuts: - 'aa': '5551239999@vtext.com' - 'cl': 'craiglamparter@somedomain.org' - 'wb': '555309@vtext.com' - -smtp: - login: something - password: some lame password - host: imap.gmail.com - port: 465 - -imap: - login: imapuser - password: something dumb - host: imap.gmail.com -''' - -log = logging.getLogger('APRSD') +DEFAULT_CONFIG_FILE = "~/.config/aprsd/aprsd.yml" def env(*vars, **kwargs): @@ -45,20 +49,56 @@ def env(*vars, **kwargs): value = os.environ.get(v, None) if value: return value - return kwargs.get('default', '') + return kwargs.get("default", "") -def get_config(): - """This tries to read the yaml config from ~/.aprsd/config.yml.""" - config_file = os.path.expanduser("~/.aprsd/config.yml") - if os.path.exists(config_file): - with open(config_file, "r") as stream: +def mkdir_p(path): + """Make directory and have it work in py2 and py3.""" + try: + os.makedirs(path) + except OSError as exc: # Python >= 2.5 + if exc.errno == errno.EEXIST and os.path.isdir(path): + pass + else: + raise + + +def create_default_config(): + """Create a default config file.""" + # make sure the directory location exists + config_file_expanded = os.path.expanduser(DEFAULT_CONFIG_FILE) + config_dir = os.path.dirname(config_file_expanded) + if not os.path.exists(config_dir): + click.echo("Config dir '{}' doesn't exist, creating.".format(config_dir)) + mkdir_p(config_dir) + with open(config_file_expanded, "w+") as cf: + yaml.dump(DEFAULT_CONFIG_DICT, cf) + + +def get_config(config_file): + """This tries to read the yaml config from .""" + config_file_expanded = os.path.expanduser(config_file) + if os.path.exists(config_file_expanded): + with open(config_file_expanded, "r") as stream: config = yaml.load(stream, Loader=yaml.FullLoader) return config else: - log.critical("%s is missing, please create config file" % config_file) - print("\nCopy to ~/.aprsd/config.yml and edit\n\nSample config:\n %s" - % example_config) + if config_file == DEFAULT_CONFIG_FILE: + click.echo( + "{} is missing, creating config file".format(config_file_expanded) + ) + create_default_config() + msg = ( + "Default config file created at {}. Please edit with your " + "settings.".format(config_file) + ) + click.echo(msg) + else: + # The user provided a config file path different from the + # Default, so we won't try and create it, just bitch and bail. + msg = "Custom config file '{}' is missing.".format(config_file) + click.echo(msg) + sys.exit(-1) @@ -66,42 +106,55 @@ def get_config(): # and consume the settings. # If the required params don't exist, # it will look in the environment -def parse_config(): +def parse_config(config_file): # for now we still use globals....ugh - global CONFIG, LOG + global CONFIG def fail(msg): - LOG.critical(msg) + click.echo(msg) sys.exit(-1) - def check_option(config, section, name=None, default=None): + def check_option(config, section, name=None, default=None, default_fail=None): if section in config: + if name and name not in config[section]: if not default: - fail("'%s' was not in '%s' section of config file" % - (name, section)) + fail( + "'%s' was not in '%s' section of config file" % (name, section) + ) else: config[section][name] = default + else: + if ( + default_fail + and name in config[section] + and config[section][name] == default_fail + ): + # We have to fail and bail if the user hasn't edited + # this config option. + fail("Config file needs to be edited from provided defaults.") else: fail("'%s' section wasn't in config file" % section) return config - # Now read the ~/.aprds/config.yml - config = get_config() - check_option(config, 'shortcuts') - check_option(config, 'ham', 'callsign') - check_option(config, 'aprs', 'login') - check_option(config, 'aprs', 'password') - check_option(config, 'aprs', 'host') - check_option(config, 'aprs', 'port') - config = check_option(config, 'aprs', 'logfile', './aprsd.log') - check_option(config, 'imap', 'host') - check_option(config, 'imap', 'login') - check_option(config, 'imap', 'password') - check_option(config, 'smtp', 'host') - check_option(config, 'smtp', 'port') - check_option(config, 'smtp', 'login') - check_option(config, 'smtp', 'password') + config = get_config(config_file) + check_option(config, "shortcuts") + # special check here to make sure user has edited the config file + # and changed the ham callsign + check_option( + config, "ham", "callsign", default_fail=DEFAULT_CONFIG_DICT["ham"]["callsign"] + ) + check_option(config, "aprs", "login") + check_option(config, "aprs", "password") + check_option(config, "aprs", "host") + check_option(config, "aprs", "port") + check_option(config, "aprs", "logfile", "./aprsd.log") + check_option(config, "imap", "host") + check_option(config, "imap", "login") + check_option(config, "imap", "password") + check_option(config, "smtp", "host") + check_option(config, "smtp", "port") + check_option(config, "smtp", "login") + check_option(config, "smtp", "password") return config - LOG.info("aprsd config loaded") diff --git a/dev-requirements.in b/dev-requirements.in new file mode 100644 index 0000000..dcd980a --- /dev/null +++ b/dev-requirements.in @@ -0,0 +1,9 @@ +tox +pytest +pytest-cov +mypy +flake8 +pep8-naming +black +isort +Sphinx diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..1735889 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,60 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile dev-requirements.in +# +alabaster==0.7.12 # via sphinx +appdirs==1.4.4 # via black, virtualenv +attrs==20.3.0 # via pytest +babel==2.9.0 # via sphinx +black==20.8b1 # via -r dev-requirements.in +certifi==2020.12.5 # via requests +chardet==3.0.4 # via requests +coverage==5.3 # via pytest-cov +distlib==0.3.1 # via virtualenv +docutils==0.16 # via sphinx +filelock==3.0.12 # via tox, virtualenv +flake8-polyfill==1.0.2 # via pep8-naming +flake8==3.8.4 # via -r dev-requirements.in, flake8-polyfill +idna==2.10 # via requests +imagesize==1.2.0 # via sphinx +iniconfig==1.1.1 # via pytest +isort==5.6.4 # via -r dev-requirements.in +jinja2==2.11.2 # via sphinx +markupsafe==1.1.1 # via jinja2 +mccabe==0.6.1 # via flake8 +mypy-extensions==0.4.3 # via black, mypy +mypy==0.790 # via -r dev-requirements.in +packaging==20.7 # via pytest, sphinx, tox +pathspec==0.8.1 # via black +pep8-naming==0.11.1 # via -r dev-requirements.in +pluggy==0.13.1 # via pytest, tox +py==1.9.0 # via pytest, tox +pycodestyle==2.6.0 # via flake8 +pyflakes==2.2.0 # via flake8 +pygments==2.7.3 # via sphinx +pyparsing==2.4.7 # via packaging +pytest-cov==2.10.1 # via -r dev-requirements.in +pytest==6.1.2 # via -r dev-requirements.in, pytest-cov +pytz==2020.4 # via babel +regex==2020.11.13 # via black +requests==2.25.0 # via sphinx +six==1.15.0 # via tox, virtualenv +snowballstemmer==2.0.0 # via sphinx +sphinx==3.3.1 # via -r dev-requirements.in +sphinxcontrib-applehelp==1.0.2 # via sphinx +sphinxcontrib-devhelp==1.0.2 # via sphinx +sphinxcontrib-htmlhelp==1.0.3 # via sphinx +sphinxcontrib-jsmath==1.0.1 # via sphinx +sphinxcontrib-qthelp==1.0.3 # via sphinx +sphinxcontrib-serializinghtml==1.1.4 # via sphinx +toml==0.10.2 # via black, pytest, tox +tox==3.20.1 # via -r dev-requirements.in +typed-ast==1.4.1 # via black, mypy +typing-extensions==3.7.4.3 # via black, mypy +urllib3==1.26.2 # via requests +virtualenv==20.2.2 # via tox + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements.txt b/requirements.txt index a784c14..2e9c7bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ imapclient pbr pyyaml six +requests diff --git a/setup.py b/setup.py index 056c16c..90623e2 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,4 @@ try: except ImportError: pass -setuptools.setup( - setup_requires=['pbr'], - pbr=True) +setuptools.setup(setup_requires=["pbr"], pbr=True) diff --git a/test-requirements-py2.txt b/test-requirements-py2.txt new file mode 100644 index 0000000..e546d2f --- /dev/null +++ b/test-requirements-py2.txt @@ -0,0 +1,3 @@ +flake8 +pytest +mock diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..a339f03 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +import sys +import unittest + +import pytest + +from aprsd import main + +if sys.version_info >= (3, 2): + from unittest import mock +else: + import mock + + +class testMain(unittest.TestCase): + @mock.patch("aprsd.main._imap_connect") + @mock.patch("aprsd.main._smtp_connect") + def test_validate_email(self, imap_mock, smtp_mock): + """Test to make sure we fail.""" + imap_mock.return_value = None + smtp_mock.return_value = {"smaiof": "fire"} + + main.validate_email() diff --git a/tox.ini b/tox.ini index 488fea3..7deaf6a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,51 @@ [tox] -minversion = 1.6 +minversion = 2.9.0 skipdist = True -envlist = py27,py36,py37,fast8,pep8,cover,docs +skip_missing_interpreters = true +envlist = py{27,36,37,38},pep8,fmt-check + +# Activate isolated build environment. tox will use a virtual environment +# to build a source distribution from the source tree. For build tools and +# arguments use the pyproject.toml file as specified in PEP-517 and PEP-518. +isolated_build = true [testenv] setenv = VIRTUAL_ENV={envdir} usedevelop = True install_command = pip install {opts} {packages} - deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt + -r{toxinidir}/dev-requirements.txt commands = - pytest aprsd/main.py {posargs} + # Use -bb to enable BytesWarnings as error to catch str/bytes misuse. + # Use -Werror to treat warnings as errors. +# {envpython} -bb -Werror -m pytest \ +# --cov="{envsitepackagesdir}/aprsd" --cov-report=html --cov-report=term {posargs} + {envpython} -bb -Werror -m pytest {posargs} -[testenv:cover] +[testenv:py27] +setenv = VIRTUAL_ENV={envdir} +usedevelop = True +install_command = pip install {opts} {packages} +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements-py2.txt commands = - pytest --cov=aprsd + # Use -bb to enable BytesWarnings as error to catch str/bytes misuse. + # Use -Werror to treat warnings as errors. +# {envpython} -bb -Werror -m pytest \ +# --cov="{envsitepackagesdir}/aprsd" --cov-report=html --cov-report=term {posargs} + {envpython} -bb -Werror -m pytest [testenv:docs] deps = -r{toxinidir}/test-requirements.txt commands = sphinx-build -b html docs/source docs/html +[testenv:pep8-27] +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements-py2.txt +commands = + flake8 {posargs} aprsd + [testenv:pep8] commands = flake8 {posargs} aprsd @@ -33,7 +58,57 @@ commands = {toxinidir}/tools/fast8.sh passenv = FAST8_NUM_COMMITS +[testenv:lint] +skip_install = true +deps = + -r{toxinidir}/dev-requirements.txt +commands = + flake8 aprsd + [flake8] +max-line-length = 99 show-source = True -ignore = E713,E501 +ignore = E713,E501,W503 +extend-ignore = E203,W503 +extend-exclude = venv exclude = .venv,.git,.tox,dist,doc,.ropeproject + +# This is the configuration for the tox-gh-actions plugin for GitHub Actions +# https://github.com/ymyzk/tox-gh-actions +# This section is not needed if not using GitHub Actions for CI. +[gh-actions] +python = + 2.7: py27, pep8-27 + 3.6: py36, pep8, fmt-check + 3.7: py38, pep8, fmt-check + 3.8: py38, pep8, fmt-check, type-check, docs + 3.9: py39 + +[testenv:fmt] +# This will reformat your code to comply with pep8 +# and standard formatting +skip_install = true +deps = + -r{toxinidir}/dev-requirements.txt +commands = + isort . + black . + +[testenv:fmt-check] +# Runs a check only on code formatting. +# you can fix imports by running isort standalone +# you can fix code formatting by running black standalone +skip_install = true +deps = + -r{toxinidir}/dev-requirements.txt +commands = + isort --check-only . + black --check . + +[testenv:type-check] +skip_install = true +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/dev-requirements.txt +commands = + mypy aprsd