From 3622ceb7ca18cac44eec69fc68b4f633a1a5bf26 Mon Sep 17 00:00:00 2001 From: felixm Date: Tue, 27 Jun 2023 17:29:07 +0200 Subject: [PATCH] Implement fuzzy category input --- src/fzf.py | 126 +++++++++++++++++++++++++++++++++++++++++++++++++ src/models.py | 3 ++ src/predict.py | 26 ++++++++++ src/process.py | 5 +- 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/fzf.py create mode 100644 src/predict.py diff --git a/src/fzf.py b/src/fzf.py new file mode 100644 index 0000000..120e2c7 --- /dev/null +++ b/src/fzf.py @@ -0,0 +1,126 @@ +from __future__ import print_function + +import errno +import os.path +import subprocess +import sys + +from pkg_resources import resource_exists, resource_filename + +__all__ = 'BUNDLED_EXECUTABLE', 'iterfzf' + +EXECUTABLE_NAME = 'fzf.exe' if sys.platform == 'win32' else 'fzf' +BUNDLED_EXECUTABLE = ( + resource_filename(__name__, EXECUTABLE_NAME) + if resource_exists(__name__, EXECUTABLE_NAME) + else ( + os.path.join(os.path.dirname(__file__), EXECUTABLE_NAME) + if os.path.isfile( + os.path.join(os.path.dirname(__file__), EXECUTABLE_NAME) + ) + else None + ) +) + + +def iterfzf( + # CHECK: When the signature changes, __init__.pyi file should also change. + iterable, + # Search mode: + extended=True, exact=False, case_sensitive=None, + # Interface: + multi=False, mouse=True, print_query=False, + # Layout: + prompt='> ', + ansi=None, + preview=None, + # Misc: + query='', encoding=None, executable=BUNDLED_EXECUTABLE or EXECUTABLE_NAME +): + cmd = [executable, '--no-sort', '--prompt=' + prompt] + cmd = [executable, '--prompt=' + prompt] + if not extended: + cmd.append('--no-extended') + if case_sensitive is not None: + cmd.append('+i' if case_sensitive else '-i') + if exact: + cmd.append('--exact') + if multi: + cmd.append('--multi') + if not mouse: + cmd.append('--no-mouse') + if print_query: + cmd.append('--print-query') + if query: + cmd.append('--query=' + query) + if preview: + cmd.append('--preview=' + preview) + if ansi: + cmd.append('--ansi') + encoding = encoding or sys.getdefaultencoding() + proc = None + stdin = None + byte = None + lf = u'\n' + cr = u'\r' + for line in iterable: + if byte is None: + byte = isinstance(line, bytes) + if byte: + lf = b'\n' + cr = b'\r' + elif isinstance(line, bytes) is not byte: + raise ValueError( + 'element values must be all byte strings or all ' + 'unicode strings, not mixed of them: ' + repr(line) + ) + if lf in line or cr in line: + raise ValueError(r"element values must not contain CR({1!r})/" + r"LF({2!r}): {0!r}".format(line, cr, lf)) + if proc is None: + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=None + ) + stdin = proc.stdin + if not byte: + line = line.encode(encoding) + try: + stdin.write(line + b'\n') + stdin.flush() + except IOError as e: + if e.errno != errno.EPIPE and errno.EPIPE != 32: + raise + break + stdin.close() + if proc is None or proc.wait() not in [0, 1]: + if print_query: + return None, None + else: + return None + try: + stdin.close() + except IOError as e: + if e.errno != errno.EPIPE and errno.EPIPE != 32: + raise + stdout = proc.stdout + decode = (lambda b: b) if byte else (lambda t: t.decode(encoding)) + output = [decode(ln.strip(b'\r\n\0')) for ln in iter(stdout.readline, b'')] + if print_query: + try: + if multi: + return output[0], output[1:] + else: + return output[0], output[1] + except IndexError: + return output[0], None + else: + if multi: + return output + else: + try: + return output[0] + except IndexError: + return None diff --git a/src/models.py b/src/models.py index 8f837b1..3d1a57f 100644 --- a/src/models.py +++ b/src/models.py @@ -4,6 +4,9 @@ from pathlib import Path from typing import List +UNKNOWN_CATEGORY = 'account2' + + class CsvConfig(BaseModel): """ Class to define how to parse a certain CSV file. We use the diff --git a/src/predict.py b/src/predict.py new file mode 100644 index 0000000..69e5ad3 --- /dev/null +++ b/src/predict.py @@ -0,0 +1,26 @@ +from src.models import Transaction, UNKNOWN_CATEGORY +from src.fzf import iterfzf +from typing import List + + +def get_categories(transactions: List[Transaction]) -> List[str]: + categories = set([t.account2 for t in transactions]) + categories.discard(UNKNOWN_CATEGORY) + return list(categories) + + +def add_account2(transactions: List[Transaction]): + categories = get_categories(transactions) + unmapped_transactions = filter(lambda t: t.account2 == UNKNOWN_CATEGORY, transactions) + for t in unmapped_transactions: + add_account2_interactive(t, categories) + + +def add_account2_interactive(transaction: Transaction, categories: List[str]): + t = transaction + account2 = None + prompt = f"{t.account1} {t.date} {t.description} {t.debit} > " + while account2 is None: + account2 = iterfzf(categories, prompt=prompt) + transaction.account2 = account2 + print(f"Assigned category '{account2}'.") diff --git a/src/process.py b/src/process.py index 1377f9c..c2791ee 100644 --- a/src/process.py +++ b/src/process.py @@ -5,6 +5,8 @@ import sys import datetime import src.utils import src.write +import src.models +import src.predict from src.models import Config, CsvConfig, Transaction from typing import List, Dict @@ -47,7 +49,7 @@ def get_transactions(csv_file: str, config: CsvConfig) -> List[Transaction]: credit=amount, date=date_to_date(t['date']), account1=config.account1, - account2="account2", + account2=src.models.UNKNOWN_CATEGORY, description=t['description'], csv_file=csv_file, row=csv_file + ", " + ", ".join(row)) @@ -97,6 +99,7 @@ def process_csv_files(config: Config): find_duplicates(transactions) mappings = src.utils.read_mappings(config.mappings_file) apply_mappings(transactions, mappings) + src.predict.add_account2(transactions) src.utils.write_mappings(transactions, config.mappings_file) src.write.render_to_file(transactions, config.output_file)