-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #11 from PyCampES/interactive_category_skeleton
Interactive category skeleton
- Loading branch information
Showing
7 changed files
with
200 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -167,3 +167,7 @@ cython_debug/ | |
|
||
samples/ | ||
.ruff_cache/ | ||
categories_database.json | ||
ficamp.db | ||
gcache.json | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,41 +1,177 @@ | ||
import argparse | ||
import json | ||
import os | ||
import shutil | ||
from enum import StrEnum | ||
|
||
import questionary | ||
from dotenv import load_dotenv | ||
from sqlmodel import Session, SQLModel, create_engine, select | ||
|
||
from ficamp.classifier.infer import infer_tx_category | ||
from ficamp.datastructures import Tx | ||
from ficamp.parsers.abn import AbnParser | ||
|
||
|
||
def cli() -> argparse.Namespace: | ||
"""Parses the first argument from the command line and prints it.""" | ||
"""Creates a command line interface with subcommands for import and categorize.""" | ||
|
||
# Create an argument parser | ||
# Create the main parser | ||
parser = argparse.ArgumentParser( | ||
prog="ficamp", description="Print the first argument from the CLI" | ||
prog="ficamp", description="Parse and categorize your expenses." | ||
) | ||
|
||
parser.add_argument("--bank", choices=["abn"], default="abn") | ||
parser.add_argument("filename", help="The spreadsheet to load") | ||
# Create subparsers for the two subcommands | ||
subparsers = parser.add_subparsers(dest="command", required=True) | ||
|
||
# Subparser for the import command | ||
import_parser = subparsers.add_parser("import", help="Import a Transactions") | ||
import_parser.add_argument( | ||
"--bank", choices=["abn"], default="abn", help="Specify the bank for the import" | ||
) | ||
import_parser.add_argument("filename", help="File to load") | ||
import_parser.set_defaults(func=import_data) | ||
|
||
# Subparser for the categorize command | ||
categorize_parser = subparsers.add_parser( | ||
"categorize", help="Categorize transactions" | ||
) | ||
categorize_parser.add_argument("--infer-category", action="store_true") | ||
categorize_parser.set_defaults(func=categorize) | ||
|
||
# Parse the arguments | ||
args = parser.parse_args() | ||
|
||
# Print the first argument | ||
return args | ||
|
||
|
||
def main(): | ||
args = cli() | ||
args.filename | ||
args.bank | ||
|
||
def import_data(args, engine): | ||
"""Run the parsers.""" | ||
print(f"Importing data from {args.filename} for bank {args.bank}.") | ||
# TODO: Build enum for banks | ||
if args.bank == "abn": | ||
parser = AbnParser() | ||
parser.load(args.filename) | ||
transactions = parser.parse() | ||
print(transactions) | ||
# TODO: Add categorizer! | ||
for tx in transactions: | ||
with Session(engine) as session: | ||
# Assuming 'date' and 'amount' can uniquely identify a transaction | ||
statement = select(Tx).where( | ||
Tx.date == tx.date, Tx.amount == tx.amount, Tx.concept == tx.concept | ||
) | ||
result = session.exec(statement).first() | ||
if result is None: # No existing transaction found | ||
session.add(tx) | ||
session.commit() | ||
else: | ||
print(f"Transaction already exists in the database. {tx}") | ||
|
||
|
||
def get_category_dict(categories_database_path="categories_database.json"): | ||
# FIXME: move categories to SQLITE instead of json file. | ||
if not os.path.exists(categories_database_path): | ||
return {} | ||
with open(categories_database_path, "r") as file: | ||
category_dict = json.load(file) | ||
string_to_category = { | ||
string: category | ||
for category, strings in category_dict.items() | ||
for string in strings | ||
} | ||
return string_to_category | ||
|
||
|
||
def revert_and_save_dict(string_to_category, filename="categories_database.json"): | ||
# Reverting the dictionary | ||
category_to_strings = {} | ||
for string, category in string_to_category.items(): | ||
category_to_strings.setdefault(category, []).append(string) | ||
|
||
# Saving to a JSON file | ||
if os.path.exists(filename): | ||
shutil.move(filename, "/tmp/categories_db_bkp.json") | ||
with open(filename, "w") as file: | ||
json.dump(category_to_strings, file, indent=4) | ||
|
||
|
||
class DefaultAnswers(StrEnum): | ||
SKIP = "Skip this Tx" | ||
NEW = "Type a new category" | ||
|
||
|
||
def query_business_category(tx, categories_dict, infer_category=False): | ||
# first try to get from the category_dict | ||
category = categories_dict.get(tx.concept) | ||
if category: | ||
return category | ||
# ask the user if we don't know it | ||
categories_choices = list(set(categories_dict.values())) | ||
categories_choices.extend([DefaultAnswers.NEW, DefaultAnswers.SKIP]) | ||
default_choice = DefaultAnswers.SKIP | ||
if infer_category: | ||
inferred_category = infer_tx_category(tx) | ||
if inferred_category: | ||
categories_choices.append(inferred_category) | ||
default_choice = inferred_category | ||
print(f"{tx.date.isoformat()} {tx.amount} {tx.concept}") | ||
answer = questionary.select( | ||
"Please select the category for this TX", | ||
choices=categories_choices, | ||
default=default_choice, | ||
show_selected=True, | ||
).ask() | ||
if answer == DefaultAnswers.NEW: | ||
answer = questionary.text("What's the category for the TX above").ask() | ||
if answer == DefaultAnswers.SKIP: | ||
return None | ||
if answer is None: | ||
# https://questionary.readthedocs.io/en/stable/pages/advanced.html#keyboard-interrupts | ||
raise KeyboardInterrupt | ||
if answer: | ||
categories_dict[tx.concept] = answer | ||
category = answer | ||
return category | ||
|
||
|
||
def categorize(args, engine): | ||
"""Function to categorize transactions.""" | ||
categories_dict = get_category_dict() | ||
try: | ||
with Session(engine) as session: | ||
statement = select(Tx).where(Tx.category.is_(None)) | ||
results = session.exec(statement).all() | ||
for tx in results: | ||
print(f"Processing {tx}") | ||
tx_category = query_business_category( | ||
tx, categories_dict, infer_category=args.infer_category | ||
) | ||
if tx_category: | ||
print(f"Saving category for {tx.concept}: {tx_category}") | ||
tx.category = tx_category | ||
# update DB | ||
session.add(tx) | ||
session.commit() | ||
revert_and_save_dict(categories_dict) | ||
else: | ||
print("Not saving any category for thi Tx") | ||
revert_and_save_dict(categories_dict) | ||
except KeyboardInterrupt: | ||
print("Closing") | ||
|
||
|
||
def main(): | ||
# create DB | ||
engine = create_engine("sqlite:///ficamp.db") | ||
# create tables | ||
SQLModel.metadata.create_all(engine) | ||
|
||
try: | ||
args = cli() | ||
if args.command: | ||
args.func(args, engine) | ||
except KeyboardInterrupt: | ||
print("\nClosing") | ||
|
||
|
||
load_dotenv() | ||
main() | ||
if __name__ == "__main__": | ||
load_dotenv() | ||
main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
from ficamp.classifier.google_apis import query_gmaps_category | ||
|
||
|
||
def infer_tx_category(tx): | ||
"""Will try to guess the category using different actions.""" | ||
gmap_category = query_gmaps_category(tx.concept) | ||
if gmap_category != "Unknown": | ||
print(f"Google Maps category is {gmap_category}") | ||
return gmap_category |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters