bmsdna-devtools 0.30.0__tar.gz → 0.32.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 (88) hide show
  1. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/PKG-INFO +166 -12
  2. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/README.md +164 -11
  3. bmsdna_devtools-0.32.0/bmsdna/devtools/_api_dump.py +58 -0
  4. bmsdna_devtools-0.32.0/bmsdna/devtools/api_usage.py +501 -0
  5. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/cli.py +184 -14
  6. bmsdna_devtools-0.32.0/bmsdna/devtools/dead_code.py +82 -0
  7. bmsdna_devtools-0.32.0/bmsdna/devtools/env_files.py +123 -0
  8. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/issue_do.py +11 -0
  9. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_findings.py +2 -2
  10. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_sql.py +7 -1
  11. bmsdna_devtools-0.32.0/bmsdna/devtools/lint_sql_files.py +149 -0
  12. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_build.py +13 -8
  13. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_info.py +9 -1
  14. bmsdna_devtools-0.32.0/bmsdna/devtools/translate.py +289 -0
  15. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/pyproject.toml +2 -1
  16. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/skills/bmsdna-devtools/SKILL.md +19 -3
  17. bmsdna_devtools-0.32.0/tests/test_api_usage.py +654 -0
  18. bmsdna_devtools-0.32.0/tests/test_env_files.py +92 -0
  19. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_lint_sql.py +10 -0
  20. bmsdna_devtools-0.32.0/tests/test_lint_sql_files.py +215 -0
  21. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_pr_build_flow.py +20 -0
  22. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_pr_info.py +47 -5
  23. bmsdna_devtools-0.32.0/tests/test_translate.py +165 -0
  24. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/uv.lock +12 -1
  25. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/.github/workflows/auto-release.yml +0 -0
  26. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/.github/workflows/python-publish.yml +0 -0
  27. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/.github/workflows/python-test.yml +0 -0
  28. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/.gitignore +0 -0
  29. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/.python-version +0 -0
  30. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/__init__.py +0 -0
  31. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/ado_auth.py +0 -0
  32. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/ado_issue.py +0 -0
  33. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/app_service_logs.py +0 -0
  34. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/bdt_config.py +0 -0
  35. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/cli_tools.py +0 -0
  36. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/commit.py +0 -0
  37. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/env_config.py +0 -0
  38. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/find_injection.py +0 -0
  39. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/find_repo.py +0 -0
  40. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/gh_issue.py +0 -0
  41. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/gh_pr.py +0 -0
  42. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/gitrepo.py +0 -0
  43. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/injection_frontend.py +0 -0
  44. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/injection_python.py +0 -0
  45. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint.py +0 -0
  46. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_models.py +0 -0
  47. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_tooling.py +0 -0
  48. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_typescript.py +0 -0
  49. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/logs.py +0 -0
  50. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  51. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_labels.py +0 -0
  52. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_markdown.py +0 -0
  53. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pull.py +0 -0
  54. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/testdb.py +0 -0
  55. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/worktree.py +0 -0
  56. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/justfile +0 -0
  57. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/conftest.py +0 -0
  58. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_ado_auth.py +0 -0
  59. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_ado_issue.py +0 -0
  60. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_ado_issue_flow.py +0 -0
  61. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_app_service_logs.py +0 -0
  62. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_bdt_config.py +0 -0
  63. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_cli_cleanup.py +0 -0
  64. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_cli_commit.py +0 -0
  65. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_cli_issue.py +0 -0
  66. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_cli_pr_create.py +0 -0
  67. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_cli_pr_id.py +0 -0
  68. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_cli_pull.py +0 -0
  69. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_cli_tools.py +0 -0
  70. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_commit.py +0 -0
  71. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_env_config.py +0 -0
  72. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_find_injection.py +0 -0
  73. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_find_repo.py +0 -0
  74. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_gh_issue.py +0 -0
  75. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_gh_pr.py +0 -0
  76. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_gitrepo.py +0 -0
  77. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_issue_do.py +0 -0
  78. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_lint.py +0 -0
  79. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_lint_models.py +0 -0
  80. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_lint_tooling.py +0 -0
  81. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_lint_typescript.py +0 -0
  82. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_pr_build.py +0 -0
  83. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_pr_issue_link.py +0 -0
  84. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_pr_labels.py +0 -0
  85. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_pr_markdown.py +0 -0
  86. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_pull.py +0 -0
  87. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_testdb.py +0 -0
  88. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.32.0}/tests/test_worktree_clean.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.30.0
3
+ Version: 0.32.0
4
4
  Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: pgdevkit[db]>=0.7.1
7
7
  Requires-Dist: requests>=2.32.0
8
8
  Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: tomli-w>=1.0.0
9
10
  Requires-Dist: typer>=0.26.7
10
11
  Provides-Extra: lint
11
12
  Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
@@ -18,7 +19,8 @@ one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue
18
19
  merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
19
20
  pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
20
21
  postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
21
- TypeScript, and baseline tooling.
22
+ TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
23
+ backend routes nothing calls.
22
24
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
23
25
  remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
24
26
  Consolidates near-duplicate scripts that used to be copy-pasted across
@@ -48,11 +50,12 @@ Find the PR opened from the current branch and report build/check status
48
50
  GitHub — whichever `origin` points at.
49
51
 
50
52
  ```bash
51
- bdt pr status [--target-branch dev] [--wait]
53
+ bdt pr status [--target-branch BRANCH] [--wait]
52
54
  ```
53
55
 
54
- Every `--target` / `--target-branch` option in bdt (`pr create/status/info/retry/publish/update/comment/watch-deploy`,
55
- `issue take`, `commit`) defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override.
56
+ The `--target` / `--target-branch` option of `pr create/retry/publish/update/comment/watch-deploy` and `commit`
57
+ defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override. `pr status`, `pr info` and
58
+ `issue take` only look a PR up, so by default they don't filter on the target branch at all.
56
59
 
57
60
  If the PR can't be merged, that's reported immediately instead of polling
58
61
  for builds/checks that will never run — e.g. on Azure DevOps:
@@ -73,7 +76,7 @@ one is also waiting on approval.
73
76
  `*.visualstudio.com` HTTPS forms). Auth is an explicit PAT (`--pat` or
74
77
  `AZURE_DEVOPS_EXT_PAT`/`AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
75
78
  caller's own `az login` — never embed a PAT literal in a script or CI file.
76
- `--target-branch` selects which PR to look at (ADO's search API needs one).
79
+ `--target-branch` optionally restricts the lookup to PRs into that branch (default: any).
77
80
 
78
81
  **GitHub**: uses `gh`'s own auth (`gh auth login`) and always resolves the
79
82
  PR opened from the current branch — `gh pr view` has no target-branch
@@ -91,7 +94,7 @@ it watches a branch-triggered build/workflow run, not any particular PR.)
91
94
  ## `bdt pr info`
92
95
 
93
96
  ```bash
94
- bdt pr info [--json] [--pr-id N] [--target-branch dev]
97
+ bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]
95
98
  ```
96
99
 
97
100
  One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
@@ -102,7 +105,7 @@ the check rollup (only a failed check fails it; skipped/cancelled ones don't cou
102
105
  from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
103
106
  `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
104
107
 
105
- Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `dev`),
108
+ Azure DevOps limits: the PR is found by the current branch (into `--target-branch` if given, else any target),
106
109
  a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
107
110
  work items are only those whose URL is in the PR description -- not ones linked via
108
111
  `bdt pr create --issue`.
@@ -323,12 +326,12 @@ last argument); extra args are passed through; `--dry-run` prints the command.
323
326
  It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>`
324
327
  on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and
325
328
  passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For
326
- another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is a claim.
329
+ another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is your claim; if it is someone else's, it stops (exit 1) unless `--force`.
327
330
 
328
331
  ## `bdt issue take`
329
332
 
330
333
  ```bash
331
- bdt issue take [NUMBER] [--target-branch dev]
334
+ bdt issue take [NUMBER] [--target-branch BRANCH] [--force]
332
335
  ```
333
336
 
334
337
  Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the
@@ -340,6 +343,50 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
340
343
  that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
341
344
  says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
342
345
 
346
+ ## `bdt env get-keys`
347
+
348
+ Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
349
+ current directory (recursively, skipping `node_modules`, `.git`, `.venv`, `.worktrees` and `.claude`) and directly in `~`, so you (or an agent) can see which variables exist without `cat .env`.
350
+ `--search TEXT` filters by key name (case-insensitive); `--no-home` leaves out the files in `~`. Files in `~` -- and any file using
351
+ `export VAR=VALUE` -- are bash syntax, so the output adds a `source <file>` hint. Exits 1 if nothing matches.
352
+
353
+ ## `bdt translate`
354
+
355
+ One `translations.toml` is the source of truth for all UI translations; `bdt translate` generates the
356
+ per-language `de.json` / `en.json` / ... files from it. The generated files are build artifacts --
357
+ add them to `.gitignore` and run `bdt translate` in your `just install` / build / CI steps.
358
+
359
+ ```toml
360
+ # translations.toml (keys with dots must be quoted when `nested = true`)
361
+ [ADD_BUTTON]
362
+ en = "Add"
363
+ de = "Hinzufügen"
364
+ fr = "Ajouter"
365
+ it = "Aggiungi"
366
+ server_only = true # optional: never emitted into the JSON files (backend-only text)
367
+ ```
368
+
369
+ ```toml
370
+ # pyproject.toml
371
+ [tool.bdt.translate]
372
+ file = "translations.toml" # default
373
+ languages = ["en", "de", "fr", "it"] # default
374
+ required_languages = ["en"] # default; `bdt translate add` fails without these
375
+ output = ["frontend/src/assets/i18n"] # directories receiving <lng>.json
376
+ nested = false # true: "a.b.c" keys become nested JSON objects
377
+ scan = ["frontend/src"] # t("KEY") / $t("KEY") in .ts/.tsx/.js/.jsx/.vue
378
+ scan_jinja = ["backend/print"] # "KEY" | tr in .jinja2/.j2/.html
379
+ ```
380
+
381
+ - A missing language falls back to `de`, then `en`, then the key itself.
382
+ - Keys used in code but absent from `translations.toml` are appended with an English placeholder and
383
+ the command exits 1 without generating anything -- fill in the translations and rerun.
384
+ - `bdt translate add KEY en=Add de=Hinzufügen fr=Ajouter it=Aggiungi` adds a key to `translations.toml` and
385
+ regenerates all JSON files in one go (the `required_languages` are mandatory; `--force` overwrites an existing key).
386
+ - `bdt translate --check` writes nothing and exits 1 if code uses keys missing from the toml (for CI).
387
+ - `bdt translate --import` merges existing `<lng>.json` files into `translations.toml` once, to migrate
388
+ a repo that so far hand-maintained its JSON files; then `git rm --cached` them and ignore them.
389
+
343
390
  ## `bdt worktree`
344
391
 
345
392
  ```bash
@@ -571,8 +618,9 @@ bdt logs fetch --env prod --out logs/ --keep-archive
571
618
  ## `bdt lint`
572
619
 
573
620
  Static checks (implementing [bmsuisse/skills#52](https://github.com/bmsuisse/skills/issues/52))
574
- for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, and
575
- that the repo has its baseline tooling actually set up:
621
+ for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, hand-wired HTTP in
622
+ TypeScript, and that the repo has its baseline tooling actually set up. Unused `.sql` files and
623
+ dead backend routes are a separate command, [`bdt dead-code`](#bdt-dead-code):
576
624
 
577
625
  ```bash
578
626
  bdt lint # scan the current directory, recursively
@@ -661,6 +709,112 @@ already pulled in transitively via `pgdevkit[db]`, but declared explicitly as
661
709
  the `bmsdna-devtools[lint]` extra; a clear install hint is printed (not a raw
662
710
  `ImportError`) if it's ever missing.
663
711
 
712
+ ## `bdt dead-code`
713
+
714
+ Dead code a linter can't see, in one command and one config table, `[tool.bdt.dead_code]`:
715
+
716
+ - **`sql`** -- `.sql` files that no Python code loads;
717
+ - **`routes`** -- backend (FastAPI) routes that neither non-generated frontend code nor a `url_for(...)`
718
+ call uses.
719
+
720
+ Each check runs when it is configured (`sql_roots`, resp. `[[tool.bdt.dead_code.apps]]`); `--only sql` /
721
+ `--only routes` (repeatable) narrows a run, e.g. to keep the slow one -- `routes` imports the app, so run
722
+ `bdt` with the repo's own interpreter (`uv run bdt dead-code`) -- out of a prek hook. Exit code is 0 (clean), 1
723
+ (findings) or 2 (setup problem: invalid config or pyproject.toml, nothing configured, the app doesn't import
724
+ within 5 minutes -- the error shows the import's output).
725
+
726
+ ### `sql` -- unreferenced `.sql` files
727
+
728
+ A `.sql` file that no Python code loads is a query left behind after its caller was deleted or renamed. Name the
729
+ folders that hold *loadable* SQL (not schema/migration scripts, which are applied rather than loaded) and every
730
+ file under them that nothing references is reported (`sql-file-unreferenced`):
731
+
732
+ ```toml
733
+ [tool.bdt.dead_code]
734
+ sql_roots = ["backend/db/queries"] # repo-relative; a typo'd root is itself reported
735
+ sql_loader_functions = ["load_sql"] # default; add your own loader's name if it differs
736
+ sql_unreferenced_ignore = ["backend/db/queries/legacy/*.sql"] # globs for files reached some other way
737
+ # exclude_dirs = ["generated"] # extra directory names to skip, beyond .venv/node_modules/etc.
738
+ ```
739
+
740
+ A file counts as referenced by a literal `load_sql("topic", "name")` call (positional or `topic=`/`name=`;
741
+ topic = the file's parent directory, name = its stem); by a `load_sql("topic", some_var)` call in a Python file that
742
+ also contains the stem as a string literal (so `name = "a" if x else "b"` and lookup tables work);
743
+ by a string literal that is a path whose trailing segments equal the file's repo-relative path
744
+ (`get_sql_with_prm_list("backend/api/sql/x.sql")`); or by its bare filename as a literal in a
745
+ Python file under the SQL folder's parent (`_SQL_DIR / "x.sql"`). It never executes code, so a
746
+ file reached through a fully computed path is a false positive -- list it in
747
+ `sql_unreferenced_ignore`. References are searched across the whole repo, since the caller can live anywhere.
748
+ A Python file that can't be parsed (including syntax newer than the interpreter running `bdt`) is itself
749
+ reported as `sql-check-python-unparseable`, because references in it are unknown.
750
+
751
+ ### `routes` -- backend routes nobody uses
752
+
753
+ Backend (FastAPI) operations that **no non-generated frontend code calls and no backend code references by
754
+ name** -- dead routes that still have to be maintained, secured and tested.
755
+
756
+ ```toml
757
+ [[tool.bdt.dead_code.apps]]
758
+ name = "akeneo" # label used in the report
759
+ app = "main_app:app" # module:attribute, imported in a subprocess ...
760
+ app_dir = "akeneo_editor/backend" # ... with this directory (repo-relative) as cwd/import root
761
+ # openapi = "frontend/openapi.json" # alternative to `app`: a committed OpenAPI document
762
+ frontends = ["akeneo_editor/frontend/src"] # repo-relative dirs or globs, e.g. "mdmapp/app/react_apps/*/src"
763
+ exclude_prefixes = ["/external_api"] # routes meant for other callers (external API, webhooks)
764
+ exclude_tags = ["agent"] # e.g. LLM/MCP tools
765
+ exclude_paths = ["/auth/*", "GET /health"] # fnmatch globs on "/path" or "METHOD /path"
766
+ baseline = "dead-code-baseline.txt" # optional ratchet, see below
767
+ # env = { SOME_REQUIRED_SETTING = "x" } # extra environment for importing the app
768
+ # exclude_frontend_globs = ["src/legacy/*"] # repo-relative frontend files to ignore as callers
769
+ # url_for_functions = ["url_for", "url_path_for"] # default; functions whose first argument names a route
770
+ ```
771
+
772
+ Pair each backend app with *its own* frontends (one `[[...apps]]` entry per backend) -- a route
773
+ called only by another backend's frontend is still unused here.
774
+
775
+ The backend inventory is `app.openapi()` of the app **and of every mounted sub-app** (with the
776
+ mount prefix), so it needs no committed schema and can't go stale; routes with
777
+ `include_in_schema=False` are not considered.
778
+
779
+ **Generated code never counts as a caller.** A generated client lists *every* route, so it is
780
+ skipped (`generated/`, `*.gen.*`, `*.generated.*`, `api-types*`, `openapi_schema*`, `*.d.ts`), as are
781
+ tests and e2e specs (`*.test.*`, `*.spec.*`, `__tests__/`, `tests/`, `e2e/`) -- for TypeScript,
782
+ JavaScript and Vue files alike. Comments are blanked before scanning. An operation is called when
783
+ non-generated code of one of the app's frontends has (strongest first):
784
+
785
+ - **sdk** -- a reference to a hey-api SDK function (read from the `sdk.gen.ts` under that same frontend
786
+ directory, matched on method *and* url) or one of its react-query helpers (`fooOptions`, `fooMutation`,
787
+ `fooQueryKey`, ...). Each `frontends` directory is scoped to its own SDK, so two apps that both generate
788
+ `listItems` aren't confused;
789
+ - **fetch** -- an openapi-fetch call `.GET("/path"` naming this method and path (a `DELETE` of the
790
+ same path with no call of its own is still reported);
791
+ - **url** -- a string/template literal equal to the path template (any method), e.g. a hand-written
792
+ `fetch(`/api/x/${id}`)`, `"/api/x/" + id` or an `<a href=...>` -- nested templates like
793
+ `${qs ? `?a=${b}` : ""}` are handled;
794
+ - **url-sfx** -- a literal (two or more segments) that is a suffix of the route (a client with a base URL,
795
+ or a `${base}` prefix).
796
+
797
+ **Routes the backend refers to by name are used, too.** Auth redirects and OAuth callbacks have no frontend caller
798
+ by nature -- the backend builds their URL: `request.url_for("auth_callback")`, `app.url_path_for("login")`,
799
+ `{{ url_for('login') }}` in a template. A string literal passed (first argument, or `name=`) to one of
800
+ `url_for_functions` in a non-test Python file or Jinja-style template (`.html`, `.jinja`, `.jinja2`, `.j2`) under the app's `app_dir`
801
+ marks the route of that name as used (`"mount:name"` for a mounted sub-app counts as `name`). The route name is recovered from
802
+ its `operationId`: FastAPI's default `name + path + method` form, a bare `name`, or `<prefix>-<name>` from a custom
803
+ `generate_unique_id`; an OpenAPI file without operation ids can't be matched this way. It is a text match, so a call
804
+ in a comment counts too.
805
+
806
+ It is a heuristic: no type information, a URL literal matches every method of that path (and so does an
807
+ SPA `<Link to="/users/${id}">`), and URLs assembled from non-literal pieces (including a non-literal route
808
+ name in `url_for`) or handed to the client by the server are invisible -- exclude those routes explicitly. An app
809
+ that exposes no documented operations (e.g. one wrapped in middleware that hides `openapi()`) is a setup error,
810
+ not a clean pass. Run against our repos, the usual legitimate exclusions are health checks, the SPA
811
+ catch-all, service-to-service endpoints and an external API folder.
812
+
813
+ **Adopting it without fixing everything first:** set `baseline`, run
814
+ `uv run bdt dead-code --update-baseline` once (it concerns the `routes` check only), and commit the file. From
815
+ then on only *new* unused routes fail -- and a baseline line whose route has since been deleted, excluded or
816
+ used is reported as `api-route-baseline-stale`, so the list can only shrink.
817
+
664
818
  ## `bdt find-injection`
665
819
 
666
820
  Scans backend and frontend code for injection risks, and warns when a web project has no
@@ -5,7 +5,8 @@ one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue
5
5
  merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
6
6
  pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
7
7
  postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
8
- TypeScript, and baseline tooling.
8
+ TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
9
+ backend routes nothing calls.
9
10
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
10
11
  remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
11
12
  Consolidates near-duplicate scripts that used to be copy-pasted across
@@ -35,11 +36,12 @@ Find the PR opened from the current branch and report build/check status
35
36
  GitHub — whichever `origin` points at.
36
37
 
37
38
  ```bash
38
- bdt pr status [--target-branch dev] [--wait]
39
+ bdt pr status [--target-branch BRANCH] [--wait]
39
40
  ```
40
41
 
41
- Every `--target` / `--target-branch` option in bdt (`pr create/status/info/retry/publish/update/comment/watch-deploy`,
42
- `issue take`, `commit`) defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override.
42
+ The `--target` / `--target-branch` option of `pr create/retry/publish/update/comment/watch-deploy` and `commit`
43
+ defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override. `pr status`, `pr info` and
44
+ `issue take` only look a PR up, so by default they don't filter on the target branch at all.
43
45
 
44
46
  If the PR can't be merged, that's reported immediately instead of polling
45
47
  for builds/checks that will never run — e.g. on Azure DevOps:
@@ -60,7 +62,7 @@ one is also waiting on approval.
60
62
  `*.visualstudio.com` HTTPS forms). Auth is an explicit PAT (`--pat` or
61
63
  `AZURE_DEVOPS_EXT_PAT`/`AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
62
64
  caller's own `az login` — never embed a PAT literal in a script or CI file.
63
- `--target-branch` selects which PR to look at (ADO's search API needs one).
65
+ `--target-branch` optionally restricts the lookup to PRs into that branch (default: any).
64
66
 
65
67
  **GitHub**: uses `gh`'s own auth (`gh auth login`) and always resolves the
66
68
  PR opened from the current branch — `gh pr view` has no target-branch
@@ -78,7 +80,7 @@ it watches a branch-triggered build/workflow run, not any particular PR.)
78
80
  ## `bdt pr info`
79
81
 
80
82
  ```bash
81
- bdt pr info [--json] [--pr-id N] [--target-branch dev]
83
+ bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]
82
84
  ```
83
85
 
84
86
  One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
@@ -89,7 +91,7 @@ the check rollup (only a failed check fails it; skipped/cancelled ones don't cou
89
91
  from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
90
92
  `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
91
93
 
92
- Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `dev`),
94
+ Azure DevOps limits: the PR is found by the current branch (into `--target-branch` if given, else any target),
93
95
  a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
94
96
  work items are only those whose URL is in the PR description -- not ones linked via
95
97
  `bdt pr create --issue`.
@@ -310,12 +312,12 @@ last argument); extra args are passed through; `--dry-run` prints the command.
310
312
  It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>`
311
313
  on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and
312
314
  passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For
313
- another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is a claim.
315
+ another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is your claim; if it is someone else's, it stops (exit 1) unless `--force`.
314
316
 
315
317
  ## `bdt issue take`
316
318
 
317
319
  ```bash
318
- bdt issue take [NUMBER] [--target-branch dev]
320
+ bdt issue take [NUMBER] [--target-branch BRANCH] [--force]
319
321
  ```
320
322
 
321
323
  Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the
@@ -327,6 +329,50 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
327
329
  that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
328
330
  says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
329
331
 
332
+ ## `bdt env get-keys`
333
+
334
+ Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
335
+ current directory (recursively, skipping `node_modules`, `.git`, `.venv`, `.worktrees` and `.claude`) and directly in `~`, so you (or an agent) can see which variables exist without `cat .env`.
336
+ `--search TEXT` filters by key name (case-insensitive); `--no-home` leaves out the files in `~`. Files in `~` -- and any file using
337
+ `export VAR=VALUE` -- are bash syntax, so the output adds a `source <file>` hint. Exits 1 if nothing matches.
338
+
339
+ ## `bdt translate`
340
+
341
+ One `translations.toml` is the source of truth for all UI translations; `bdt translate` generates the
342
+ per-language `de.json` / `en.json` / ... files from it. The generated files are build artifacts --
343
+ add them to `.gitignore` and run `bdt translate` in your `just install` / build / CI steps.
344
+
345
+ ```toml
346
+ # translations.toml (keys with dots must be quoted when `nested = true`)
347
+ [ADD_BUTTON]
348
+ en = "Add"
349
+ de = "Hinzufügen"
350
+ fr = "Ajouter"
351
+ it = "Aggiungi"
352
+ server_only = true # optional: never emitted into the JSON files (backend-only text)
353
+ ```
354
+
355
+ ```toml
356
+ # pyproject.toml
357
+ [tool.bdt.translate]
358
+ file = "translations.toml" # default
359
+ languages = ["en", "de", "fr", "it"] # default
360
+ required_languages = ["en"] # default; `bdt translate add` fails without these
361
+ output = ["frontend/src/assets/i18n"] # directories receiving <lng>.json
362
+ nested = false # true: "a.b.c" keys become nested JSON objects
363
+ scan = ["frontend/src"] # t("KEY") / $t("KEY") in .ts/.tsx/.js/.jsx/.vue
364
+ scan_jinja = ["backend/print"] # "KEY" | tr in .jinja2/.j2/.html
365
+ ```
366
+
367
+ - A missing language falls back to `de`, then `en`, then the key itself.
368
+ - Keys used in code but absent from `translations.toml` are appended with an English placeholder and
369
+ the command exits 1 without generating anything -- fill in the translations and rerun.
370
+ - `bdt translate add KEY en=Add de=Hinzufügen fr=Ajouter it=Aggiungi` adds a key to `translations.toml` and
371
+ regenerates all JSON files in one go (the `required_languages` are mandatory; `--force` overwrites an existing key).
372
+ - `bdt translate --check` writes nothing and exits 1 if code uses keys missing from the toml (for CI).
373
+ - `bdt translate --import` merges existing `<lng>.json` files into `translations.toml` once, to migrate
374
+ a repo that so far hand-maintained its JSON files; then `git rm --cached` them and ignore them.
375
+
330
376
  ## `bdt worktree`
331
377
 
332
378
  ```bash
@@ -558,8 +604,9 @@ bdt logs fetch --env prod --out logs/ --keep-archive
558
604
  ## `bdt lint`
559
605
 
560
606
  Static checks (implementing [bmsuisse/skills#52](https://github.com/bmsuisse/skills/issues/52))
561
- for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, and
562
- that the repo has its baseline tooling actually set up:
607
+ for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, hand-wired HTTP in
608
+ TypeScript, and that the repo has its baseline tooling actually set up. Unused `.sql` files and
609
+ dead backend routes are a separate command, [`bdt dead-code`](#bdt-dead-code):
563
610
 
564
611
  ```bash
565
612
  bdt lint # scan the current directory, recursively
@@ -648,6 +695,112 @@ already pulled in transitively via `pgdevkit[db]`, but declared explicitly as
648
695
  the `bmsdna-devtools[lint]` extra; a clear install hint is printed (not a raw
649
696
  `ImportError`) if it's ever missing.
650
697
 
698
+ ## `bdt dead-code`
699
+
700
+ Dead code a linter can't see, in one command and one config table, `[tool.bdt.dead_code]`:
701
+
702
+ - **`sql`** -- `.sql` files that no Python code loads;
703
+ - **`routes`** -- backend (FastAPI) routes that neither non-generated frontend code nor a `url_for(...)`
704
+ call uses.
705
+
706
+ Each check runs when it is configured (`sql_roots`, resp. `[[tool.bdt.dead_code.apps]]`); `--only sql` /
707
+ `--only routes` (repeatable) narrows a run, e.g. to keep the slow one -- `routes` imports the app, so run
708
+ `bdt` with the repo's own interpreter (`uv run bdt dead-code`) -- out of a prek hook. Exit code is 0 (clean), 1
709
+ (findings) or 2 (setup problem: invalid config or pyproject.toml, nothing configured, the app doesn't import
710
+ within 5 minutes -- the error shows the import's output).
711
+
712
+ ### `sql` -- unreferenced `.sql` files
713
+
714
+ A `.sql` file that no Python code loads is a query left behind after its caller was deleted or renamed. Name the
715
+ folders that hold *loadable* SQL (not schema/migration scripts, which are applied rather than loaded) and every
716
+ file under them that nothing references is reported (`sql-file-unreferenced`):
717
+
718
+ ```toml
719
+ [tool.bdt.dead_code]
720
+ sql_roots = ["backend/db/queries"] # repo-relative; a typo'd root is itself reported
721
+ sql_loader_functions = ["load_sql"] # default; add your own loader's name if it differs
722
+ sql_unreferenced_ignore = ["backend/db/queries/legacy/*.sql"] # globs for files reached some other way
723
+ # exclude_dirs = ["generated"] # extra directory names to skip, beyond .venv/node_modules/etc.
724
+ ```
725
+
726
+ A file counts as referenced by a literal `load_sql("topic", "name")` call (positional or `topic=`/`name=`;
727
+ topic = the file's parent directory, name = its stem); by a `load_sql("topic", some_var)` call in a Python file that
728
+ also contains the stem as a string literal (so `name = "a" if x else "b"` and lookup tables work);
729
+ by a string literal that is a path whose trailing segments equal the file's repo-relative path
730
+ (`get_sql_with_prm_list("backend/api/sql/x.sql")`); or by its bare filename as a literal in a
731
+ Python file under the SQL folder's parent (`_SQL_DIR / "x.sql"`). It never executes code, so a
732
+ file reached through a fully computed path is a false positive -- list it in
733
+ `sql_unreferenced_ignore`. References are searched across the whole repo, since the caller can live anywhere.
734
+ A Python file that can't be parsed (including syntax newer than the interpreter running `bdt`) is itself
735
+ reported as `sql-check-python-unparseable`, because references in it are unknown.
736
+
737
+ ### `routes` -- backend routes nobody uses
738
+
739
+ Backend (FastAPI) operations that **no non-generated frontend code calls and no backend code references by
740
+ name** -- dead routes that still have to be maintained, secured and tested.
741
+
742
+ ```toml
743
+ [[tool.bdt.dead_code.apps]]
744
+ name = "akeneo" # label used in the report
745
+ app = "main_app:app" # module:attribute, imported in a subprocess ...
746
+ app_dir = "akeneo_editor/backend" # ... with this directory (repo-relative) as cwd/import root
747
+ # openapi = "frontend/openapi.json" # alternative to `app`: a committed OpenAPI document
748
+ frontends = ["akeneo_editor/frontend/src"] # repo-relative dirs or globs, e.g. "mdmapp/app/react_apps/*/src"
749
+ exclude_prefixes = ["/external_api"] # routes meant for other callers (external API, webhooks)
750
+ exclude_tags = ["agent"] # e.g. LLM/MCP tools
751
+ exclude_paths = ["/auth/*", "GET /health"] # fnmatch globs on "/path" or "METHOD /path"
752
+ baseline = "dead-code-baseline.txt" # optional ratchet, see below
753
+ # env = { SOME_REQUIRED_SETTING = "x" } # extra environment for importing the app
754
+ # exclude_frontend_globs = ["src/legacy/*"] # repo-relative frontend files to ignore as callers
755
+ # url_for_functions = ["url_for", "url_path_for"] # default; functions whose first argument names a route
756
+ ```
757
+
758
+ Pair each backend app with *its own* frontends (one `[[...apps]]` entry per backend) -- a route
759
+ called only by another backend's frontend is still unused here.
760
+
761
+ The backend inventory is `app.openapi()` of the app **and of every mounted sub-app** (with the
762
+ mount prefix), so it needs no committed schema and can't go stale; routes with
763
+ `include_in_schema=False` are not considered.
764
+
765
+ **Generated code never counts as a caller.** A generated client lists *every* route, so it is
766
+ skipped (`generated/`, `*.gen.*`, `*.generated.*`, `api-types*`, `openapi_schema*`, `*.d.ts`), as are
767
+ tests and e2e specs (`*.test.*`, `*.spec.*`, `__tests__/`, `tests/`, `e2e/`) -- for TypeScript,
768
+ JavaScript and Vue files alike. Comments are blanked before scanning. An operation is called when
769
+ non-generated code of one of the app's frontends has (strongest first):
770
+
771
+ - **sdk** -- a reference to a hey-api SDK function (read from the `sdk.gen.ts` under that same frontend
772
+ directory, matched on method *and* url) or one of its react-query helpers (`fooOptions`, `fooMutation`,
773
+ `fooQueryKey`, ...). Each `frontends` directory is scoped to its own SDK, so two apps that both generate
774
+ `listItems` aren't confused;
775
+ - **fetch** -- an openapi-fetch call `.GET("/path"` naming this method and path (a `DELETE` of the
776
+ same path with no call of its own is still reported);
777
+ - **url** -- a string/template literal equal to the path template (any method), e.g. a hand-written
778
+ `fetch(`/api/x/${id}`)`, `"/api/x/" + id` or an `<a href=...>` -- nested templates like
779
+ `${qs ? `?a=${b}` : ""}` are handled;
780
+ - **url-sfx** -- a literal (two or more segments) that is a suffix of the route (a client with a base URL,
781
+ or a `${base}` prefix).
782
+
783
+ **Routes the backend refers to by name are used, too.** Auth redirects and OAuth callbacks have no frontend caller
784
+ by nature -- the backend builds their URL: `request.url_for("auth_callback")`, `app.url_path_for("login")`,
785
+ `{{ url_for('login') }}` in a template. A string literal passed (first argument, or `name=`) to one of
786
+ `url_for_functions` in a non-test Python file or Jinja-style template (`.html`, `.jinja`, `.jinja2`, `.j2`) under the app's `app_dir`
787
+ marks the route of that name as used (`"mount:name"` for a mounted sub-app counts as `name`). The route name is recovered from
788
+ its `operationId`: FastAPI's default `name + path + method` form, a bare `name`, or `<prefix>-<name>` from a custom
789
+ `generate_unique_id`; an OpenAPI file without operation ids can't be matched this way. It is a text match, so a call
790
+ in a comment counts too.
791
+
792
+ It is a heuristic: no type information, a URL literal matches every method of that path (and so does an
793
+ SPA `<Link to="/users/${id}">`), and URLs assembled from non-literal pieces (including a non-literal route
794
+ name in `url_for`) or handed to the client by the server are invisible -- exclude those routes explicitly. An app
795
+ that exposes no documented operations (e.g. one wrapped in middleware that hides `openapi()`) is a setup error,
796
+ not a clean pass. Run against our repos, the usual legitimate exclusions are health checks, the SPA
797
+ catch-all, service-to-service endpoints and an external API folder.
798
+
799
+ **Adopting it without fixing everything first:** set `baseline`, run
800
+ `uv run bdt dead-code --update-baseline` once (it concerns the `routes` check only), and commit the file. From
801
+ then on only *new* unused routes fail -- and a baseline line whose route has since been deleted, excluded or
802
+ used is reported as `api-route-baseline-stale`, so the list can only shrink.
803
+
651
804
  ## `bdt find-injection`
652
805
 
653
806
  Scans backend and frontend code for injection risks, and warns when a web project has no
@@ -0,0 +1,58 @@
1
+ """Standalone (stdlib-only) helper of the `routes` check of `bdt dead-code`: list the documented HTTP operations of a
2
+ FastAPI-like app, recursing into mounted sub-apps.
3
+
4
+ It is executed in the *target repo's* interpreter via `python -c <this file's source> module:attr out.json`
5
+ and must therefore never import `bmsdna.devtools`: the working directory is first on `sys.path` there, and a repo may
6
+ ship its own top-level `bmsdna` package (CCMT2 does) that would shadow ours. `api_usage` imports it normally for
7
+ `ops_from_doc`, so the OpenAPI-document parsing exists exactly once.
8
+ """
9
+
10
+ import importlib
11
+ import json
12
+ import sys
13
+
14
+ HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head")
15
+
16
+
17
+ def ops_from_doc(doc, mount=""):
18
+ """Operations of one OpenAPI document as dicts, with `mount` prepended to every path."""
19
+ ops = []
20
+ for path, item in (doc.get("paths") or {}).items():
21
+ for method, op in item.items():
22
+ if method in HTTP_METHODS and isinstance(op, dict):
23
+ ops.append(
24
+ {
25
+ "method": method.upper(),
26
+ "path": mount + path,
27
+ "tags": list(op.get("tags") or []),
28
+ "mount": mount,
29
+ "operation_id": str(op.get("operationId") or ""),
30
+ }
31
+ )
32
+ return ops
33
+
34
+
35
+ def collect(app, mount=""):
36
+ """Operations of `app` and, recursively, of every mounted sub-app. Uses only `app.openapi()` and the
37
+ `.path`/`.app` attributes of mount routes -- not FastAPI internals, which change between releases (FastAPI 0.141 wraps
38
+ included routers in lazy objects that `app.routes` no longer lists as plain routes)."""
39
+ openapi = getattr(app, "openapi", None)
40
+ if not callable(openapi):
41
+ return []
42
+ ops = ops_from_doc(openapi(), mount)
43
+ for route in getattr(app, "routes", None) or []:
44
+ sub = getattr(route, "app", None)
45
+ if sub is not None and callable(getattr(sub, "openapi", None)) and isinstance(getattr(route, "path", None), str):
46
+ ops.extend(collect(sub, mount + route.path.rstrip("/")))
47
+ return ops
48
+
49
+
50
+ def main(argv):
51
+ module_name, _, attr = argv[0].partition(":")
52
+ app = getattr(importlib.import_module(module_name), attr)
53
+ with open(argv[1], "w", encoding="utf-8") as out:
54
+ json.dump(collect(app), out)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main(sys.argv[1:])