refactor config / parser loading

This commit is contained in:
Keenan Tims 2025-04-24 16:04:34 -07:00
parent 90bf08484a
commit 336b732c60

View File

@ -1,9 +1,11 @@
#!/usr/bin/env python #!/usr/bin/env python
import asyncio import asyncio
import email.policy import email.policy
import importlib.util
import logging import logging
import os import os
import ssl import ssl
from dataclasses import dataclass
from email.message import EmailMessage from email.message import EmailMessage
from imaplib import IMAP4 from imaplib import IMAP4
from logging import debug from logging import debug
@ -14,19 +16,12 @@ from os import getenv
from pprint import pprint from pprint import pprint
from typing import cast from typing import cast
from config import PARSERS
from model import Transaction from model import Transaction
from parsers import TransactionParsingFailed from parsers import TransactionParsingFailed
IMAP_SERVER = getenv("IMAP_SERVER")
IMAP_PORT = int(getenv("IMAP_PORT", 143))
IMAP_USER = getenv("IMAP_USER")
IMAP_PASS = getenv("IMAP_PASS")
IMAP_MAILBOX = getenv("IMAP_MAILBOX", "INBOX")
IMAP_INTERVAL = float(getenv("IMAP_INTERVAL", 300))
CONFIG_PATH = getenv("CONFIG_PATH", "/data/config.py")
ACTUAL_PATH = "./cli.js" ACTUAL_PATH = "./cli.js"
TIMEOUT = 30 TIMEOUT = 30
@ -36,7 +31,43 @@ async def ticker(interval: float):
await asyncio.sleep(interval) await asyncio.sleep(interval)
async def submit_transaction(t: Transaction): def load_config_module(path: str):
spec = importlib.util.spec_from_file_location("config", path)
config = importlib.util.module_from_spec(spec)
spec.loader.exec_module(config)
return config
@dataclass
class AppConfig:
@staticmethod
def from_environ() -> "AppConfig":
return AppConfig(
imap_server=getenv("IMAP_SERVER"),
imap_port=int(getenv("IMAP_PORT", 143)),
imap_user=getenv("IMAP_USER"),
imap_pass=getenv("IMAP_PASS"),
imap_mailbox=getenv("IMAP_MAILBOX", "INBOX"),
imap_interval=float(getenv("IMAP_INTERVAL", 300)),
imap_starttls=bool(getenv("IMAP_STARTTLS", True)),
)
imap_server: str
imap_user: str
imap_pass: str
imap_port: int = 143
imap_mailbox: str = "INBOX"
imap_interval: int = 300
imap_starttls: bool = True
class App:
def __init__(self, app_config: AppConfig, parser_config_path: str):
config_mod = load_config_module(parser_config_path)
self._parsers = config_mod.PARSERS
self._config = app_config
async def submit_transaction(self, t: Transaction):
cmd = ( cmd = (
ACTUAL_PATH ACTUAL_PATH
+ f' -a "{t.account}"' + f' -a "{t.account}"'
@ -56,8 +87,7 @@ async def submit_transaction(t: Transaction):
if proc.returncode != 0: if proc.returncode != 0:
error("Submitting to actual failed: %s", stdout) error("Submitting to actual failed: %s", stdout)
async def process_message(self, msg_b: bytes):
async def process_message(msg_b: bytes):
debug("parsing message") debug("parsing message")
msg = cast( msg = cast(
EmailMessage, email.message_from_bytes(msg_b, policy=email.policy.default) EmailMessage, email.message_from_bytes(msg_b, policy=email.policy.default)
@ -70,41 +100,41 @@ async def process_message(msg_b: bytes):
msg.get("Subject", "<no subject>"), msg.get("Subject", "<no subject>"),
) )
for parser in PARSERS: for parser in self._parsers:
debug("Running parser %s", type(parser).__name__) debug("Running parser %s", type(parser).__name__)
try: try:
if parser.match(msg): if parser.match(msg):
info("Parser %s claimed message", type(parser).__name__) info("Parser %s claimed message", type(parser).__name__)
trans = parser.extract(msg) trans = parser.extract(msg)
info("Submitting transaction to Actual: %s", trans) info("Submitting transaction to Actual: %s", trans)
await submit_transaction(trans) await self.submit_transaction(trans)
except TransactionParsingFailed as e: except TransactionParsingFailed as e:
warning("Unable to parse message %s", e) warning("Unable to parse message %s", e)
except Exception as e: except Exception as e:
warning("Unexpected exception %s", e) warning("Unexpected exception %s", e)
async def poll_imap(self):
async def poll_imap():
info("polling mailbox") info("polling mailbox")
with IMAP4(IMAP_SERVER, IMAP_PORT, 30) as M: with IMAP4(self._config.imap_server, self._config.imap_port, TIMEOUT) as M:
if self._config.imap_starttls:
context = ssl.create_default_context() context = ssl.create_default_context()
M.starttls(context) M.starttls(context)
M.login(IMAP_USER, IMAP_PASS) M.login(self._config.imap_user, self._config.imap_pass)
M.select(IMAP_MAILBOX) M.select(self._config.imap_mailbox)
status, m_set = M.search(None, "UNSEEN") status, m_set = M.search(None, "UNSEEN")
for msg_id in m_set[0].split(): for msg_id in m_set[0].split():
debug("Retrieving msg id %s", msg_id) debug("Retrieving msg id %s", msg_id)
status, msg = M.fetch(msg_id, "(RFC822)") status, msg = M.fetch(msg_id, "(RFC822)")
await process_message(msg[0][1]) await self.process_message(msg[0][1])
async def run(self):
async def app(): async for tick in ticker(self._config.imap_interval):
async for tick in ticker(IMAP_INTERVAL): await self.poll_imap()
await poll_imap()
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=getenv("LOG_LEVEL", "INFO")) logging.basicConfig(level=getenv("LOG_LEVEL", "INFO"))
debug("Parsers: %s", PARSERS) app_config = AppConfig.from_environ()
asyncio.run(app()) app = App(app_config, CONFIG_PATH)
asyncio.run(app.run())