generated from felixm/defaultpy
Implement fuzzy category input
This commit is contained in:
126
src/fzf.py
Normal file
126
src/fzf.py
Normal 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
|
||||||
@@ -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
26
src/predict.py
Normal 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}'.")
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user