Implement fuzzy category input

This commit is contained in:
2023-06-27 17:29:07 +02:00
parent ba0c906e3c
commit 3622ceb7ca
4 changed files with 159 additions and 1 deletions

126
src/fzf.py Normal file
View File

@@ -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

View File

@@ -4,6 +4,9 @@ from pathlib import Path
from typing import List from typing import List
UNKNOWN_CATEGORY = 'account2'
class CsvConfig(BaseModel): class CsvConfig(BaseModel):
""" """
Class to define how to parse a certain CSV file. We use the Class to define how to parse a certain CSV file. We use the

26
src/predict.py Normal file
View File

@@ -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}'.")

View File

@@ -5,6 +5,8 @@ import sys
import datetime import datetime
import src.utils import src.utils
import src.write import src.write
import src.models
import src.predict
from src.models import Config, CsvConfig, Transaction from src.models import Config, CsvConfig, Transaction
from typing import List, Dict from typing import List, Dict
@@ -47,7 +49,7 @@ def get_transactions(csv_file: str, config: CsvConfig) -> List[Transaction]:
credit=amount, credit=amount,
date=date_to_date(t['date']), date=date_to_date(t['date']),
account1=config.account1, account1=config.account1,
account2="account2", account2=src.models.UNKNOWN_CATEGORY,
description=t['description'], description=t['description'],
csv_file=csv_file, csv_file=csv_file,
row=csv_file + ", " + ", ".join(row)) row=csv_file + ", " + ", ".join(row))
@@ -97,6 +99,7 @@ def process_csv_files(config: Config):
find_duplicates(transactions) find_duplicates(transactions)
mappings = src.utils.read_mappings(config.mappings_file) mappings = src.utils.read_mappings(config.mappings_file)
apply_mappings(transactions, mappings) apply_mappings(transactions, mappings)
src.predict.add_account2(transactions)
src.utils.write_mappings(transactions, config.mappings_file) src.utils.write_mappings(transactions, config.mappings_file)
src.write.render_to_file(transactions, config.output_file) src.write.render_to_file(transactions, config.output_file)