sqlite-export-for-ynab 2.1.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {sqlite_export_for_ynab-2.1.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.2.0}/PKG-INFO +149 -36
  2. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/README.md +148 -35
  3. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/setup.cfg +1 -1
  4. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/_main.py +92 -38
  5. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +149 -36
  6. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/testing/fixtures.py +3 -4
  7. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/tests/_main_test.py +86 -3
  8. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/LICENSE +0 -0
  9. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/pyproject.toml +0 -0
  10. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/setup.py +0 -0
  11. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__init__.py +0 -0
  12. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__main__.py +0 -0
  13. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  14. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
  15. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  16. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/py.typed +0 -0
  17. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  18. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  19. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  20. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/requires.txt +0 -0
  21. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  22. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/testing/__init__.py +0 -0
  23. {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite_export_for_ynab
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: SQLite Export for YNAB - Export YNAB Data to SQLite
5
5
  Home-page: https://github.com/mxr/sqlite-export-for-ynab
6
6
  Author: Max R
@@ -51,6 +51,7 @@ $ sqlite-export-for-ynab
51
51
  ```
52
52
 
53
53
  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.
54
+ Pass `--quiet` to suppress all CLI output, including progress bars.
54
55
 
55
56
  <a id="db-path"></a>You can specify the DB path with the following options
56
57
  1. The `--db` flag.
@@ -345,44 +346,80 @@ To compare assigned category values to a given account's balance:
345
346
  -- -cmd ".parameter set @account_name_like %Savings%" \
346
347
  -- -cmd ".parameter set @include_category_groups 'Home,Food'" \
347
348
  -- < query.sql
349
+ CREATE TEMP TABLE excess_query_results AS
348
350
  WITH params AS (
349
351
  SELECT
350
352
  TRIM(COALESCE(@account_name_like, '')) AS account_name_like
353
+ , TRIM(COALESCE(@plan_id, '')) AS plan_id
351
354
  , TRIM(COALESCE(@include_category_groups, ''))
352
355
  AS include_category_groups
353
356
  , TRIM(COALESCE(@exclude_category_groups, ''))
354
357
  AS exclude_category_groups
355
358
  )
356
359
 
357
- , validation AS (
358
- SELECT 'Set @account_name_like' AS error
359
- FROM params AS p
360
- WHERE p.account_name_like = ''
360
+ , scoped_plans AS (
361
+ SELECT
362
+ p.id
363
+ , p.name
364
+ FROM plans AS p
365
+ CROSS JOIN params AS prm
366
+ WHERE prm.plan_id = '' OR p.id = prm.plan_id
367
+ )
368
+
369
+ , split_include_category_groups (value, rest) AS (
370
+ SELECT
371
+ ''
372
+ , prm.include_category_groups || ','
373
+ FROM params AS prm
361
374
  UNION ALL
362
375
  SELECT
363
- 'Set only one of @include_category_groups'
364
- || ' or @exclude_category_groups' AS error
365
- FROM params AS p
366
- WHERE p.include_category_groups != '' AND p.exclude_category_groups != ''
376
+ TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
377
+ , SUBSTR(rest, INSTR(rest, ',') + 1)
378
+ FROM split_include_category_groups
379
+ WHERE rest != ''
367
380
  )
368
381
 
369
- SELECT v.error AS error_message
370
- FROM validation AS v
371
- WHERE v.error IS NOT NULL
372
- ;
382
+ , include_category_groups AS (
383
+ SELECT value AS name
384
+ FROM split_include_category_groups
385
+ WHERE value != ''
386
+ )
373
387
 
374
- WITH params AS (
388
+ , split_exclude_category_groups (value, rest) AS (
375
389
  SELECT
376
- TRIM(COALESCE(@account_name_like, '')) AS account_name_like
377
- , TRIM(COALESCE(@include_category_groups, ''))
378
- AS include_category_groups
379
- , TRIM(COALESCE(@exclude_category_groups, ''))
380
- AS exclude_category_groups
390
+ ''
391
+ , prm.exclude_category_groups || ','
392
+ FROM params AS prm
393
+ UNION ALL
394
+ SELECT
395
+ TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
396
+ , SUBSTR(rest, INSTR(rest, ',') + 1)
397
+ FROM split_exclude_category_groups
398
+ WHERE rest != ''
399
+ )
400
+
401
+ , exclude_category_groups AS (
402
+ SELECT value AS name
403
+ FROM split_exclude_category_groups
404
+ WHERE value != ''
405
+ )
406
+
407
+ , matching_accounts AS (
408
+ SELECT
409
+ sp.id AS plan_id
410
+ , sp.name AS plan_name
411
+ , COUNT(*) AS matches
412
+ FROM scoped_plans AS sp
413
+ INNER JOIN accounts AS a ON sp.id = a.plan_id
414
+ CROSS JOIN params AS prm
415
+ WHERE NOT a.deleted AND a.name LIKE prm.account_name_like
416
+ GROUP BY sp.id, sp.name
381
417
  )
382
418
 
383
419
  , validation AS (
384
420
  SELECT
385
421
  p.account_name_like
422
+ , p.plan_id
386
423
  , p.include_category_groups
387
424
  , p.exclude_category_groups
388
425
  FROM params AS p
@@ -398,11 +435,62 @@ WITH params AS (
398
435
  || ' or @exclude_category_groups' AS error
399
436
  FROM validation AS v
400
437
  WHERE v.include_category_groups != '' AND v.exclude_category_groups != ''
438
+ UNION ALL
439
+ SELECT 'No plan matched @plan_id' AS error
440
+ FROM validation AS v
441
+ WHERE
442
+ v.plan_id != '' AND NOT EXISTS (
443
+ SELECT 1
444
+ FROM scoped_plans
445
+ )
446
+ UNION ALL
447
+ SELECT 'No account names matched @account_name_like' AS error
448
+ FROM validation AS v
449
+ WHERE
450
+ v.account_name_like != '' AND NOT EXISTS (
451
+ SELECT 1
452
+ FROM matching_accounts
453
+ )
454
+ UNION ALL
455
+ SELECT
456
+ 'Matched more than 1 account in plan: '
457
+ || ma.plan_name AS error
458
+ FROM matching_accounts AS ma
459
+ WHERE ma.matches > 1
460
+ UNION ALL
461
+ SELECT
462
+ 'Unknown include category group in plan '
463
+ || sp.name
464
+ || ': '
465
+ || icg.name AS error
466
+ FROM scoped_plans AS sp
467
+ CROSS JOIN include_category_groups AS icg
468
+ LEFT JOIN category_groups AS cg
469
+ ON
470
+ sp.id = cg.plan_id
471
+ AND NOT COALESCE(cg.deleted, 0)
472
+ AND LOWER(cg.name) = LOWER(icg.name)
473
+ WHERE cg.id IS NULL
474
+ UNION ALL
475
+ SELECT
476
+ 'Unknown exclude category group in plan '
477
+ || sp.name
478
+ || ': '
479
+ || ecg.name AS error
480
+ FROM scoped_plans AS sp
481
+ CROSS JOIN exclude_category_groups AS ecg
482
+ LEFT JOIN category_groups AS cg
483
+ ON
484
+ sp.id = cg.plan_id
485
+ AND NOT COALESCE(cg.deleted, 0)
486
+ AND LOWER(cg.name) = LOWER(ecg.name)
487
+ WHERE cg.id IS NULL
401
488
  )
402
489
 
403
490
  , valid_params AS (
404
491
  SELECT
405
492
  v.account_name_like
493
+ , v.plan_id
406
494
  , v.include_category_groups
407
495
  , v.exclude_category_groups
408
496
  FROM validation AS v
@@ -425,7 +513,7 @@ WITH params AS (
425
513
  TRUE
426
514
  AND NOT a.deleted
427
515
  AND a.name LIKE v.account_name_like
428
- AND (COALESCE(@plan_id, '') = '' OR p.id = @plan_id)
516
+ AND (v.plan_id = '' OR p.id = v.plan_id)
429
517
  )
430
518
 
431
519
  , category_totals AS (
@@ -440,36 +528,61 @@ WITH params AS (
440
528
  AND c.category_group_name != 'Internal Master Category'
441
529
  AND (
442
530
  v.include_category_groups = ''
443
- OR INSTR(
444
- ','
445
- || LOWER(REPLACE(v.include_category_groups, ', ', ','))
446
- || ','
447
- , ',' || LOWER(c.category_group_name) || ','
531
+ OR EXISTS (
532
+ SELECT 1
533
+ FROM include_category_groups AS icg
534
+ WHERE LOWER(icg.name) = LOWER(c.category_group_name)
448
535
  )
449
- > 0
450
536
  )
451
537
  AND (
452
538
  v.exclude_category_groups = ''
453
- OR INSTR(
454
- ','
455
- || LOWER(REPLACE(v.exclude_category_groups, ', ', ','))
456
- || ','
457
- , ',' || LOWER(c.category_group_name) || ','
539
+ OR NOT EXISTS (
540
+ SELECT 1
541
+ FROM exclude_category_groups AS ecg
542
+ WHERE LOWER(ecg.name) = LOWER(c.category_group_name)
458
543
  )
459
- = 0
460
544
  )
461
- AND (COALESCE(@plan_id, '') = '' OR c.plan_id = @plan_id)
545
+ AND (v.plan_id = '' OR c.plan_id = v.plan_id)
462
546
  GROUP BY c.plan_id
463
547
  )
464
548
 
465
549
  SELECT
466
- ma.plan_name AS "plan"
550
+ ve.error AS error_message
551
+ , NULL AS "plan"
552
+ , NULL AS account
553
+ , NULL AS total
554
+ , NULL AS excess
555
+ FROM validation_errors AS ve
556
+
557
+ UNION ALL
558
+
559
+ SELECT
560
+ NULL AS error_message
561
+ , ma.plan_name AS "plan"
467
562
  , ma.account_name AS account
468
563
  , PRINTF('%.2f', COALESCE(ct.total, 0)) AS total
469
- , PRINTF('%.2f', COALESCE(ct.total, 0) - ma.account_amount) AS excess
564
+ , PRINTF('%.2f', ma.account_amount - COALESCE(ct.total, 0)) AS excess
470
565
  FROM matched_accounts AS ma
471
566
  LEFT JOIN category_totals AS ct ON ma.plan_id = ct.plan_id
472
- ORDER BY "plan", account
567
+ ;
568
+
569
+ SELECT error_message
570
+ FROM excess_query_results
571
+ WHERE error_message IS NOT NULL
572
+ ;
573
+
574
+ SELECT
575
+ eqr."plan"
576
+ , eqr.account
577
+ , eqr.total
578
+ , eqr.excess
579
+ FROM excess_query_results AS eqr
580
+ WHERE
581
+ NOT EXISTS (
582
+ SELECT 1
583
+ FROM excess_query_results AS eqr_errors
584
+ WHERE eqr_errors.error_message IS NOT NULL
585
+ )
473
586
  ;
474
587
  ```
475
588
 
@@ -31,6 +31,7 @@ $ sqlite-export-for-ynab
31
31
  ```
32
32
 
33
33
  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.
34
35
 
35
36
  <a id="db-path"></a>You can specify the DB path with the following options
36
37
  1. The `--db` flag.
@@ -325,44 +326,80 @@ To compare assigned category values to a given account's balance:
325
326
  -- -cmd ".parameter set @account_name_like %Savings%" \
326
327
  -- -cmd ".parameter set @include_category_groups 'Home,Food'" \
327
328
  -- < query.sql
329
+ CREATE TEMP TABLE excess_query_results AS
328
330
  WITH params AS (
329
331
  SELECT
330
332
  TRIM(COALESCE(@account_name_like, '')) AS account_name_like
333
+ , TRIM(COALESCE(@plan_id, '')) AS plan_id
331
334
  , TRIM(COALESCE(@include_category_groups, ''))
332
335
  AS include_category_groups
333
336
  , TRIM(COALESCE(@exclude_category_groups, ''))
334
337
  AS exclude_category_groups
335
338
  )
336
339
 
337
- , validation AS (
338
- SELECT 'Set @account_name_like' AS error
339
- FROM params AS p
340
- WHERE p.account_name_like = ''
340
+ , scoped_plans AS (
341
+ SELECT
342
+ p.id
343
+ , p.name
344
+ FROM plans AS p
345
+ CROSS JOIN params AS prm
346
+ WHERE prm.plan_id = '' OR p.id = prm.plan_id
347
+ )
348
+
349
+ , split_include_category_groups (value, rest) AS (
350
+ SELECT
351
+ ''
352
+ , prm.include_category_groups || ','
353
+ FROM params AS prm
341
354
  UNION ALL
342
355
  SELECT
343
- 'Set only one of @include_category_groups'
344
- || ' or @exclude_category_groups' AS error
345
- FROM params AS p
346
- WHERE p.include_category_groups != '' AND p.exclude_category_groups != ''
356
+ TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
357
+ , SUBSTR(rest, INSTR(rest, ',') + 1)
358
+ FROM split_include_category_groups
359
+ WHERE rest != ''
347
360
  )
348
361
 
349
- SELECT v.error AS error_message
350
- FROM validation AS v
351
- WHERE v.error IS NOT NULL
352
- ;
362
+ , include_category_groups AS (
363
+ SELECT value AS name
364
+ FROM split_include_category_groups
365
+ WHERE value != ''
366
+ )
353
367
 
354
- WITH params AS (
368
+ , split_exclude_category_groups (value, rest) AS (
355
369
  SELECT
356
- TRIM(COALESCE(@account_name_like, '')) AS account_name_like
357
- , TRIM(COALESCE(@include_category_groups, ''))
358
- AS include_category_groups
359
- , TRIM(COALESCE(@exclude_category_groups, ''))
360
- AS exclude_category_groups
370
+ ''
371
+ , prm.exclude_category_groups || ','
372
+ FROM params AS prm
373
+ UNION ALL
374
+ SELECT
375
+ TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
376
+ , SUBSTR(rest, INSTR(rest, ',') + 1)
377
+ FROM split_exclude_category_groups
378
+ WHERE rest != ''
379
+ )
380
+
381
+ , exclude_category_groups AS (
382
+ SELECT value AS name
383
+ FROM split_exclude_category_groups
384
+ WHERE value != ''
385
+ )
386
+
387
+ , matching_accounts AS (
388
+ SELECT
389
+ sp.id AS plan_id
390
+ , sp.name AS plan_name
391
+ , COUNT(*) AS matches
392
+ FROM scoped_plans AS sp
393
+ INNER JOIN accounts AS a ON sp.id = a.plan_id
394
+ CROSS JOIN params AS prm
395
+ WHERE NOT a.deleted AND a.name LIKE prm.account_name_like
396
+ GROUP BY sp.id, sp.name
361
397
  )
362
398
 
363
399
  , validation AS (
364
400
  SELECT
365
401
  p.account_name_like
402
+ , p.plan_id
366
403
  , p.include_category_groups
367
404
  , p.exclude_category_groups
368
405
  FROM params AS p
@@ -378,11 +415,62 @@ WITH params AS (
378
415
  || ' or @exclude_category_groups' AS error
379
416
  FROM validation AS v
380
417
  WHERE v.include_category_groups != '' AND v.exclude_category_groups != ''
418
+ UNION ALL
419
+ SELECT 'No plan matched @plan_id' AS error
420
+ FROM validation AS v
421
+ WHERE
422
+ v.plan_id != '' AND NOT EXISTS (
423
+ SELECT 1
424
+ FROM scoped_plans
425
+ )
426
+ UNION ALL
427
+ SELECT 'No account names matched @account_name_like' AS error
428
+ FROM validation AS v
429
+ WHERE
430
+ v.account_name_like != '' AND NOT EXISTS (
431
+ SELECT 1
432
+ FROM matching_accounts
433
+ )
434
+ UNION ALL
435
+ SELECT
436
+ 'Matched more than 1 account in plan: '
437
+ || ma.plan_name AS error
438
+ FROM matching_accounts AS ma
439
+ WHERE ma.matches > 1
440
+ UNION ALL
441
+ SELECT
442
+ 'Unknown include category group in plan '
443
+ || sp.name
444
+ || ': '
445
+ || icg.name AS error
446
+ FROM scoped_plans AS sp
447
+ CROSS JOIN include_category_groups AS icg
448
+ LEFT JOIN category_groups AS cg
449
+ ON
450
+ sp.id = cg.plan_id
451
+ AND NOT COALESCE(cg.deleted, 0)
452
+ AND LOWER(cg.name) = LOWER(icg.name)
453
+ WHERE cg.id IS NULL
454
+ UNION ALL
455
+ SELECT
456
+ 'Unknown exclude category group in plan '
457
+ || sp.name
458
+ || ': '
459
+ || ecg.name AS error
460
+ FROM scoped_plans AS sp
461
+ CROSS JOIN exclude_category_groups AS ecg
462
+ LEFT JOIN category_groups AS cg
463
+ ON
464
+ sp.id = cg.plan_id
465
+ AND NOT COALESCE(cg.deleted, 0)
466
+ AND LOWER(cg.name) = LOWER(ecg.name)
467
+ WHERE cg.id IS NULL
381
468
  )
382
469
 
383
470
  , valid_params AS (
384
471
  SELECT
385
472
  v.account_name_like
473
+ , v.plan_id
386
474
  , v.include_category_groups
387
475
  , v.exclude_category_groups
388
476
  FROM validation AS v
@@ -405,7 +493,7 @@ WITH params AS (
405
493
  TRUE
406
494
  AND NOT a.deleted
407
495
  AND a.name LIKE v.account_name_like
408
- AND (COALESCE(@plan_id, '') = '' OR p.id = @plan_id)
496
+ AND (v.plan_id = '' OR p.id = v.plan_id)
409
497
  )
410
498
 
411
499
  , category_totals AS (
@@ -420,36 +508,61 @@ WITH params AS (
420
508
  AND c.category_group_name != 'Internal Master Category'
421
509
  AND (
422
510
  v.include_category_groups = ''
423
- OR INSTR(
424
- ','
425
- || LOWER(REPLACE(v.include_category_groups, ', ', ','))
426
- || ','
427
- , ',' || LOWER(c.category_group_name) || ','
511
+ OR EXISTS (
512
+ SELECT 1
513
+ FROM include_category_groups AS icg
514
+ WHERE LOWER(icg.name) = LOWER(c.category_group_name)
428
515
  )
429
- > 0
430
516
  )
431
517
  AND (
432
518
  v.exclude_category_groups = ''
433
- OR INSTR(
434
- ','
435
- || LOWER(REPLACE(v.exclude_category_groups, ', ', ','))
436
- || ','
437
- , ',' || LOWER(c.category_group_name) || ','
519
+ OR NOT EXISTS (
520
+ SELECT 1
521
+ FROM exclude_category_groups AS ecg
522
+ WHERE LOWER(ecg.name) = LOWER(c.category_group_name)
438
523
  )
439
- = 0
440
524
  )
441
- AND (COALESCE(@plan_id, '') = '' OR c.plan_id = @plan_id)
525
+ AND (v.plan_id = '' OR c.plan_id = v.plan_id)
442
526
  GROUP BY c.plan_id
443
527
  )
444
528
 
445
529
  SELECT
446
- ma.plan_name AS "plan"
530
+ ve.error AS error_message
531
+ , NULL AS "plan"
532
+ , NULL AS account
533
+ , NULL AS total
534
+ , NULL AS excess
535
+ FROM validation_errors AS ve
536
+
537
+ UNION ALL
538
+
539
+ SELECT
540
+ NULL AS error_message
541
+ , ma.plan_name AS "plan"
447
542
  , ma.account_name AS account
448
543
  , PRINTF('%.2f', COALESCE(ct.total, 0)) AS total
449
- , PRINTF('%.2f', COALESCE(ct.total, 0) - ma.account_amount) AS excess
544
+ , PRINTF('%.2f', ma.account_amount - COALESCE(ct.total, 0)) AS excess
450
545
  FROM matched_accounts AS ma
451
546
  LEFT JOIN category_totals AS ct ON ma.plan_id = ct.plan_id
452
- ORDER BY "plan", account
547
+ ;
548
+
549
+ SELECT error_message
550
+ FROM excess_query_results
551
+ WHERE error_message IS NOT NULL
552
+ ;
553
+
554
+ SELECT
555
+ eqr."plan"
556
+ , eqr.account
557
+ , eqr.total
558
+ , eqr.excess
559
+ FROM excess_query_results AS eqr
560
+ WHERE
561
+ NOT EXISTS (
562
+ SELECT 1
563
+ FROM excess_query_results AS eqr_errors
564
+ WHERE eqr_errors.error_message IS NOT NULL
565
+ )
453
566
  ;
454
567
  ```
455
568
 
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = sqlite_export_for_ynab
3
- version = 2.1.0
3
+ version = 2.2.0
4
4
  description = SQLite Export for YNAB - Export YNAB Data to SQLite
5
5
  long_description = file: README.md
6
6
  long_description_content_type = text/markdown
@@ -50,7 +50,19 @@ _ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
50
50
  _PACKAGE = "sqlite-export-for-ynab"
51
51
 
52
52
 
53
- async def async_main(argv: Sequence[str] | None = None) -> int:
53
+ def resolve_token(token_override: str | None = None) -> str:
54
+ token = token_override or os.environ.get(_ENV_TOKEN)
55
+ if token:
56
+ return token
57
+
58
+ raise ValueError(
59
+ f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass token_override directly. See https://api.ynab.com/#personal-access-tokens"
60
+ )
61
+
62
+
63
+ async def async_main(
64
+ argv: Sequence[str] | None = None, *, token_override: str | None = None
65
+ ) -> int:
54
66
  parser = argparse.ArgumentParser(prog=_PACKAGE)
55
67
  parser.add_argument(
56
68
  "--db",
@@ -66,20 +78,20 @@ async def async_main(argv: Sequence[str] | None = None) -> int:
66
78
  parser.add_argument(
67
79
  "--version", action="version", version=f"%(prog)s {version(_PACKAGE)}"
68
80
  )
81
+ parser.add_argument(
82
+ "--quiet",
83
+ action="store_true",
84
+ help="Suppress all CLI output, including progress bars.",
85
+ )
69
86
 
70
87
  args = parser.parse_args(argv)
71
88
  db: Path = args.db
72
89
  full_refresh: bool = args.full_refresh
90
+ quiet: bool = args.quiet
73
91
 
74
- token = os.environ.get(_ENV_TOKEN)
75
- if not token:
76
- raise ValueError(
77
- f"Must set YNAB access token as {_ENV_TOKEN!r} "
78
- "environment variable. See "
79
- "https://api.ynab.com/#personal-access-tokens"
80
- )
92
+ token = resolve_token(token_override)
81
93
 
82
- await sync(token, db, full_refresh)
94
+ await sync(token, db, full_refresh, quiet=quiet)
83
95
 
84
96
  return 0
85
97
 
@@ -96,7 +108,14 @@ def default_db_path() -> Path:
96
108
  )
97
109
 
98
110
 
99
- 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:
100
119
  async with aiohttp.ClientSession() as session:
101
120
  plans = (await YnabClient(token, session)("plans"))["plans"]
102
121
 
@@ -106,26 +125,26 @@ async def sync(token: str, db: Path, full_refresh: bool) -> None:
106
125
  db.parent.mkdir(parents=True, exist_ok=True)
107
126
 
108
127
  with sqlite3.connect(db) as con:
109
- con.row_factory = _row_factory
128
+ con.row_factory = sqlite3.Row
110
129
  cur = con.cursor()
111
130
 
112
131
  if full_refresh:
113
- print("Dropping relations...")
132
+ _print("Dropping relations...", quiet=quiet)
114
133
  cur.executescript(contents("drop-relations.sql"))
115
134
  con.commit()
116
- print("Done")
135
+ _print("Done", quiet=quiet)
117
136
 
118
137
  relations = get_relations(cur)
119
138
  if relations != _ALL_RELATIONS:
120
- print("Recreating relations...")
139
+ _print("Recreating relations...", quiet=quiet)
121
140
  cur.executescript(contents("create-relations.sql"))
122
141
  con.commit()
123
- print("Done")
142
+ _print("Done", quiet=quiet)
124
143
 
125
- print("Fetching plan data...")
144
+ _print("Fetching plan data...", quiet=quiet)
126
145
  lkos = get_last_knowledge_of_server(cur)
127
146
  async with aiohttp.ClientSession() as session:
128
- 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:
129
148
  yc = ProgressYnabClient(YnabClient(token, session), pbar)
130
149
 
131
150
  account_jobs = jobs(yc, "accounts", plan_ids, lkos)
@@ -155,7 +174,7 @@ async def sync(token: str, db: Path, full_refresh: bool) -> None:
155
174
  plan_ids, all_txn_data, strict=True
156
175
  )
157
176
  }
158
- print("Done")
177
+ _print("Done", quiet=quiet)
159
178
 
160
179
  if (
161
180
  not any(t["accounts"] for t in all_account_data)
@@ -164,29 +183,27 @@ async def sync(token: str, db: Path, full_refresh: bool) -> None:
164
183
  and not any(t["transactions"] for t in all_txn_data)
165
184
  and not any(s["scheduled_transactions"] for s in all_sched_txn_data)
166
185
  ):
167
- print("No new data fetched")
186
+ _print("No new data fetched", quiet=quiet)
168
187
  else:
169
- print("Inserting plan data...")
188
+ _print("Inserting plan data...", quiet=quiet)
170
189
  insert_plans(cur, plans, new_lkos)
171
190
  for plan_id, account_data in zip(plan_ids, all_account_data, strict=True):
172
- insert_accounts(cur, plan_id, account_data["accounts"])
191
+ insert_accounts(cur, plan_id, account_data["accounts"], quiet=quiet)
173
192
  for plan_id, cat_data in zip(plan_ids, all_cat_data, strict=True):
174
- 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
+ )
175
196
  for plan_id, payee_data in zip(plan_ids, all_payee_data, strict=True):
176
- insert_payees(cur, plan_id, payee_data["payees"])
197
+ insert_payees(cur, plan_id, payee_data["payees"], quiet=quiet)
177
198
  for plan_id, txn_data in zip(plan_ids, all_txn_data, strict=True):
178
- insert_transactions(cur, plan_id, txn_data["transactions"])
199
+ insert_transactions(cur, plan_id, txn_data["transactions"], quiet=quiet)
179
200
  for plan_id, sched_txn_data in zip(
180
201
  plan_ids, all_sched_txn_data, strict=True
181
202
  ):
182
203
  insert_scheduled_transactions(
183
- cur, plan_id, sched_txn_data["scheduled_transactions"]
204
+ cur, plan_id, sched_txn_data["scheduled_transactions"], quiet=quiet
184
205
  )
185
- print("Done")
186
-
187
-
188
- def _row_factory(c: sqlite3.Cursor, row: tuple[Any, ...]) -> dict[str, Any]:
189
- return {d[0]: r for d, r in zip(c.description, row, strict=True)}
206
+ _print("Done", quiet=quiet)
190
207
 
191
208
 
192
209
  def contents(filename: str) -> str:
@@ -253,7 +270,11 @@ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
253
270
 
254
271
 
255
272
  def insert_accounts(
256
- 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,
257
278
  ) -> None:
258
279
  # YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
259
280
  updated_accounts = [
@@ -281,11 +302,16 @@ def insert_accounts(
281
302
  "accounts",
282
303
  "account_periodic_values",
283
304
  "account_periodic_values",
305
+ quiet=quiet,
284
306
  )
285
307
 
286
308
 
287
309
  def insert_category_groups(
288
- 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,
289
315
  ) -> None:
290
316
  return insert_nested_entries(
291
317
  cur,
@@ -295,21 +321,30 @@ def insert_category_groups(
295
321
  "category_groups",
296
322
  "categories",
297
323
  "categories",
324
+ quiet=quiet,
298
325
  )
299
326
 
300
327
 
301
328
  def insert_payees(
302
- 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,
303
334
  ) -> None:
304
335
  if not payees:
305
336
  return
306
337
 
307
- for payee in tldm(payees, desc="Payees"):
338
+ for payee in tldm(payees, desc="Payees", disable=quiet):
308
339
  insert_entry(cur, "payees", plan_id, payee)
309
340
 
310
341
 
311
342
  def insert_transactions(
312
- 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,
313
348
  ) -> None:
314
349
  return insert_nested_entries(
315
350
  cur,
@@ -319,11 +354,16 @@ def insert_transactions(
319
354
  "transactions",
320
355
  "subtransactions",
321
356
  "subtransactions",
357
+ quiet=quiet,
322
358
  )
323
359
 
324
360
 
325
361
  def insert_scheduled_transactions(
326
- 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,
327
367
  ) -> None:
328
368
  return insert_nested_entries(
329
369
  cur,
@@ -333,6 +373,7 @@ def insert_scheduled_transactions(
333
373
  "scheduled_transactions",
334
374
  "subtransactions",
335
375
  "scheduled_subtransactions",
376
+ quiet=quiet,
336
377
  )
337
378
 
338
379
 
@@ -345,6 +386,8 @@ def insert_nested_entries(
345
386
  entries_name: Literal["accounts"],
346
387
  subentries_name: Literal["account_periodic_values"],
347
388
  subentries_table_name: Literal["account_periodic_values"],
389
+ *,
390
+ quiet: bool = False,
348
391
  ) -> None: ...
349
392
 
350
393
 
@@ -357,6 +400,8 @@ def insert_nested_entries(
357
400
  entries_name: Literal["category_groups"],
358
401
  subentries_name: Literal["categories"],
359
402
  subentries_table_name: Literal["categories"],
403
+ *,
404
+ quiet: bool = False,
360
405
  ) -> None: ...
361
406
 
362
407
 
@@ -369,6 +414,8 @@ def insert_nested_entries(
369
414
  entries_name: Literal["transactions"],
370
415
  subentries_name: Literal["subtransactions"],
371
416
  subentries_table_name: Literal["subtransactions"],
417
+ *,
418
+ quiet: bool = False,
372
419
  ) -> None: ...
373
420
 
374
421
 
@@ -381,6 +428,8 @@ def insert_nested_entries(
381
428
  entries_name: Literal["scheduled_transactions"],
382
429
  subentries_name: Literal["subtransactions"],
383
430
  subentries_table_name: Literal["scheduled_subtransactions"],
431
+ *,
432
+ quiet: bool = False,
384
433
  ) -> None: ...
385
434
 
386
435
 
@@ -411,6 +460,8 @@ def insert_nested_entries(
411
460
  | Literal["subtransactions"]
412
461
  | Literal["scheduled_subtransactions"]
413
462
  ),
463
+ *,
464
+ quiet: bool = False,
414
465
  ) -> None:
415
466
  if not entries:
416
467
  return
@@ -418,6 +469,7 @@ def insert_nested_entries(
418
469
  with tldm(
419
470
  total=sum(1 + len(e[subentries_name]) for e in entries),
420
471
  desc=desc,
472
+ disable=quiet,
421
473
  ) as pbar:
422
474
  for entry in entries:
423
475
  insert_entry(
@@ -530,5 +582,7 @@ class YnabClient:
530
582
  raise AssertionError("unreachable")
531
583
 
532
584
 
533
- def main(argv: Sequence[str] | None = None) -> int:
534
- return asyncio.run(async_main(argv))
585
+ def main(
586
+ argv: Sequence[str] | None = None, *, token_override: str | None = None
587
+ ) -> int:
588
+ return asyncio.run(async_main(argv, token_override=token_override))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite_export_for_ynab
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: SQLite Export for YNAB - Export YNAB Data to SQLite
5
5
  Home-page: https://github.com/mxr/sqlite-export-for-ynab
6
6
  Author: Max R
@@ -51,6 +51,7 @@ $ sqlite-export-for-ynab
51
51
  ```
52
52
 
53
53
  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.
54
+ Pass `--quiet` to suppress all CLI output, including progress bars.
54
55
 
55
56
  <a id="db-path"></a>You can specify the DB path with the following options
56
57
  1. The `--db` flag.
@@ -345,44 +346,80 @@ To compare assigned category values to a given account's balance:
345
346
  -- -cmd ".parameter set @account_name_like %Savings%" \
346
347
  -- -cmd ".parameter set @include_category_groups 'Home,Food'" \
347
348
  -- < query.sql
349
+ CREATE TEMP TABLE excess_query_results AS
348
350
  WITH params AS (
349
351
  SELECT
350
352
  TRIM(COALESCE(@account_name_like, '')) AS account_name_like
353
+ , TRIM(COALESCE(@plan_id, '')) AS plan_id
351
354
  , TRIM(COALESCE(@include_category_groups, ''))
352
355
  AS include_category_groups
353
356
  , TRIM(COALESCE(@exclude_category_groups, ''))
354
357
  AS exclude_category_groups
355
358
  )
356
359
 
357
- , validation AS (
358
- SELECT 'Set @account_name_like' AS error
359
- FROM params AS p
360
- WHERE p.account_name_like = ''
360
+ , scoped_plans AS (
361
+ SELECT
362
+ p.id
363
+ , p.name
364
+ FROM plans AS p
365
+ CROSS JOIN params AS prm
366
+ WHERE prm.plan_id = '' OR p.id = prm.plan_id
367
+ )
368
+
369
+ , split_include_category_groups (value, rest) AS (
370
+ SELECT
371
+ ''
372
+ , prm.include_category_groups || ','
373
+ FROM params AS prm
361
374
  UNION ALL
362
375
  SELECT
363
- 'Set only one of @include_category_groups'
364
- || ' or @exclude_category_groups' AS error
365
- FROM params AS p
366
- WHERE p.include_category_groups != '' AND p.exclude_category_groups != ''
376
+ TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
377
+ , SUBSTR(rest, INSTR(rest, ',') + 1)
378
+ FROM split_include_category_groups
379
+ WHERE rest != ''
367
380
  )
368
381
 
369
- SELECT v.error AS error_message
370
- FROM validation AS v
371
- WHERE v.error IS NOT NULL
372
- ;
382
+ , include_category_groups AS (
383
+ SELECT value AS name
384
+ FROM split_include_category_groups
385
+ WHERE value != ''
386
+ )
373
387
 
374
- WITH params AS (
388
+ , split_exclude_category_groups (value, rest) AS (
375
389
  SELECT
376
- TRIM(COALESCE(@account_name_like, '')) AS account_name_like
377
- , TRIM(COALESCE(@include_category_groups, ''))
378
- AS include_category_groups
379
- , TRIM(COALESCE(@exclude_category_groups, ''))
380
- AS exclude_category_groups
390
+ ''
391
+ , prm.exclude_category_groups || ','
392
+ FROM params AS prm
393
+ UNION ALL
394
+ SELECT
395
+ TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
396
+ , SUBSTR(rest, INSTR(rest, ',') + 1)
397
+ FROM split_exclude_category_groups
398
+ WHERE rest != ''
399
+ )
400
+
401
+ , exclude_category_groups AS (
402
+ SELECT value AS name
403
+ FROM split_exclude_category_groups
404
+ WHERE value != ''
405
+ )
406
+
407
+ , matching_accounts AS (
408
+ SELECT
409
+ sp.id AS plan_id
410
+ , sp.name AS plan_name
411
+ , COUNT(*) AS matches
412
+ FROM scoped_plans AS sp
413
+ INNER JOIN accounts AS a ON sp.id = a.plan_id
414
+ CROSS JOIN params AS prm
415
+ WHERE NOT a.deleted AND a.name LIKE prm.account_name_like
416
+ GROUP BY sp.id, sp.name
381
417
  )
382
418
 
383
419
  , validation AS (
384
420
  SELECT
385
421
  p.account_name_like
422
+ , p.plan_id
386
423
  , p.include_category_groups
387
424
  , p.exclude_category_groups
388
425
  FROM params AS p
@@ -398,11 +435,62 @@ WITH params AS (
398
435
  || ' or @exclude_category_groups' AS error
399
436
  FROM validation AS v
400
437
  WHERE v.include_category_groups != '' AND v.exclude_category_groups != ''
438
+ UNION ALL
439
+ SELECT 'No plan matched @plan_id' AS error
440
+ FROM validation AS v
441
+ WHERE
442
+ v.plan_id != '' AND NOT EXISTS (
443
+ SELECT 1
444
+ FROM scoped_plans
445
+ )
446
+ UNION ALL
447
+ SELECT 'No account names matched @account_name_like' AS error
448
+ FROM validation AS v
449
+ WHERE
450
+ v.account_name_like != '' AND NOT EXISTS (
451
+ SELECT 1
452
+ FROM matching_accounts
453
+ )
454
+ UNION ALL
455
+ SELECT
456
+ 'Matched more than 1 account in plan: '
457
+ || ma.plan_name AS error
458
+ FROM matching_accounts AS ma
459
+ WHERE ma.matches > 1
460
+ UNION ALL
461
+ SELECT
462
+ 'Unknown include category group in plan '
463
+ || sp.name
464
+ || ': '
465
+ || icg.name AS error
466
+ FROM scoped_plans AS sp
467
+ CROSS JOIN include_category_groups AS icg
468
+ LEFT JOIN category_groups AS cg
469
+ ON
470
+ sp.id = cg.plan_id
471
+ AND NOT COALESCE(cg.deleted, 0)
472
+ AND LOWER(cg.name) = LOWER(icg.name)
473
+ WHERE cg.id IS NULL
474
+ UNION ALL
475
+ SELECT
476
+ 'Unknown exclude category group in plan '
477
+ || sp.name
478
+ || ': '
479
+ || ecg.name AS error
480
+ FROM scoped_plans AS sp
481
+ CROSS JOIN exclude_category_groups AS ecg
482
+ LEFT JOIN category_groups AS cg
483
+ ON
484
+ sp.id = cg.plan_id
485
+ AND NOT COALESCE(cg.deleted, 0)
486
+ AND LOWER(cg.name) = LOWER(ecg.name)
487
+ WHERE cg.id IS NULL
401
488
  )
402
489
 
403
490
  , valid_params AS (
404
491
  SELECT
405
492
  v.account_name_like
493
+ , v.plan_id
406
494
  , v.include_category_groups
407
495
  , v.exclude_category_groups
408
496
  FROM validation AS v
@@ -425,7 +513,7 @@ WITH params AS (
425
513
  TRUE
426
514
  AND NOT a.deleted
427
515
  AND a.name LIKE v.account_name_like
428
- AND (COALESCE(@plan_id, '') = '' OR p.id = @plan_id)
516
+ AND (v.plan_id = '' OR p.id = v.plan_id)
429
517
  )
430
518
 
431
519
  , category_totals AS (
@@ -440,36 +528,61 @@ WITH params AS (
440
528
  AND c.category_group_name != 'Internal Master Category'
441
529
  AND (
442
530
  v.include_category_groups = ''
443
- OR INSTR(
444
- ','
445
- || LOWER(REPLACE(v.include_category_groups, ', ', ','))
446
- || ','
447
- , ',' || LOWER(c.category_group_name) || ','
531
+ OR EXISTS (
532
+ SELECT 1
533
+ FROM include_category_groups AS icg
534
+ WHERE LOWER(icg.name) = LOWER(c.category_group_name)
448
535
  )
449
- > 0
450
536
  )
451
537
  AND (
452
538
  v.exclude_category_groups = ''
453
- OR INSTR(
454
- ','
455
- || LOWER(REPLACE(v.exclude_category_groups, ', ', ','))
456
- || ','
457
- , ',' || LOWER(c.category_group_name) || ','
539
+ OR NOT EXISTS (
540
+ SELECT 1
541
+ FROM exclude_category_groups AS ecg
542
+ WHERE LOWER(ecg.name) = LOWER(c.category_group_name)
458
543
  )
459
- = 0
460
544
  )
461
- AND (COALESCE(@plan_id, '') = '' OR c.plan_id = @plan_id)
545
+ AND (v.plan_id = '' OR c.plan_id = v.plan_id)
462
546
  GROUP BY c.plan_id
463
547
  )
464
548
 
465
549
  SELECT
466
- ma.plan_name AS "plan"
550
+ ve.error AS error_message
551
+ , NULL AS "plan"
552
+ , NULL AS account
553
+ , NULL AS total
554
+ , NULL AS excess
555
+ FROM validation_errors AS ve
556
+
557
+ UNION ALL
558
+
559
+ SELECT
560
+ NULL AS error_message
561
+ , ma.plan_name AS "plan"
467
562
  , ma.account_name AS account
468
563
  , PRINTF('%.2f', COALESCE(ct.total, 0)) AS total
469
- , PRINTF('%.2f', COALESCE(ct.total, 0) - ma.account_amount) AS excess
564
+ , PRINTF('%.2f', ma.account_amount - COALESCE(ct.total, 0)) AS excess
470
565
  FROM matched_accounts AS ma
471
566
  LEFT JOIN category_totals AS ct ON ma.plan_id = ct.plan_id
472
- ORDER BY "plan", account
567
+ ;
568
+
569
+ SELECT error_message
570
+ FROM excess_query_results
571
+ WHERE error_message IS NOT NULL
572
+ ;
573
+
574
+ SELECT
575
+ eqr."plan"
576
+ , eqr.account
577
+ , eqr.total
578
+ , eqr.excess
579
+ FROM excess_query_results AS eqr
580
+ WHERE
581
+ NOT EXISTS (
582
+ SELECT 1
583
+ FROM excess_query_results AS eqr_errors
584
+ WHERE eqr_errors.error_message IS NOT NULL
585
+ )
473
586
  ;
474
587
  ```
475
588
 
@@ -8,7 +8,6 @@ from uuid import uuid4
8
8
  import pytest
9
9
  from aioresponses import aioresponses
10
10
 
11
- from sqlite_export_for_ynab._main import _row_factory
12
11
  from sqlite_export_for_ynab._main import contents
13
12
 
14
13
  PLAN_ID_1 = str(uuid4())
@@ -326,7 +325,7 @@ SCHEDULED_TRANSACTIONS: list[dict[str, Any]] = [
326
325
  @pytest.fixture
327
326
  def cur():
328
327
  with sqlite3.connect(":memory:") as con:
329
- con.row_factory = _row_factory
328
+ con.row_factory = sqlite3.Row
330
329
  cursor = con.cursor()
331
330
  cursor.executescript(contents("create-relations.sql"))
332
331
  yield cursor
@@ -338,8 +337,8 @@ def mock_aioresponses():
338
337
  yield m
339
338
 
340
339
 
341
- def strip_nones(d: dict[str, Any]) -> dict[str, Any]:
342
- return {k: v for k, v in d.items() if v is not None}
340
+ def strip_nones(d: sqlite3.Row) -> dict[str, Any]:
341
+ return {k: v for k, v in dict(d).items() if v is not None}
343
342
 
344
343
 
345
344
  TOKEN = f"token-{uuid4()}"
@@ -27,6 +27,7 @@ from sqlite_export_for_ynab._main import insert_scheduled_transactions
27
27
  from sqlite_export_for_ynab._main import insert_transactions
28
28
  from sqlite_export_for_ynab._main import main
29
29
  from sqlite_export_for_ynab._main import ProgressYnabClient
30
+ from sqlite_export_for_ynab._main import resolve_token
30
31
  from sqlite_export_for_ynab._main import sync
31
32
  from sqlite_export_for_ynab._main import YnabClient
32
33
  from testing.fixtures import ACCOUNT_ID_1
@@ -106,7 +107,7 @@ def test_get_last_knowledge_of_server(cur):
106
107
  def test_insert_plans(cur):
107
108
  insert_plans(cur, PLANS, LKOS)
108
109
  cur.execute("SELECT * FROM plans ORDER BY name")
109
- assert cur.fetchall() == [
110
+ assert [strip_nones(d) for d in cur.fetchall()] == [
110
111
  {
111
112
  "id": PLAN_ID_1,
112
113
  "name": PLANS[0]["name"],
@@ -170,7 +171,7 @@ def test_insert_accounts(cur):
170
171
  ]
171
172
 
172
173
  cur.execute("SELECT * FROM account_periodic_values ORDER BY name")
173
- assert cur.fetchall() == [
174
+ assert [strip_nones(d) for d in cur.fetchall()] == [
174
175
  {
175
176
  "account_id": ACCOUNT_ID_1,
176
177
  "plan_id": PLAN_ID_1,
@@ -575,7 +576,7 @@ def test_main_ok(sync, tmp_path, monkeypatch):
575
576
  monkeypatch.setenv(_ENV_TOKEN, TOKEN)
576
577
 
577
578
  ret = main(("--db", str(tmp_path / "db.sqlite")))
578
- sync.assert_called()
579
+ sync.assert_called_once_with(TOKEN, tmp_path / "db.sqlite", False, quiet=False)
579
580
  assert ret == 0
580
581
 
581
582
 
@@ -586,6 +587,40 @@ def test_main_no_token(tmp_path, monkeypatch):
586
587
  main(("--db", str(tmp_path / "db.sqlite")))
587
588
 
588
589
 
590
+ @patch("sqlite_export_for_ynab._main.sync")
591
+ def test_main_uses_token_override(sync, tmp_path, monkeypatch):
592
+ monkeypatch.delenv(_ENV_TOKEN, raising=False)
593
+
594
+ ret = main(("--db", str(tmp_path / "db.sqlite")), token_override="override-token")
595
+
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)
609
+ assert ret == 0
610
+
611
+
612
+ def test_resolve_token_override(monkeypatch):
613
+ monkeypatch.delenv(_ENV_TOKEN, raising=False)
614
+
615
+ assert resolve_token("override-token") == "override-token"
616
+
617
+
618
+ def test_resolve_token_env(monkeypatch):
619
+ monkeypatch.setenv(_ENV_TOKEN, TOKEN)
620
+
621
+ assert resolve_token() == TOKEN
622
+
623
+
589
624
  @pytest.mark.asyncio
590
625
  @pytest.mark.usefixtures(mock_aioresponses.__name__)
591
626
  async def test_sync_no_data(tmp_path, mock_aioresponses):
@@ -631,6 +666,54 @@ async def test_sync_no_data(tmp_path, mock_aioresponses):
631
666
  await sync(TOKEN, db, False)
632
667
 
633
668
 
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
+
634
717
  @pytest.mark.asyncio
635
718
  @pytest.mark.usefixtures(mock_aioresponses.__name__)
636
719
  async def test_sync(tmp_path, mock_aioresponses):