bmsdna-devtools 0.34.0__tar.gz → 0.35.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.34.0/README.md → bmsdna_devtools-0.35.0/PKG-INFO +28 -3
  2. bmsdna_devtools-0.34.0/PKG-INFO → bmsdna_devtools-0.35.0/README.md +14 -17
  3. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/cli.py +1 -1
  4. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_sql.py +81 -15
  5. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/pyproject.toml +1 -1
  6. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/skills/bmsdna-devtools/SKILL.md +1 -1
  7. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_find_injection.py +12 -0
  8. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_sql.py +255 -0
  9. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/uv.lock +1 -1
  10. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.github/workflows/auto-release.yml +0 -0
  11. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.github/workflows/python-publish.yml +0 -0
  12. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.github/workflows/python-test.yml +0 -0
  13. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.gitignore +0 -0
  14. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.python-version +0 -0
  15. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/__init__.py +0 -0
  16. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/_api_dump.py +0 -0
  17. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/ado_auth.py +0 -0
  18. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/ado_issue.py +0 -0
  19. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/api_usage.py +0 -0
  20. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/app_service_logs.py +0 -0
  21. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/bdt_config.py +0 -0
  22. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/cli_tools.py +0 -0
  23. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/commit.py +0 -0
  24. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/dead_code.py +0 -0
  25. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/env_config.py +0 -0
  26. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/env_files.py +0 -0
  27. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/find_injection.py +0 -0
  28. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/find_repo.py +0 -0
  29. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/gh_issue.py +0 -0
  30. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/gh_pr.py +0 -0
  31. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/gitrepo.py +0 -0
  32. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/injection_frontend.py +0 -0
  33. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/injection_python.py +0 -0
  34. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/issue_do.py +0 -0
  35. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint.py +0 -0
  36. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_findings.py +0 -0
  37. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_models.py +0 -0
  38. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  39. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_tooling.py +0 -0
  40. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_typescript.py +0 -0
  41. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/logs.py +0 -0
  42. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_build.py +0 -0
  43. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_info.py +0 -0
  44. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  45. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_labels.py +0 -0
  46. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_markdown.py +0 -0
  47. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pull.py +0 -0
  48. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/testdb.py +0 -0
  49. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/translate.py +0 -0
  50. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/worktree.py +0 -0
  51. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/justfile +0 -0
  52. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/conftest.py +0 -0
  53. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_ado_auth.py +0 -0
  54. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_ado_issue.py +0 -0
  55. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_ado_issue_flow.py +0 -0
  56. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_api_usage.py +0 -0
  57. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_app_service_logs.py +0 -0
  58. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_bdt_config.py +0 -0
  59. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_cleanup.py +0 -0
  60. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_commit.py +0 -0
  61. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_issue.py +0 -0
  62. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_pr_create.py +0 -0
  63. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_pr_id.py +0 -0
  64. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_pull.py +0 -0
  65. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_tools.py +0 -0
  66. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_commit.py +0 -0
  67. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_env_config.py +0 -0
  68. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_env_files.py +0 -0
  69. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_find_repo.py +0 -0
  70. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_gh_issue.py +0 -0
  71. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_gh_pr.py +0 -0
  72. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_gitrepo.py +0 -0
  73. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_issue_do.py +0 -0
  74. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint.py +0 -0
  75. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_models.py +0 -0
  76. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_sql_files.py +0 -0
  77. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_tooling.py +0 -0
  78. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_typescript.py +0 -0
  79. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_build.py +0 -0
  80. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_build_flow.py +0 -0
  81. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_info.py +0 -0
  82. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_issue_link.py +0 -0
  83. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_labels.py +0 -0
  84. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_markdown.py +0 -0
  85. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pull.py +0 -0
  86. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_testdb.py +0 -0
  87. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_translate.py +0 -0
  88. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_worktree_clean.py +0 -0
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: bmsdna-devtools
3
+ Version: 0.35.0
4
+ Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: pgdevkit[db]>=0.7.1
7
+ Requires-Dist: requests>=2.32.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: tomli-w>=1.0.0
10
+ Requires-Dist: typer>=0.26.7
11
+ Provides-Extra: lint
12
+ Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # bmsdna-devtools
2
16
 
3
17
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -616,12 +630,21 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
616
630
  bdt lint --no-tooling-check # skip the tooling-config check for this run
617
631
  ```
618
632
 
619
- Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
633
+ Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
620
634
  `PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
621
635
  literal or f-string/concatenation/`%`-format expression is checked (an
622
636
  unresolvable argument, e.g. a plain function parameter, is silently skipped --
623
637
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
624
- it can't see through):
638
+ it can't see through).
639
+
640
+ pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
641
+ exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
642
+ `pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
643
+ recognised file-wide), so an unrelated `execute()` from another library, or one defined locally in a file without that
644
+ import, is never flagged. Aliased imports of the other helpers (`fetch_all as fa`) are recognised too. Not covered: a re-export through the
645
+ project's own module (`from app.db import execute`; call it as `db.execute(...)` or import it from `pgdevkit.db`
646
+ instead), a subclass of `PostgresJsonResponse` under another name, and CCMT2's pre-pgdevkit
647
+ `PostgresJsonResponse("postgres", sql)`, whose first argument is a connection source, not SQL.
625
648
 
626
649
  - **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
627
650
  JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
@@ -634,6 +657,8 @@ it can't see through):
634
657
  with an f-string, `+` concatenation, the `%` operator, or `str.format()`
635
658
  instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
636
659
  usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
660
+ A dynamic fragment may be an identifier/expression, an optional clause, or a (possibly empty) list of
661
+ assignments -- the statement is flagged if it parses as DML with any of these readings of its fragments.
637
662
  - **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
638
663
  sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
639
664
  The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
@@ -821,7 +846,7 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
821
846
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
822
847
  code 0 unless `--strict`).
823
848
 
824
- - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
849
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
825
850
  SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
826
851
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
827
852
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: bmsdna-devtools
3
- Version: 0.34.0
4
- Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
- Requires-Python: >=3.14
6
- Requires-Dist: pgdevkit[db]>=0.7.1
7
- Requires-Dist: requests>=2.32.0
8
- Requires-Dist: rich>=13.0.0
9
- Requires-Dist: tomli-w>=1.0.0
10
- Requires-Dist: typer>=0.26.7
11
- Provides-Extra: lint
12
- Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
13
- Description-Content-Type: text/markdown
14
-
15
1
  # bmsdna-devtools
16
2
 
17
3
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -630,12 +616,21 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
630
616
  bdt lint --no-tooling-check # skip the tooling-config check for this run
631
617
  ```
632
618
 
633
- Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
619
+ Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
634
620
  `PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
635
621
  literal or f-string/concatenation/`%`-format expression is checked (an
636
622
  unresolvable argument, e.g. a plain function parameter, is silently skipped --
637
623
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
638
- it can't see through):
624
+ it can't see through).
625
+
626
+ pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
627
+ exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
628
+ `pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
629
+ recognised file-wide), so an unrelated `execute()` from another library, or one defined locally in a file without that
630
+ import, is never flagged. Aliased imports of the other helpers (`fetch_all as fa`) are recognised too. Not covered: a re-export through the
631
+ project's own module (`from app.db import execute`; call it as `db.execute(...)` or import it from `pgdevkit.db`
632
+ instead), a subclass of `PostgresJsonResponse` under another name, and CCMT2's pre-pgdevkit
633
+ `PostgresJsonResponse("postgres", sql)`, whose first argument is a connection source, not SQL.
639
634
 
640
635
  - **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
641
636
  JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
@@ -648,6 +643,8 @@ it can't see through):
648
643
  with an f-string, `+` concatenation, the `%` operator, or `str.format()`
649
644
  instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
650
645
  usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
646
+ A dynamic fragment may be an identifier/expression, an optional clause, or a (possibly empty) list of
647
+ assignments -- the statement is flagged if it parses as DML with any of these readings of its fragments.
651
648
  - **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
652
649
  sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
653
650
  The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
@@ -835,7 +832,7 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
835
832
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
836
833
  code 0 unless `--strict`).
837
834
 
838
- - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
835
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
839
836
  SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
840
837
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
841
838
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
@@ -1093,7 +1093,7 @@ def lint(
1093
1093
  ),
1094
1094
  ) -> None:
1095
1095
  """Static checks (bmsuisse/skills#52): postgres/psycopg SQL rules on every `.execute()` call and pgdevkit
1096
- `fetch_all`/`fetch_one`/`fetch_scalar`/`PostgresJsonResponse` call
1096
+ `fetch_all`/`fetch_one`/`fetch_scalar`/`execute`/`PostgresJsonResponse` call
1097
1097
  (must use load_sql()/a .sql file, a t-string, or psycopg.sql for anything beyond a trivial
1098
1098
  query; never an f-string/concatenation/`%`-formatting), pydantic-model placement under api/
1099
1099
  directories, hand-wired HTTP in TypeScript, and that the repo declares/configures ty, ruff, pytest and prek.
@@ -1,5 +1,5 @@
1
1
  """AST rule engine for `bdt lint`'s postgres/psycopg checks (bmsuisse/skills#52):
2
- every `.execute()`/`.executemany()` call (and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
2
+ every `.execute()`/`.executemany()` call (and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
3
3
  `PostgresJsonResponse`) is classified by how its SQL argument was
4
4
  built, then checked against the `postgres-best-practices` skill's rules
5
5
  (../../skills/postgres-best-practices in a checkout of bmsuisse/skills).
@@ -24,6 +24,7 @@ than guessed at -- a linter that can't be sure must stay quiet, not noisy.
24
24
  from __future__ import annotations
25
25
 
26
26
  import ast
27
+ import itertools
27
28
  import re
28
29
  import sys
29
30
  from collections.abc import Mapping
@@ -63,10 +64,18 @@ _POSITIONAL_PARAM_RE = re.compile(r"(?<!%)%s\b")
63
64
  # Placeholder substituted for a non-literal fragment (an f-string {expr}, a concatenated
64
65
  # non-constant operand, a str.format() {field}) before attempting an sqlglot parse -- padded
65
66
  # with spaces so it never merges into a neighboring identifier/keyword, and looks like an
66
- # ordinary identifier so the parse doesn't fail on the substitution itself.
67
+ # ordinary identifier so the parse doesn't fail on the substitution itself (see
68
+ # `_PROBE_SLOT_VARIANTS`/`_parse_probe_text` for the fallbacks when an identifier doesn't fit).
67
69
  _PROBE_PLACEHOLDER = " __X__ "
68
70
  _FORMAT_FIELD_RE = re.compile(r"\{[^{}]*\}")
69
71
 
72
+ # A dynamic fragment isn't always an identifier/expression: `SET {assignments}, {extra}` where `extra` is
73
+ # empty or `"is_approved = false,"`, or an optional `{where}` clause. When the probe with every fragment as an
74
+ # identifier doesn't parse, each fragment is retried as "nothing" and as an assignment, all combinations
75
+ # (only up to _MAX_PROBE_SLOTS fragments, so the number of parses stays small).
76
+ _PROBE_SLOT_VARIANTS = (_PROBE_PLACEHOLDER, " ", " __X__ = 1 ")
77
+ _MAX_PROBE_SLOTS = 4
78
+
70
79
  _MAX_RESOLVE_HOPS = 5
71
80
 
72
81
 
@@ -127,6 +136,7 @@ class _Trust:
127
136
  sqlglot_names: frozenset[str] = frozenset()
128
137
  literal_funcs: frozenset[str] = frozenset()
129
138
  sqlglot_aliases: Mapping[str, str] = field(default_factory=dict) # local name -> name it was imported as
139
+ pgdevkit_sinks: frozenset[str] = frozenset() # local names bound by `from pgdevkit[.x] import <sql-taking helper>`
130
140
 
131
141
 
132
142
  _NO_TRUST = _Trust()
@@ -185,10 +195,15 @@ def _unwrap_str_call(node: ast.Call) -> ast.expr | None:
185
195
  return None
186
196
 
187
197
 
198
+ def _is_pgdevkit_module(module: str | None) -> bool:
199
+ return module is not None and (module == "pgdevkit" or module.startswith("pgdevkit."))
200
+
201
+
188
202
  def _file_trust(tree: ast.AST) -> _Trust:
189
203
  literal_funcs: set[str] = set()
190
204
  sqlglot_names: set[str] = set()
191
205
  sqlglot_aliases: dict[str, str] = {}
206
+ pgdevkit_sinks: set[str] = set()
192
207
  for node in ast.walk(tree):
193
208
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None and _is_literal_string_ref(node.returns):
194
209
  literal_funcs.add(node.name)
@@ -197,7 +212,9 @@ def _file_trust(tree: ast.AST) -> _Trust:
197
212
  elif isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "sqlglot":
198
213
  sqlglot_names.update(a.asname or a.name for a in node.names)
199
214
  sqlglot_aliases.update({a.asname or a.name: a.name for a in node.names})
200
- return _Trust(frozenset(sqlglot_names), frozenset(literal_funcs), sqlglot_aliases)
215
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and _is_pgdevkit_module(node.module):
216
+ pgdevkit_sinks.update(a.asname or a.name for a in node.names if a.name in _PGDEVKIT_SQL_CALLEES)
217
+ return _Trust(frozenset(sqlglot_names), frozenset(literal_funcs), sqlglot_aliases, frozenset(pgdevkit_sinks))
201
218
 
202
219
 
203
220
  def _resolve_single(expr: ast.expr, lookup) -> ast.expr:
@@ -278,8 +295,47 @@ _FSTRING_FIX = (
278
295
  _GENERIC_FIX = "Use a psycopg t-string (Python 3.14+), psycopg.sql for dynamic SQL, or load_sql()/a .sql file for static SQL."
279
296
 
280
297
 
298
+ # Keyword that must literally be in the text before a variant reading (a fragment as nothing/an assignment) may
299
+ # count: sqlglot is lenient enough to read prose like "Update to version {v}" as an UPDATE with the fragment gone.
300
+ _VARIANT_REQUIRED_KEYWORD: dict[type, re.Pattern[str]] = {}
301
+ if exp is not None:
302
+ _VARIANT_REQUIRED_KEYWORD = {
303
+ exp.Select: re.compile(r"(?i)\bfrom\b"),
304
+ exp.Union: re.compile(r"(?i)\bfrom\b"),
305
+ exp.Insert: re.compile(r"(?i)\binto\b"),
306
+ exp.Update: re.compile(r"(?i)\bset\b"),
307
+ exp.Delete: re.compile(r"(?i)\bfrom\b"),
308
+ exp.Merge: re.compile(r"(?i)\busing\b"),
309
+ }
310
+
311
+
312
+ def _parse_probe_text(text: str) -> "exp.Expression | None":
313
+ """`parse_sql_text` for text with `_PROBE_PLACEHOLDER` standing in for dynamic fragments: the placeholder
314
+ as an identifier first, then (see `_PROBE_SLOT_VARIANTS`) every fragment as nothing/an assignment -- the
315
+ latter only when the literal text carries the statement's own keyword (`_VARIANT_REQUIRED_KEYWORD`)."""
316
+ parsed = parse_sql_text(text)
317
+ if parsed is not None:
318
+ return parsed
319
+ parts = text.split(_PROBE_PLACEHOLDER)
320
+ slots = len(parts) - 1
321
+ if not 1 <= slots <= _MAX_PROBE_SLOTS:
322
+ return None
323
+ literal_text = " ".join(parts)
324
+ for combination in itertools.product(_PROBE_SLOT_VARIANTS, repeat=slots):
325
+ if all(c == _PROBE_PLACEHOLDER for c in combination):
326
+ continue # that's `text` itself, already tried
327
+ candidate = "".join(a + b for a, b in itertools.zip_longest(parts, combination, fillvalue=""))
328
+ parsed = parse_sql_text(candidate)
329
+ if parsed is None:
330
+ continue
331
+ keyword = next((rx for t, rx in _VARIANT_REQUIRED_KEYWORD.items() if isinstance(parsed, t)), None)
332
+ if keyword is not None and keyword.search(literal_text):
333
+ return parsed
334
+ return None
335
+
336
+
281
337
  def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str) -> list[Finding]:
282
- if parse_sql_text(text) is None:
338
+ if _parse_probe_text(text) is None:
283
339
  return []
284
340
  fix = _FSTRING_FIX if rule == "sql-fstring-injection" else _GENERIC_FIX
285
341
  return [Finding(path, lineno, rule, f"SQL built with {how} -- injection risk. {fix}")]
@@ -455,22 +511,32 @@ def _is_sql_named(name: str) -> bool:
455
511
  return lowered == "sql" or lowered.endswith("_sql")
456
512
 
457
513
 
458
- # pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and `pgdevkit.fastapi.PostgresJsonResponse` run their first
459
- # argument as SQL, exactly like `.execute()`, so they get the same rules (plain functions / a class, so matched by
460
- # bare name too). Matched by name only, so not covered: an alias (`as PJR`), a subclass under another name (the
461
- # documented `class AppJson(PostgresJsonResponse): expose_errors = ...`), and the pre-pgdevkit
514
+ # pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and `pgdevkit.fastapi.PostgresJsonResponse` run their
515
+ # first argument as SQL, exactly like `.execute()`, so they get the same rules. As an attribute (`db.fetch_all(...)`,
516
+ # `pgdevkit.db.execute(...)`) they are matched by name only, like `.execute()` itself.
517
+ _PGDEVKIT_SQL_CALLEES = frozenset({"fetch_all", "fetch_one", "fetch_scalar", "execute", "PostgresJsonResponse"})
518
+ # A bare `execute(...)` is far too generic a name (sqlite helpers, subprocess wrappers, ...): it counts only in a file
519
+ # that imports it from `pgdevkit`/`pgdevkit.db` (also under an alias: `from pgdevkit.db import execute as run`; see
520
+ # `_file_trust`). The import is tracked file-wide, so a local def/parameter named like it is not told apart. The sqlglot
521
+ # DML gate still applies on top. The other callees are matched by bare name too, and by their pgdevkit import alias.
522
+ # Not covered: a re-export through the project's own module (`from app.db import execute`), a subclass of
523
+ # PostgresJsonResponse under another name (the documented `class AppJson(PostgresJsonResponse)`), and the pre-pgdevkit
462
524
  # `PostgresJsonResponse("postgres", sql, ...)` copy in CCMT2, whose first argument is a connection source -- its SQL
463
525
  # only gets checked once that call site moves to pgdevkit.
464
- _PGDEVKIT_SQL_CALLEES = frozenset({"fetch_all", "fetch_one", "fetch_scalar", "PostgresJsonResponse"})
526
+ _IMPORT_GATED_BARE_CALLEES = frozenset({"execute"})
527
+ _BARE_NAME_SQL_CALLEES = _PGDEVKIT_SQL_CALLEES - _IMPORT_GATED_BARE_CALLEES
528
+
529
+
530
+ def _is_pgdevkit_sink_call(func: ast.expr, trust: _Trust) -> bool:
531
+ if isinstance(func, ast.Name):
532
+ return func.id in _BARE_NAME_SQL_CALLEES or func.id in trust.pgdevkit_sinks
533
+ return isinstance(func, ast.Attribute) and func.attr in _PGDEVKIT_SQL_CALLEES
465
534
 
466
535
 
467
- def _execute_query_arg(call: ast.Call) -> ast.expr | None:
536
+ def _execute_query_arg(call: ast.Call, trust: _Trust = _NO_TRUST) -> ast.expr | None:
468
537
  func = call.func
469
538
  is_execute = isinstance(func, ast.Attribute) and func.attr in ("execute", "executemany")
470
- is_fetch_helper = (isinstance(func, ast.Name) and func.id in _PGDEVKIT_SQL_CALLEES) or (
471
- isinstance(func, ast.Attribute) and func.attr in _PGDEVKIT_SQL_CALLEES
472
- )
473
- if not (is_execute or is_fetch_helper):
539
+ if not (is_execute or _is_pgdevkit_sink_call(func, trust)):
474
540
  return None
475
541
  if call.args:
476
542
  return call.args[0]
@@ -634,7 +700,7 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
634
700
 
635
701
  def visit_Call(self, node: ast.Call) -> None:
636
702
  self.findings.extend(_sqlglot_builder_findings(node, self.path, self.trust, self._lookup, self._sqlglot_vars))
637
- query_arg = _execute_query_arg(node)
703
+ query_arg = _execute_query_arg(node, self.trust)
638
704
  if query_arg is not None:
639
705
  found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, self.trust, self.review)
640
706
  if isinstance(query_arg, ast.Name) and _is_sql_named(query_arg.id) and self._all_bare_strings(query_arg.id):
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.34.0"
13
+ version = "0.35.0"
14
14
  description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency
@@ -123,7 +123,7 @@ vendor a git submodule under that path.
123
123
 
124
124
  ## Static checks
125
125
 
126
- - `bdt lint [paths]` -- SQL/psycopg rules (`.execute()`, pgdevkit `fetch_*`, `PostgresJsonResponse`), pydantic-model placement, hand-wired HTTP in TypeScript, baseline tooling.
126
+ - `bdt lint [paths]` -- SQL/psycopg rules (`.execute()`, pgdevkit `fetch_*`/`execute`, `PostgresJsonResponse`), pydantic-model placement, hand-wired HTTP in TypeScript, baseline tooling.
127
127
  - `uv run bdt dead-code` -- dead code a linter can't see, configured under `[tool.bdt.dead_code]` in pyproject.toml:
128
128
  `.sql` files no Python code loads (`sql_roots`) and FastAPI routes that neither non-generated frontend code nor a
129
129
  `url_for(...)` call uses (`[[tool.bdt.dead_code.apps]]`: app or openapi file, frontends, excludes, optional `baseline`;
@@ -151,6 +151,18 @@ def test_sql_injection_is_error_and_unverified_call_is_review(tmp_path: Path) ->
151
151
  assert [f.rule for f in result.reviews] == ["sql-unverified-call"]
152
152
 
153
153
 
154
+ def test_pgdevkit_execute_is_a_sql_sink_for_find_injection(tmp_path: Path) -> None:
155
+ (tmp_path / "db.py").write_text(
156
+ "from pgdevkit.db import execute\n\n"
157
+ 'async def a(x):\n await execute(f"update t set a = {x}")\n\n'
158
+ "async def b(x):\n await execute(build(x))\n"
159
+ )
160
+ (tmp_path / "other.py").write_text('from lib import execute\n\ndef a(x):\n execute(f"update t set a = {x}")\n')
161
+ result = _run(tmp_path)
162
+ assert [(f.path.name, f.rule) for f in result.errors] == [("db.py", "sql-fstring-injection")]
163
+ assert [(f.path.name, f.rule) for f in result.reviews] == [("db.py", "sql-unverified-call")]
164
+
165
+
154
166
  def test_tests_and_missing_paths(tmp_path: Path) -> None:
155
167
  (tmp_path / "tests").mkdir()
156
168
  (tmp_path / "tests" / "test_x.py").write_text("eval(x)\n")
@@ -608,3 +608,258 @@ def f(table):
608
608
  return PostgresJsonResponse("postgres", f"select id from dim.{table}", parameters={})
609
609
  '''
610
610
  assert _findings(source, tmp_path / "a.py") == []
611
+
612
+
613
+ # --- pgdevkit.db.execute (bare name, gated on a pgdevkit import) ---
614
+
615
+
616
+ def test_bare_execute_imported_from_pgdevkit_is_flagged_like_cursor_execute(tmp_path: Path) -> None:
617
+ # Real pattern from CCMT2's _write_rule_change.
618
+ source = '''
619
+ from pgdevkit.db import execute
620
+
621
+ async def f(table, rule_id):
622
+ await execute(f"UPDATE ccmt.{table} SET active = false WHERE id = %(id)s", {"id": rule_id})
623
+ '''
624
+ findings = _findings(source, tmp_path / "a.py")
625
+ assert _rules(findings) == {"sql-fstring-injection"}
626
+ assert [f.line for f in findings] == [5]
627
+
628
+
629
+ def test_bare_execute_flags_concat_format_percent_and_keyword_query(tmp_path: Path) -> None:
630
+ source = '''
631
+ from pgdevkit.db import execute
632
+
633
+ async def f(value, table):
634
+ await execute("delete from t where id = " + str(value))
635
+ await execute("delete from t where id = %s" % value)
636
+ await execute("delete from t where id = {}".format(value))
637
+ await execute(query=f"delete from {table} where id = 1", pool=None)
638
+ '''
639
+ findings = _findings(source, tmp_path / "a.py")
640
+ assert _rules(findings) == {
641
+ "sql-concat-injection",
642
+ "sql-percent-format-injection",
643
+ "sql-format-injection",
644
+ "sql-fstring-injection",
645
+ }
646
+ assert len(findings) == 4
647
+
648
+
649
+ def test_bare_execute_follows_a_variable_to_its_unsafe_assignment(tmp_path: Path) -> None:
650
+ source = '''
651
+ from pgdevkit.db import execute
652
+
653
+ async def f(table):
654
+ query = f"update dim.{table} set x = 1"
655
+ await execute(query)
656
+ '''
657
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
658
+
659
+
660
+ def test_bare_execute_import_forms_gate_the_rule(tmp_path: Path) -> None:
661
+ bad = 'await {call}(f"update t set x = {{v}}")'
662
+ for i, (imp, call) in enumerate(
663
+ [
664
+ ("from pgdevkit.db import execute", "execute"),
665
+ ("from pgdevkit.db import execute as run", "run"),
666
+ ("from pgdevkit.db import fetch_all, execute", "execute"),
667
+ ("from pgdevkit.db.fetch import execute", "execute"),
668
+ ]
669
+ ):
670
+ source = f"{imp}\n\nasync def f(v):\n {bad.format(call=call)}\n"
671
+ assert _rules(_findings(source, tmp_path / f"a{i}.py")) == {"sql-fstring-injection"}, imp
672
+
673
+
674
+ def test_execute_attribute_forms_are_flagged(tmp_path: Path) -> None:
675
+ source = '''
676
+ import pgdevkit
677
+ import pgdevkit.db as db
678
+ from pgdevkit import db as pgdb
679
+
680
+ async def f(v):
681
+ await db.execute(f"update t set x = {v}")
682
+ await pgdb.execute("update t set x = " + v)
683
+ await pgdevkit.db.execute("update t set x = {}".format(v))
684
+ '''
685
+ findings = _findings(source, tmp_path / "a.py")
686
+ assert _rules(findings) == {"sql-fstring-injection", "sql-concat-injection", "sql-format-injection"}
687
+ assert len(findings) == 3
688
+
689
+
690
+ def test_bare_execute_accepts_literal_load_sql_sqlglot_template_and_sql_composed(tmp_path: Path) -> None:
691
+ source = '''
692
+ from psycopg import sql
693
+ from sqlglot import exp
694
+ from pgdevkit.db import execute
695
+
696
+ async def f(sql_loader, user_id, table):
697
+ await execute(sql_loader.load_sql("users", "deactivate"), {"id": user_id})
698
+ await execute(exp.delete("t").where("id = 1").sql(dialect="postgres"))
699
+ await execute(t"update t set x = 1 where user_id = {user_id}")
700
+ await execute(sql.SQL("update {} set x = 1").format(sql.Identifier(table)))
701
+ await execute("update t set x = 1 where user_id = %(id)s", {"id": user_id})
702
+ '''
703
+ assert _findings(source, tmp_path / "a.py") == []
704
+
705
+
706
+ def test_bare_execute_applies_inline_complexity_and_positional_rules(tmp_path: Path) -> None:
707
+ source = '''
708
+ from pgdevkit.db import execute
709
+
710
+ async def f():
711
+ await execute("insert into t (id) select id from u")
712
+ await execute("update t set x = 1 where id = %s", (1,))
713
+ '''
714
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-inline-too-complex", "sql-positional-param"}
715
+
716
+
717
+ def test_bare_execute_simple_write_is_clean_and_forbidden_join_is_flagged(tmp_path: Path) -> None:
718
+ source = '''
719
+ from pgdevkit.db import execute
720
+
721
+ async def f():
722
+ await execute("update t set x = 1 where id = %(id)s", {"id": 1})
723
+ await execute("delete from t using u right join v on v.id = u.id where t.id = u.id")
724
+ '''
725
+ assert "sql-forbidden-join" in _rules(_findings(source, tmp_path / "a.py"))
726
+
727
+
728
+ def test_bare_execute_is_reported_as_unverified_call_in_review_mode(tmp_path: Path) -> None:
729
+ source = '''
730
+ from pgdevkit.db import execute
731
+
732
+ async def f(build_query):
733
+ await execute(build_query("t"))
734
+ '''
735
+ path = tmp_path / "a.py"
736
+ path.write_text(source)
737
+ assert check_sql_file(path) == []
738
+ assert _rules(check_sql_file(path, review=True)) == {"sql-unverified-call"}
739
+
740
+
741
+ def test_bare_execute_without_a_pgdevkit_import_is_not_flagged(tmp_path: Path) -> None:
742
+ # `execute` is a very generic name: no pgdevkit import, no finding -- even when the text is real DML.
743
+ source = '''
744
+ async def execute(sql):
745
+ ...
746
+
747
+ async def f(table):
748
+ await execute(f"update {table} set x = 1")
749
+ await execute("update t set x = %s", (1,))
750
+ '''
751
+ assert _findings(source, tmp_path / "a.py") == []
752
+
753
+
754
+ def test_bare_execute_imported_from_another_library_is_not_flagged(tmp_path: Path) -> None:
755
+ source = '''
756
+ from sqlalchemy import execute
757
+ from mylib.db import execute as run
758
+ from .db import execute as local_run
759
+ from . import execute as dotted
760
+ from pgdevkitx import execute as lookalike
761
+
762
+ def f(table, name):
763
+ execute(f"update {table} set x = 1")
764
+ run(f"update {table} set x = 1")
765
+ local_run(f"update {table} set x = 1")
766
+ dotted(f"update {table} set x = 1")
767
+ lookalike(f"update {table} set x = 1")
768
+ execute(f"echo {name}")
769
+ '''
770
+ assert _findings(source, tmp_path / "a.py") == []
771
+
772
+
773
+ def test_pgdevkit_import_does_not_make_a_non_sql_execute_flagged(tmp_path: Path) -> None:
774
+ source = '''
775
+ from pgdevkit.db import execute
776
+
777
+ async def f(cmd, name):
778
+ await execute(f"echo {name}")
779
+ await execute(f"COPY t TO '/tmp/{name}.csv'")
780
+ '''
781
+ assert _findings(source, tmp_path / "a.py") == []
782
+
783
+
784
+ def test_pgdevkit_import_of_other_names_does_not_enable_bare_execute(tmp_path: Path) -> None:
785
+ source = '''
786
+ import pgdevkit
787
+ from pgdevkit.db import fetch_all
788
+ from other import execute
789
+
790
+ async def f(table):
791
+ execute(f"update {table} set x = 1")
792
+ '''
793
+ assert _findings(source, tmp_path / "a.py") == []
794
+
795
+
796
+ def test_aliased_pgdevkit_fetch_helpers_and_json_response_are_flagged(tmp_path: Path) -> None:
797
+ source = '''
798
+ from pgdevkit.db import fetch_all as fa
799
+ from pgdevkit.fastapi import PostgresJsonResponse as PJR
800
+
801
+ async def f(table):
802
+ await fa(f"select id from {table}")
803
+ return PJR(f"select id from {table}")
804
+ '''
805
+ findings = _findings(source, tmp_path / "a.py")
806
+ assert _rules(findings) == {"sql-fstring-injection"}
807
+ assert len(findings) == 2
808
+
809
+
810
+ # --- dynamic fragments that aren't identifiers (probe variants) ---
811
+
812
+
813
+ def test_fstring_with_optional_assignment_fragments_is_flagged(tmp_path: Path) -> None:
814
+ # CCMT2's _write_rule_change: `{is_approved_clause}` is "" or "is_approved = false," -- as an identifier probe
815
+ # the statement doesn't parse, as nothing/an assignment it does.
816
+ source = '''
817
+ from pgdevkit.db import execute
818
+
819
+ async def f(con, fields, tiers):
820
+ set_clause = ", ".join(f"{c} = %({c})s" for c in fields)
821
+ is_approved_clause = "is_approved = false," if tiers is not None else ""
822
+ await execute(
823
+ f"""
824
+ UPDATE editing.bonus_rules
825
+ SET {set_clause},
826
+ {is_approved_clause}
827
+ modified_user = %(modified_user)s
828
+ WHERE bonus_rule_id = %(bonus_rule_id)s
829
+ """,
830
+ {**fields, "modified_user": "x", "bonus_rule_id": 1},
831
+ con=con,
832
+ )
833
+ '''
834
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
835
+
836
+
837
+ def test_fstring_with_optional_trailing_clause_fragment_is_flagged(tmp_path: Path) -> None:
838
+ source = '''
839
+ async def f(cur, lock):
840
+ await cur.execute(f"update t set x = 1 where id = %(id)s {lock}", {"id": 1})
841
+ '''
842
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
843
+
844
+
845
+ def test_probe_variants_do_not_flag_non_sql_or_non_dml_text(tmp_path: Path) -> None:
846
+ source = '''
847
+ async def f(cur, a, b, c, d, e, name):
848
+ await cur.execute(f"{a} {b} {c} {d} {e}")
849
+ await cur.execute(f"update the user {name} please")
850
+ await cur.execute(f"COPY t TO '/tmp/{name}.csv'")
851
+ await cur.execute(f"set {name} {a} {b} {c} {d} {e}")
852
+ '''
853
+ assert _findings(source, tmp_path / "a.py") == []
854
+
855
+
856
+ def test_probe_variants_do_not_read_prose_as_dml(tmp_path: Path) -> None:
857
+ # sqlglot parses these once the fragment is dropped/turned into an assignment ("UPDATE to AS version ...").
858
+ source = '''
859
+ def f(cur, v):
860
+ cur.execute(f"Update to version {v}")
861
+ cur.execute(f"Delete the file {v}")
862
+ cur.execute(f"Insert coin {v}")
863
+ cur.execute(f"Update complete, {v} rows changed")
864
+ '''
865
+ assert _findings(source, tmp_path / "a.py") == []
@@ -22,7 +22,7 @@ wheels = [
22
22
 
23
23
  [[package]]
24
24
  name = "bmsdna-devtools"
25
- version = "0.34.0"
25
+ version = "0.35.0"
26
26
  source = { editable = "." }
27
27
  dependencies = [
28
28
  { name = "pgdevkit", extra = ["db"] },