Skip to content

Commit a237110

Browse files
authored
Add quiet CLI mode (#190)
#### Problem The CLI always prints status messages and `tldm` progress output, which makes it awkward to use in scripts or other fully silent workflows. #### Solution Add a `--quiet` flag that suppresses all CLI output, thread that flag through the sync and insert paths, and cover the behavior in tests and README usage docs.
1 parent 98dfdca commit a237110

3 files changed

Lines changed: 136 additions & 25 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ $ sqlite-export-for-ynab
3131
```
3232

3333
Running it again will pull only data that changed since the last pull (this is done with [Delta Requests](https://api.ynab.com/#deltas)). If you want to wipe the DB and pull all data again use the `--full-refresh` flag.
34+
Pass `--quiet` to suppress all CLI output, including progress bars.
3435

3536
<a id="db-path"></a>You can specify the DB path with the following options
3637
1. The `--db` flag.

sqlite_export_for_ynab/_main.py

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,20 @@ async def async_main(
7878
parser.add_argument(
7979
"--version", action="version", version=f"%(prog)s {version(_PACKAGE)}"
8080
)
81+
parser.add_argument(
82+
"--quiet",
83+
action="store_true",
84+
help="Suppress all CLI output, including progress bars.",
85+
)
8186

8287
args = parser.parse_args(argv)
8388
db: Path = args.db
8489
full_refresh: bool = args.full_refresh
90+
quiet: bool = args.quiet
8591

8692
token = resolve_token(token_override)
8793

88-
await sync(token, db, full_refresh)
94+
await sync(token, db, full_refresh, quiet=quiet)
8995

9096
return 0
9197

@@ -102,7 +108,14 @@ def default_db_path() -> Path:
102108
)
103109

104110

105-
async def sync(token: str, db: Path, full_refresh: bool) -> None:
111+
def _print(message: str, *, quiet: bool) -> None:
112+
if not quiet:
113+
print(message)
114+
115+
116+
async def sync(
117+
token: str, db: Path, full_refresh: bool, *, quiet: bool = False
118+
) -> None:
106119
async with aiohttp.ClientSession() as session:
107120
plans = (await YnabClient(token, session)("plans"))["plans"]
108121

@@ -116,22 +129,22 @@ async def sync(token: str, db: Path, full_refresh: bool) -> None:
116129
cur = con.cursor()
117130

118131
if full_refresh:
119-
print("Dropping relations...")
132+
_print("Dropping relations...", quiet=quiet)
120133
cur.executescript(contents("drop-relations.sql"))
121134
con.commit()
122-
print("Done")
135+
_print("Done", quiet=quiet)
123136

124137
relations = get_relations(cur)
125138
if relations != _ALL_RELATIONS:
126-
print("Recreating relations...")
139+
_print("Recreating relations...", quiet=quiet)
127140
cur.executescript(contents("create-relations.sql"))
128141
con.commit()
129-
print("Done")
142+
_print("Done", quiet=quiet)
130143

131-
print("Fetching plan data...")
144+
_print("Fetching plan data...", quiet=quiet)
132145
lkos = get_last_knowledge_of_server(cur)
133146
async with aiohttp.ClientSession() as session:
134-
with tldm(desc="Plan Data", total=len(plans) * 5) as pbar:
147+
with tldm(desc="Plan Data", total=len(plans) * 5, disable=quiet) as pbar:
135148
yc = ProgressYnabClient(YnabClient(token, session), pbar)
136149

137150
account_jobs = jobs(yc, "accounts", plan_ids, lkos)
@@ -161,7 +174,7 @@ async def sync(token: str, db: Path, full_refresh: bool) -> None:
161174
plan_ids, all_txn_data, strict=True
162175
)
163176
}
164-
print("Done")
177+
_print("Done", quiet=quiet)
165178

166179
if (
167180
not any(t["accounts"] for t in all_account_data)
@@ -170,25 +183,27 @@ async def sync(token: str, db: Path, full_refresh: bool) -> None:
170183
and not any(t["transactions"] for t in all_txn_data)
171184
and not any(s["scheduled_transactions"] for s in all_sched_txn_data)
172185
):
173-
print("No new data fetched")
186+
_print("No new data fetched", quiet=quiet)
174187
else:
175-
print("Inserting plan data...")
188+
_print("Inserting plan data...", quiet=quiet)
176189
insert_plans(cur, plans, new_lkos)
177190
for plan_id, account_data in zip(plan_ids, all_account_data, strict=True):
178-
insert_accounts(cur, plan_id, account_data["accounts"])
191+
insert_accounts(cur, plan_id, account_data["accounts"], quiet=quiet)
179192
for plan_id, cat_data in zip(plan_ids, all_cat_data, strict=True):
180-
insert_category_groups(cur, plan_id, cat_data["category_groups"])
193+
insert_category_groups(
194+
cur, plan_id, cat_data["category_groups"], quiet=quiet
195+
)
181196
for plan_id, payee_data in zip(plan_ids, all_payee_data, strict=True):
182-
insert_payees(cur, plan_id, payee_data["payees"])
197+
insert_payees(cur, plan_id, payee_data["payees"], quiet=quiet)
183198
for plan_id, txn_data in zip(plan_ids, all_txn_data, strict=True):
184-
insert_transactions(cur, plan_id, txn_data["transactions"])
199+
insert_transactions(cur, plan_id, txn_data["transactions"], quiet=quiet)
185200
for plan_id, sched_txn_data in zip(
186201
plan_ids, all_sched_txn_data, strict=True
187202
):
188203
insert_scheduled_transactions(
189-
cur, plan_id, sched_txn_data["scheduled_transactions"]
204+
cur, plan_id, sched_txn_data["scheduled_transactions"], quiet=quiet
190205
)
191-
print("Done")
206+
_print("Done", quiet=quiet)
192207

193208

194209
def contents(filename: str) -> str:
@@ -255,7 +270,11 @@ def insert_plans(
255270

256271

257272
def insert_accounts(
258-
cur: sqlite3.Cursor, plan_id: str, accounts: list[dict[str, Any]]
273+
cur: sqlite3.Cursor,
274+
plan_id: str,
275+
accounts: list[dict[str, Any]],
276+
*,
277+
quiet: bool = False,
259278
) -> None:
260279
# YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
261280
updated_accounts = [
@@ -283,11 +302,16 @@ def insert_accounts(
283302
"accounts",
284303
"account_periodic_values",
285304
"account_periodic_values",
305+
quiet=quiet,
286306
)
287307

288308

289309
def insert_category_groups(
290-
cur: sqlite3.Cursor, plan_id: str, category_groups: list[dict[str, Any]]
310+
cur: sqlite3.Cursor,
311+
plan_id: str,
312+
category_groups: list[dict[str, Any]],
313+
*,
314+
quiet: bool = False,
291315
) -> None:
292316
return insert_nested_entries(
293317
cur,
@@ -297,21 +321,30 @@ def insert_category_groups(
297321
"category_groups",
298322
"categories",
299323
"categories",
324+
quiet=quiet,
300325
)
301326

302327

303328
def insert_payees(
304-
cur: sqlite3.Cursor, plan_id: str, payees: list[dict[str, Any]]
329+
cur: sqlite3.Cursor,
330+
plan_id: str,
331+
payees: list[dict[str, Any]],
332+
*,
333+
quiet: bool = False,
305334
) -> None:
306335
if not payees:
307336
return
308337

309-
for payee in tldm(payees, desc="Payees"):
338+
for payee in tldm(payees, desc="Payees", disable=quiet):
310339
insert_entry(cur, "payees", plan_id, payee)
311340

312341

313342
def insert_transactions(
314-
cur: sqlite3.Cursor, plan_id: str, transactions: list[dict[str, Any]]
343+
cur: sqlite3.Cursor,
344+
plan_id: str,
345+
transactions: list[dict[str, Any]],
346+
*,
347+
quiet: bool = False,
315348
) -> None:
316349
return insert_nested_entries(
317350
cur,
@@ -321,11 +354,16 @@ def insert_transactions(
321354
"transactions",
322355
"subtransactions",
323356
"subtransactions",
357+
quiet=quiet,
324358
)
325359

326360

327361
def insert_scheduled_transactions(
328-
cur: sqlite3.Cursor, plan_id: str, scheduled_transactions: list[dict[str, Any]]
362+
cur: sqlite3.Cursor,
363+
plan_id: str,
364+
scheduled_transactions: list[dict[str, Any]],
365+
*,
366+
quiet: bool = False,
329367
) -> None:
330368
return insert_nested_entries(
331369
cur,
@@ -335,6 +373,7 @@ def insert_scheduled_transactions(
335373
"scheduled_transactions",
336374
"subtransactions",
337375
"scheduled_subtransactions",
376+
quiet=quiet,
338377
)
339378

340379

@@ -347,6 +386,8 @@ def insert_nested_entries(
347386
entries_name: Literal["accounts"],
348387
subentries_name: Literal["account_periodic_values"],
349388
subentries_table_name: Literal["account_periodic_values"],
389+
*,
390+
quiet: bool = False,
350391
) -> None: ...
351392

352393

@@ -359,6 +400,8 @@ def insert_nested_entries(
359400
entries_name: Literal["category_groups"],
360401
subentries_name: Literal["categories"],
361402
subentries_table_name: Literal["categories"],
403+
*,
404+
quiet: bool = False,
362405
) -> None: ...
363406

364407

@@ -371,6 +414,8 @@ def insert_nested_entries(
371414
entries_name: Literal["transactions"],
372415
subentries_name: Literal["subtransactions"],
373416
subentries_table_name: Literal["subtransactions"],
417+
*,
418+
quiet: bool = False,
374419
) -> None: ...
375420

376421

@@ -383,6 +428,8 @@ def insert_nested_entries(
383428
entries_name: Literal["scheduled_transactions"],
384429
subentries_name: Literal["subtransactions"],
385430
subentries_table_name: Literal["scheduled_subtransactions"],
431+
*,
432+
quiet: bool = False,
386433
) -> None: ...
387434

388435

@@ -413,13 +460,16 @@ def insert_nested_entries(
413460
| Literal["subtransactions"]
414461
| Literal["scheduled_subtransactions"]
415462
),
463+
*,
464+
quiet: bool = False,
416465
) -> None:
417466
if not entries:
418467
return
419468

420469
with tldm(
421470
total=sum(1 + len(e[subentries_name]) for e in entries),
422471
desc=desc,
472+
disable=quiet,
423473
) as pbar:
424474
for entry in entries:
425475
insert_entry(

tests/_main_test.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ def test_main_ok(sync, tmp_path, monkeypatch):
576576
monkeypatch.setenv(_ENV_TOKEN, TOKEN)
577577

578578
ret = main(("--db", str(tmp_path / "db.sqlite")))
579-
sync.assert_called()
579+
sync.assert_called_once_with(TOKEN, tmp_path / "db.sqlite", False, quiet=False)
580580
assert ret == 0
581581

582582

@@ -593,7 +593,19 @@ def test_main_uses_token_override(sync, tmp_path, monkeypatch):
593593

594594
ret = main(("--db", str(tmp_path / "db.sqlite")), token_override="override-token")
595595

596-
sync.assert_called_once_with("override-token", tmp_path / "db.sqlite", False)
596+
sync.assert_called_once_with(
597+
"override-token", tmp_path / "db.sqlite", False, quiet=False
598+
)
599+
assert ret == 0
600+
601+
602+
@patch("sqlite_export_for_ynab._main.sync")
603+
def test_main_quiet(sync, tmp_path, monkeypatch):
604+
monkeypatch.setenv(_ENV_TOKEN, TOKEN)
605+
606+
ret = main(("--db", str(tmp_path / "db.sqlite"), "--quiet"))
607+
608+
sync.assert_called_once_with(TOKEN, tmp_path / "db.sqlite", False, quiet=True)
597609
assert ret == 0
598610

599611

@@ -654,6 +666,54 @@ async def test_sync_no_data(tmp_path, mock_aioresponses):
654666
await sync(TOKEN, db, False)
655667

656668

669+
@pytest.mark.asyncio
670+
@pytest.mark.usefixtures(mock_aioresponses.__name__)
671+
async def test_sync_no_data_quiet(tmp_path, mock_aioresponses, capsys):
672+
mock_aioresponses.get(
673+
PLANS_ENDPOINT_RE, body=json.dumps({"data": {"plans": PLANS}})
674+
)
675+
mock_aioresponses.get(
676+
ACCOUNTS_ENDPOINT_RE,
677+
body=json.dumps({"data": {"accounts": []}}),
678+
repeat=True,
679+
)
680+
mock_aioresponses.get(
681+
CATEGORIES_ENDPOINT_RE,
682+
body=json.dumps({"data": {"category_groups": []}}),
683+
repeat=True,
684+
)
685+
mock_aioresponses.get(
686+
PAYEES_ENDPOINT_RE, body=json.dumps({"data": {"payees": []}}), repeat=True
687+
)
688+
mock_aioresponses.get(
689+
TRANSACTIONS_ENDPOINT_RE,
690+
body=json.dumps(
691+
{
692+
"data": {
693+
"transactions": [],
694+
"server_knowledge": SERVER_KNOWLEDGE_1,
695+
}
696+
}
697+
),
698+
repeat=True,
699+
)
700+
mock_aioresponses.get(
701+
SCHEDULED_TRANSACTIONS_ENDPOINT_RE,
702+
body=json.dumps({"data": {"scheduled_transactions": []}}),
703+
repeat=True,
704+
)
705+
706+
db = tmp_path / "db.sqlite"
707+
with sqlite3.connect(db) as con:
708+
con.executescript(contents("create-relations.sql"))
709+
710+
await sync(TOKEN, db, False, quiet=True)
711+
712+
out, err = capsys.readouterr()
713+
assert out == ""
714+
assert err == ""
715+
716+
657717
@pytest.mark.asyncio
658718
@pytest.mark.usefixtures(mock_aioresponses.__name__)
659719
async def test_sync(tmp_path, mock_aioresponses):

0 commit comments

Comments
 (0)