Coverage for src/sankey_cashflow/cli.py: 72%
90 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-05 04:36 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-05 04:36 +0000
1import argparse
2import datetime
4import pandas as pd
6from .diagram import build_line_figure, build_sankey_figure
7from .io import fetch_data, read_csv_as_df
8from .labels import RowLabels
9from .settings import AppSettings
10from .transactions import Transactions
11from .utils import logger, save_report, validate_date_string
14def build_arg_parser() -> argparse.ArgumentParser:
15 parser = argparse.ArgumentParser(prog="sankeyd", description="Generate a cashflow Sankey or line diagram.")
16 parser.add_argument("-s", "--source", help="Data source, either a Google sheet or a .csv")
17 parser.add_argument("-t", "--sheet", help="Sheet name, defaults to 'current_exp'")
18 parser.add_argument("--srcmap", help="Location for sources-targets mapping csv (only needed for csv data "
19 "sources). Defaults to 'Sources-Targets' for GSheets")
20 parser.add_argument("-r", "--range", help="Enable time range filtering", action="store_true")
21 parser.add_argument("--separate_tax", help="Show taxes as own flow", action="store_true")
22 parser.add_argument("-v", "--verbose", help="Enable verbose output", action="store_true")
23 # Note: the specified service account needs to have access to the data source document.
24 parser.add_argument("--creds", help="Google service account credentials to use (in json format).")
25 parser.add_argument("--distributions", help="Distribute transactions where noted", action="store_true")
26 parser.add_argument("--tags", help="Comma delimited list of tags to search for, eg 'foo, bar, baz bat'.")
27 parser.add_argument("--exclude", help="Comma delimited list of tags to exclude, eg 'foo, bar, baz bat'.")
28 parser.add_argument("--tag_override", action="store_true",
29 help="Use tag labels to override source/targets instead of appending.")
30 parser.add_argument("--stores", help="Comma delimited list of stores/payees to override source-targets on, "
31 "eg 'Safeway, Bartells, Fred Meyer'.")
32 parser.add_argument("--all_time", action="store_true",
33 help="Include transactions that happen in the future and before 10/1/2022")
34 parser.add_argument("--feed_in", help="If using source-tags, feed surplus into income.", action="store_true")
35 parser.add_argument("--audit", help="Audit source data transactions for missing items", action="store_true")
36 parser.add_argument("--recurring", help="Split recurring expenses out", action="store_true")
37 parser.add_argument("--hover", help="Grouping field for hover text. Defaults to 'Category'")
38 parser.add_argument("--dtype", help="Diagram type to generate (sankey, line). Defaults to 'sankey'")
39 return parser
42def _prompt_for_date_range(app_settings) -> tuple:
43 sdate = input("Enter start date, in form of YYYY-MM-DD or MM/DD/YYYY. (<Enter> to leave unbounded): ")
44 while not validate_date_string(sdate, True):
45 sdate = input(f"Invalid date string ({sdate})! Please enter start date, in form of YYYY-MM-DD or "
46 "MM/DD/YYYY. (<Enter> to leave unbounded): ")
47 edate = input("Enter end date, in form of YYYY-MM-DD. (<Enter> to leave unbounded): ")
48 if not edate:
49 edate = datetime.datetime.now().strftime("%Y-%m-%d")
50 while not validate_date_string(edate, True):
51 edate = input(f"Invalid date string ({edate})! Please enter end date, in form of YYYY-MM-DD or "
52 "MM/DD/YYYY. (<Enter> to leave unbounded): ")
53 try:
54 app_settings.date_filter_start = sdate
55 app_settings.date_filter_end = edate
56 if app_settings.date_filter_start is None and app_settings.date_filter_end is None:
57 print("Need to have at least one of start date or end date to use date filtering!")
58 raise SystemExit(1)
59 except Exception as e:
60 print(f"Could not parse supplied date(s). Error was: {e}")
61 raise SystemExit(1)
62 return sdate, edate
65def main(argv=None):
66 args = build_arg_parser().parse_args(argv)
67 app_settings = AppSettings(args)
69 date_range = None
70 if app_settings.filter_dates:
71 # Doing this first, as it involves user input - throw any errors related to that before fetching data.
72 date_range = _prompt_for_date_range(app_settings)
74 if app_settings.diagram_type == 'line':
75 chart_resolution = input("Enter chart resolution (day, week, month): ")
76 while chart_resolution not in ['day', 'week', 'month']:
77 chart_resolution = input("Invalid chart resolution! Enter chart resolution (day, week, month): ")
78 app_settings.chart_resolution = chart_resolution
80 if app_settings.verbose:
81 logger.info(f"Fetching data from {app_settings.data_source}: {app_settings.data_sheet}...")
82 src_target, df = fetch_data(app_settings)
84 sources_targets = RowLabels(src_target)
85 transactions_data = Transactions(df, sources_targets, app_settings)
87 save_report(sources_targets.process_report, 'labels_report')
89 if app_settings.audit_mode:
90 print("We are in audit mode, so we will not process the data.")
91 audit_file = input("Enter the path to the bank transactions file: ")
92 audit_data = read_csv_as_df(audit_file)
93 audit_data["Date"] = pd.to_datetime(audit_data["Date"]) # Normalize date format
94 audit_data["Amount"] = audit_data["Amount"].apply(lambda x: x * -1) # Convert from negative to positive
95 audit_report = transactions_data.audit(audit_data, date_range)
96 print(audit_report)
97 raise SystemExit
99 if app_settings.diagram_type == 'sankey':
100 transactions_data.process(date_range)
101 elif app_settings.diagram_type == 'line':
102 transactions_data.process_line(date_range)
103 else:
104 print(f"Invalid diagram type: {app_settings.diagram_type}")
105 raise SystemExit(1)
107 save_report(transactions_data.process_report, 'transactions_report')
109 if app_settings.verbose:
110 logger.info(f"Generating diagram of type {app_settings.diagram_type}")
112 if app_settings.diagram_type == 'sankey':
113 fig = build_sankey_figure(transactions_data, sources_targets, app_settings)
114 else:
115 fig = build_line_figure(transactions_data, app_settings)
116 fig.show()
119if __name__ == '__main__':
120 main()