bmsdna-devtools 0.35.0__tar.gz → 0.36.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.35.0 → bmsdna_devtools-0.36.0}/PKG-INFO +19 -4
  2. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/README.md +18 -3
  3. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/cli.py +2 -1
  4. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_sql.py +147 -24
  5. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/pyproject.toml +1 -1
  6. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/skills/bmsdna-devtools/SKILL.md +2 -2
  7. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_find_injection.py +14 -0
  8. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_sql.py +221 -0
  9. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/uv.lock +1 -1
  10. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.github/workflows/auto-release.yml +0 -0
  11. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.github/workflows/python-publish.yml +0 -0
  12. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.github/workflows/python-test.yml +0 -0
  13. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.gitignore +0 -0
  14. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.python-version +0 -0
  15. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/__init__.py +0 -0
  16. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/_api_dump.py +0 -0
  17. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/ado_auth.py +0 -0
  18. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/ado_issue.py +0 -0
  19. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/api_usage.py +0 -0
  20. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/app_service_logs.py +0 -0
  21. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/bdt_config.py +0 -0
  22. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/cli_tools.py +0 -0
  23. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/commit.py +0 -0
  24. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/dead_code.py +0 -0
  25. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/env_config.py +0 -0
  26. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/env_files.py +0 -0
  27. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/find_injection.py +0 -0
  28. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/find_repo.py +0 -0
  29. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gh_issue.py +0 -0
  30. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gh_pr.py +0 -0
  31. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gitrepo.py +0 -0
  32. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/injection_frontend.py +0 -0
  33. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/injection_python.py +0 -0
  34. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/issue_do.py +0 -0
  35. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint.py +0 -0
  36. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_findings.py +0 -0
  37. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_models.py +0 -0
  38. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  39. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_tooling.py +0 -0
  40. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_typescript.py +0 -0
  41. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/logs.py +0 -0
  42. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_build.py +0 -0
  43. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_info.py +0 -0
  44. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  45. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_labels.py +0 -0
  46. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_markdown.py +0 -0
  47. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pull.py +0 -0
  48. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/testdb.py +0 -0
  49. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/translate.py +0 -0
  50. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/worktree.py +0 -0
  51. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/justfile +0 -0
  52. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/conftest.py +0 -0
  53. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_ado_auth.py +0 -0
  54. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_ado_issue.py +0 -0
  55. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_ado_issue_flow.py +0 -0
  56. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_api_usage.py +0 -0
  57. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_app_service_logs.py +0 -0
  58. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_bdt_config.py +0 -0
  59. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_cleanup.py +0 -0
  60. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_commit.py +0 -0
  61. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_issue.py +0 -0
  62. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pr_create.py +0 -0
  63. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pr_id.py +0 -0
  64. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pull.py +0 -0
  65. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_tools.py +0 -0
  66. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_commit.py +0 -0
  67. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_env_config.py +0 -0
  68. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_env_files.py +0 -0
  69. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_find_repo.py +0 -0
  70. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_gh_issue.py +0 -0
  71. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_gh_pr.py +0 -0
  72. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_gitrepo.py +0 -0
  73. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_issue_do.py +0 -0
  74. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint.py +0 -0
  75. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_models.py +0 -0
  76. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_sql_files.py +0 -0
  77. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_tooling.py +0 -0
  78. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_typescript.py +0 -0
  79. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_build.py +0 -0
  80. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_build_flow.py +0 -0
  81. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_info.py +0 -0
  82. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_issue_link.py +0 -0
  83. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_labels.py +0 -0
  84. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_markdown.py +0 -0
  85. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pull.py +0 -0
  86. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_testdb.py +0 -0
  87. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_translate.py +0 -0
  88. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_worktree_clean.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.35.0
3
+ Version: 0.36.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
@@ -18,7 +18,7 @@ Shared developer tooling for BMS projects: PR build/check status and a
18
18
  one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and
19
19
  merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
20
20
  pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
21
- postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
21
+ postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
22
22
  TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
23
23
  backend routes nothing calls.
24
24
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
@@ -637,6 +637,21 @@ unresolvable argument, e.g. a plain function parameter, is silently skipped --
637
637
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
638
638
  it can't see through).
639
639
 
640
+ **arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
641
+ and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
642
+ are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
643
+ Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
644
+ arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
645
+ `parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
646
+ them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
647
+ statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
648
+ imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
649
+ under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
650
+ Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
651
+ common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
652
+ arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
653
+ only as safe as `x`.
654
+
640
655
  pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
641
656
  exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
642
657
  `pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
@@ -846,8 +861,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
846
861
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
847
862
  code 0 unless `--strict`).
848
863
 
849
- - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
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
864
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
865
+ SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
851
866
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
852
867
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
853
868
  reports unverified calls.)
@@ -4,7 +4,7 @@ Shared developer tooling for BMS projects: PR build/check status and a
4
4
  one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and
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
- postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
7
+ postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
8
8
  TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
9
9
  backend routes nothing calls.
10
10
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
@@ -623,6 +623,21 @@ unresolvable argument, e.g. a plain function parameter, is silently skipped --
623
623
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
624
624
  it can't see through).
625
625
 
626
+ **arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
627
+ and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
628
+ are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
629
+ Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
630
+ arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
631
+ `parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
632
+ them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
633
+ statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
634
+ imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
635
+ under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
636
+ Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
637
+ common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
638
+ arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
639
+ only as safe as `x`.
640
+
626
641
  pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
627
642
  exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
628
643
  `pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
@@ -832,8 +847,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
832
847
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
833
848
  code 0 unless `--strict`).
834
849
 
835
- - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
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
850
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
851
+ SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
837
852
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
838
853
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
839
854
  reports unverified calls.)
@@ -1093,7 +1093,8 @@ 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`/`execute`/`PostgresJsonResponse` call
1096
+ `fetch_all`/`fetch_one`/`fetch_scalar`/`execute`/`PostgresJsonResponse` call (and arrowbricks'
1097
+ `execute_streamed`/`query_agent`/`stream_query_json`, injection rules only)
1097
1098
  (must use load_sql()/a .sql file, a t-string, or psycopg.sql for anything beyond a trivial
1098
1099
  query; never an f-string/concatenation/`%`-formatting), pydantic-model placement under api/
1099
1100
  directories, hand-wired HTTP in TypeScript, and that the repo declares/configures ty, ruff, pytest and prek.
@@ -1,6 +1,6 @@
1
1
  """AST rule engine for `bdt lint`'s postgres/psycopg checks (bmsuisse/skills#52):
2
2
  every `.execute()`/`.executemany()` call (and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
3
- `PostgresJsonResponse`) is classified by how its SQL argument was
3
+ `PostgresJsonResponse`, and arrowbricks' `execute_streamed`/`query_agent`/`stream_query_json`) 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).
6
6
 
@@ -28,7 +28,7 @@ import itertools
28
28
  import re
29
29
  import sys
30
30
  from collections.abc import Mapping
31
- from dataclasses import dataclass, field
31
+ from dataclasses import dataclass, field, replace
32
32
  from pathlib import Path
33
33
 
34
34
  from .lint_findings import Finding
@@ -137,6 +137,14 @@ class _Trust:
137
137
  literal_funcs: frozenset[str] = frozenset()
138
138
  sqlglot_aliases: Mapping[str, str] = field(default_factory=dict) # local name -> name it was imported as
139
139
  pgdevkit_sinks: frozenset[str] = frozenset() # local names bound by `from pgdevkit[.x] import <sql-taking helper>`
140
+ arrowbricks_modules: frozenset[str] = frozenset() # local names bound by `import arrowbricks[.x] [as y]`
141
+ arrowbricks_file: bool = False # the file imports arrowbricks (`import arrowbricks` / `from arrowbricks[.x] import ...`)
142
+ arrowbricks_stream_funcs: frozenset[str] = frozenset() # local names of the free `stream_query_json(client, sql)`
143
+ # Per-call, set by the visitor (see `_call_trust`): this sink's SQL is Databricks SQL, so a probe that does not parse
144
+ # as postgres is retried as Databricks (backticks, `:name` markers, QUALIFY, ...) ...
145
+ databricks: bool = False
146
+ # ... and the postgres house style (inline-complexity, `%s` params, forbidden joins) is not applied to it.
147
+ postgres_rules: bool = True
140
148
 
141
149
 
142
150
  _NO_TRUST = _Trust()
@@ -176,10 +184,13 @@ def _is_trusted_sql_call(node: ast.AST, trust: _Trust = _NO_TRUST) -> bool:
176
184
 
177
185
 
178
186
  def _literal_string_cast_inner(node: ast.Call) -> ast.expr | None:
179
- """`cast(LiteralString, x)` -> `x`. The cast itself proves nothing: it is only as safe as `x`."""
187
+ """`cast(LiteralString, x)` -> `x`, and arrowbricks' `unsafe_sql(x)` (its no-op spelling of the same cast) -> `x`.
188
+ The cast itself proves nothing: it is only as safe as `x`."""
180
189
  func = node.func
181
- is_cast = (isinstance(func, ast.Name) and func.id == "cast") or (isinstance(func, ast.Attribute) and func.attr == "cast")
182
- if is_cast and len(node.args) == 2 and _is_literal_string_ref(node.args[0]):
190
+ name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None
191
+ if name == "unsafe_sql" and len(node.args) == 1 and not node.keywords:
192
+ return node.args[0]
193
+ if name == "cast" and len(node.args) == 2 and _is_literal_string_ref(node.args[0]):
183
194
  return node.args[1]
184
195
  return None
185
196
 
@@ -199,22 +210,48 @@ def _is_pgdevkit_module(module: str | None) -> bool:
199
210
  return module is not None and (module == "pgdevkit" or module.startswith("pgdevkit."))
200
211
 
201
212
 
213
+ def _is_arrowbricks_module(module: str | None) -> bool:
214
+ return module is not None and (module == "arrowbricks" or module.startswith("arrowbricks."))
215
+
216
+
202
217
  def _file_trust(tree: ast.AST) -> _Trust:
203
218
  literal_funcs: set[str] = set()
204
219
  sqlglot_names: set[str] = set()
205
220
  sqlglot_aliases: dict[str, str] = {}
206
221
  pgdevkit_sinks: set[str] = set()
222
+ arrowbricks_modules: set[str] = set()
223
+ arrowbricks_stream_funcs: set[str] = set()
224
+ arrowbricks_file = False
207
225
  for node in ast.walk(tree):
208
226
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None and _is_literal_string_ref(node.returns):
209
227
  literal_funcs.add(node.name)
210
228
  elif isinstance(node, ast.Import):
211
229
  sqlglot_names.update((a.asname or a.name).split(".")[0] for a in node.names if a.name.split(".")[0] == "sqlglot")
230
+ for alias in node.names:
231
+ if _is_arrowbricks_module(alias.name):
232
+ arrowbricks_file = True
233
+ arrowbricks_modules.add(alias.asname or alias.name.split(".")[0])
212
234
  elif isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "sqlglot":
213
235
  sqlglot_names.update(a.asname or a.name for a in node.names)
214
236
  sqlglot_aliases.update({a.asname or a.name: a.name for a in node.names})
215
237
  elif isinstance(node, ast.ImportFrom) and node.level == 0 and _is_pgdevkit_module(node.module):
216
238
  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))
239
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and _is_arrowbricks_module(node.module):
240
+ arrowbricks_file = True
241
+ for alias in node.names:
242
+ if alias.name == "stream_query_json":
243
+ arrowbricks_stream_funcs.add(alias.asname or alias.name)
244
+ elif alias.name == "_streaming": # the module that defines the free function
245
+ arrowbricks_modules.add(alias.asname or alias.name)
246
+ return _Trust(
247
+ frozenset(sqlglot_names),
248
+ frozenset(literal_funcs),
249
+ sqlglot_aliases,
250
+ frozenset(pgdevkit_sinks),
251
+ frozenset(arrowbricks_modules),
252
+ arrowbricks_file,
253
+ frozenset(arrowbricks_stream_funcs),
254
+ )
218
255
 
219
256
 
220
257
  def _resolve_single(expr: ast.expr, lookup) -> ast.expr:
@@ -293,6 +330,20 @@ _FSTRING_FIX = (
293
330
  "`{name:i}` quotes a table/column identifier. Or use load_sql()/a .sql file for static SQL."
294
331
  )
295
332
  _GENERIC_FIX = "Use a psycopg t-string (Python 3.14+), psycopg.sql for dynamic SQL, or load_sql()/a .sql file for static SQL."
333
+ _ARROWBRICKS_FSTRING_FIX = (
334
+ "Change the `f` prefix to `t` (arrowbricks t-string, Python 3.14+): `{value}` is bound as a named parameter and "
335
+ "`{name:i}` quotes a table/column identifier. Or keep the SQL literal and pass its `:name` markers' values as "
336
+ "`parameters=[...]` (`params=[...]` for `query_agent`/`stream_query_json`)."
337
+ )
338
+ _ARROWBRICKS_GENERIC_FIX = (
339
+ "Use an arrowbricks t-string (Python 3.14+: `{value}` binds a named parameter, `{name:i}` quotes an identifier), or a "
340
+ "literal SQL string with `parameters=[...]` (`params=[...]` for `query_agent`/`stream_query_json`) for its `:name` markers."
341
+ )
342
+ # A plain `.execute()` in a file that imports arrowbricks may be either driver's cursor: name neither.
343
+ _MIXED_FIX = (
344
+ "Change the `f` prefix to `t` (psycopg/arrowbricks t-string, Python 3.14+): `{value}` becomes a bound parameter and "
345
+ "`{name:i}` quotes a table/column identifier. Or use load_sql()/a .sql file for static SQL."
346
+ )
296
347
 
297
348
 
298
349
  # Keyword that must literally be in the text before a variant reading (a fragment as nothing/an assignment) may
@@ -309,11 +360,18 @@ if exp is not None:
309
360
  }
310
361
 
311
362
 
312
- def _parse_probe_text(text: str) -> "exp.Expression | None":
363
+ def _parse_any_dialect(text: str, databricks: bool) -> "exp.Expression | None":
364
+ parsed = parse_sql_text(text)
365
+ if parsed is None and databricks:
366
+ parsed = parse_sql_text(text, "databricks")
367
+ return parsed
368
+
369
+
370
+ def _parse_probe_text(text: str, databricks: bool = False) -> "exp.Expression | None":
313
371
  """`parse_sql_text` for text with `_PROBE_PLACEHOLDER` standing in for dynamic fragments: the placeholder
314
372
  as an identifier first, then (see `_PROBE_SLOT_VARIANTS`) every fragment as nothing/an assignment -- the
315
373
  latter only when the literal text carries the statement's own keyword (`_VARIANT_REQUIRED_KEYWORD`)."""
316
- parsed = parse_sql_text(text)
374
+ parsed = _parse_any_dialect(text, databricks)
317
375
  if parsed is not None:
318
376
  return parsed
319
377
  parts = text.split(_PROBE_PLACEHOLDER)
@@ -325,7 +383,7 @@ def _parse_probe_text(text: str) -> "exp.Expression | None":
325
383
  if all(c == _PROBE_PLACEHOLDER for c in combination):
326
384
  continue # that's `text` itself, already tried
327
385
  candidate = "".join(a + b for a, b in itertools.zip_longest(parts, combination, fillvalue=""))
328
- parsed = parse_sql_text(candidate)
386
+ parsed = _parse_any_dialect(candidate, databricks)
329
387
  if parsed is None:
330
388
  continue
331
389
  keyword = next((rx for t, rx in _VARIANT_REQUIRED_KEYWORD.items() if isinstance(parsed, t)), None)
@@ -334,10 +392,15 @@ def _parse_probe_text(text: str) -> "exp.Expression | None":
334
392
  return None
335
393
 
336
394
 
337
- def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str) -> list[Finding]:
338
- if _parse_probe_text(text) is None:
395
+ def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str, trust: _Trust = _NO_TRUST) -> list[Finding]:
396
+ if _parse_probe_text(text, trust.databricks) is None:
339
397
  return []
340
- fix = _FSTRING_FIX if rule == "sql-fstring-injection" else _GENERIC_FIX
398
+ if trust.databricks and not trust.postgres_rules:
399
+ fix = _ARROWBRICKS_FSTRING_FIX if rule == "sql-fstring-injection" else _ARROWBRICKS_GENERIC_FIX
400
+ elif trust.databricks:
401
+ fix = _MIXED_FIX
402
+ else:
403
+ fix = _FSTRING_FIX if rule == "sql-fstring-injection" else _GENERIC_FIX
341
404
  return [Finding(path, lineno, rule, f"SQL built with {how} -- injection risk. {fix}")]
342
405
 
343
406
 
@@ -416,30 +479,30 @@ def _check_resolved_candidate(
416
479
  if _is_load_sql_call(resolved) or _is_sql_composed_call(resolved) or _is_trusted_sql_call(resolved, trust):
417
480
  return []
418
481
  if isinstance(resolved, getattr(ast, "TemplateStr", ())):
419
- return [] # psycopg t-string: params are always bound, never interpolated
482
+ return [] # psycopg/arrowbricks t-string: params are always bound, never interpolated
420
483
 
421
484
  if isinstance(resolved, ast.JoinedStr):
422
- return _injection_finding(_fstring_probe_text(resolved), path, lineno, "sql-fstring-injection", "an f-string")
485
+ return _injection_finding(_fstring_probe_text(resolved), path, lineno, "sql-fstring-injection", "an f-string", trust)
423
486
 
424
487
  if isinstance(resolved, ast.BinOp) and isinstance(resolved.op, ast.Mod):
425
488
  left = _resolve_single(resolved.left, lookup)
426
489
  if isinstance(left, ast.Constant) and isinstance(left.value, str):
427
- return _injection_finding(left.value, path, lineno, "sql-percent-format-injection", "the `%` string-formatting operator")
490
+ return _injection_finding(left.value, path, lineno, "sql-percent-format-injection", "the `%` string-formatting operator", trust)
428
491
  return []
429
492
 
430
493
  if isinstance(resolved, ast.BinOp) and isinstance(resolved.op, ast.Add):
431
494
  text = _concat_probe_text(resolved, lookup)
432
- return _injection_finding(text, path, lineno, "sql-concat-injection", "string concatenation") if text is not None else []
495
+ return _injection_finding(text, path, lineno, "sql-concat-injection", "string concatenation", trust) if text is not None else []
433
496
 
434
497
  if isinstance(resolved, ast.Call) and isinstance(resolved.func, ast.Attribute) and resolved.func.attr == "format":
435
498
  template = _resolve_single(resolved.func.value, lookup)
436
499
  if isinstance(template, ast.Constant) and isinstance(template.value, str):
437
500
  text = _FORMAT_FIELD_RE.sub(_PROBE_PLACEHOLDER, template.value)
438
- return _injection_finding(text, path, lineno, "sql-format-injection", "`str.format()`")
501
+ return _injection_finding(text, path, lineno, "sql-format-injection", "`str.format()`", trust)
439
502
  return []
440
503
 
441
504
  if isinstance(resolved, ast.Constant) and isinstance(resolved.value, str):
442
- return _literal_findings(resolved.value, path, lineno)
505
+ return _literal_findings(resolved.value, path, lineno) if trust.postgres_rules else []
443
506
 
444
507
  if isinstance(resolved, ast.Call):
445
508
  cast_inner = _literal_string_cast_inner(resolved)
@@ -474,7 +537,7 @@ def _unverified_cast_finding(path: Path, lineno: int) -> Finding:
474
537
  path,
475
538
  lineno,
476
539
  "sql-unverified-cast",
477
- "`cast(LiteralString, ...)` wraps a value that isn't visibly a sqlglot expression or a literal. AI reviewer: verify it "
540
+ "`cast(LiteralString, ...)`/`unsafe_sql(...)` wraps a value that isn't visibly a sqlglot expression or a literal. AI reviewer: verify it "
478
541
  "cannot carry user-controlled text (the cast doesn't make it safe); pass a sqlglot expression, or add "
479
542
  "`# bdt-lint: ignore sql-unverified-cast`.",
480
543
  severity="review",
@@ -540,7 +603,8 @@ def _execute_query_arg(call: ast.Call, trust: _Trust = _NO_TRUST) -> ast.expr |
540
603
  return None
541
604
  if call.args:
542
605
  return call.args[0]
543
- return next((kw.value for kw in call.keywords if kw.arg == "query"), None)
606
+ keywords = ("query", "sql") if trust.arrowbricks_file else ("query",) # arrowbricks' Cursor.execute(sql=...)
607
+ return next((kw.value for kw in call.keywords if kw.arg in keywords), None)
544
608
 
545
609
 
546
610
  # sqlglot's builders parse plain strings as SQL (`select(f"a, {x}")`, `.where(f"id = {x}")`), so text
@@ -634,6 +698,52 @@ def _sqlglot_builder_findings(
634
698
  return []
635
699
 
636
700
 
701
+ # arrowbricks' (Databricks SQL client) calls that take SQL: `Cursor.execute_streamed(sql, ...)`,
702
+ # `DatabricksClient.query_agent(sql, ...)`, `DatabricksClient.stream_query_json(sql, ...)` and the free function
703
+ # `arrowbricks.stream_query_json(client, sql, ...)`. (`Cursor.execute` is already matched as any `.execute()`.) The names are
704
+ # specific enough to match anywhere, like pgdevkit's `fetch_all`. Their SQL is Databricks SQL, so only the injection rules apply
705
+ # (`_call_trust`); a t-string or `unsafe_sql(...)` is handled like psycopg's t-string / `cast(LiteralString, ...)`.
706
+ _ARROWBRICKS_SQL_CALLEES = frozenset({"execute_streamed", "query_agent", "stream_query_json"})
707
+
708
+
709
+ def _attribute_root(node: ast.expr) -> str | None:
710
+ while isinstance(node, ast.Attribute):
711
+ node = node.value
712
+ return node.id if isinstance(node, ast.Name) else None
713
+
714
+
715
+ def _arrowbricks_sink_arg(call: ast.Call, trust: _Trust) -> ast.expr | None:
716
+ func = call.func
717
+ if isinstance(func, ast.Name):
718
+ # a bare name is the free function (also under an import alias) or, for the other two, the user's own helper
719
+ name = "stream_query_json" if func.id in trust.arrowbricks_stream_funcs else func.id
720
+ free_function = True
721
+ elif isinstance(func, ast.Attribute):
722
+ name = func.attr
723
+ free_function = _attribute_root(func.value) in trust.arrowbricks_modules # `arrowbricks.stream_query_json(client, sql)`
724
+ else:
725
+ return None
726
+ if name not in _ARROWBRICKS_SQL_CALLEES:
727
+ return None
728
+ index = 1 if name == "stream_query_json" and free_function else 0 # the free function takes the client first
729
+ if len(call.args) > index and not isinstance(call.args[index], ast.Starred):
730
+ return call.args[index]
731
+ return next((kw.value for kw in call.keywords if kw.arg == "sql"), None)
732
+
733
+
734
+ def _call_trust(call: ast.Call, trust: _Trust) -> tuple[ast.expr | None, _Trust]:
735
+ """The SQL argument of `call` (None if it isn't a SQL sink) and the trust to check it with: an arrowbricks call is
736
+ Databricks SQL without the postgres house style; a plain `.execute()` in a file that imports arrowbricks keeps the
737
+ postgres rules but may also be Databricks SQL (arrowbricks' `Cursor.execute`)."""
738
+ arrowbricks_arg = _arrowbricks_sink_arg(call, trust)
739
+ if arrowbricks_arg is not None:
740
+ return arrowbricks_arg, replace(trust, databricks=True, postgres_rules=False)
741
+ query_arg = _execute_query_arg(call, trust)
742
+ if query_arg is not None and trust.arrowbricks_file:
743
+ return query_arg, replace(trust, databricks=True)
744
+ return query_arg, trust
745
+
746
+
637
747
  class _ExecuteCallVisitor(ast.NodeVisitor):
638
748
  """Walks a module, tracking a stack of (function/class/module) local-variable scopes so a
639
749
  `cur.execute(query, ...)` call can resolve `query` back to every assignment it had in that
@@ -641,10 +751,11 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
641
751
  branch-shadowed unsafe assignment is still seen), then applies the SQL rules to every
642
752
  `.execute()`/`.executemany()` call found."""
643
753
 
644
- def __init__(self, path: Path, trust: _Trust = _NO_TRUST, review: bool = False) -> None:
754
+ def __init__(self, path: Path, trust: _Trust = _NO_TRUST, review: bool = False, arrowbricks_names: frozenset[str] = frozenset()) -> None:
645
755
  self.path = path
646
756
  self.trust = trust
647
757
  self.review = review
758
+ self._arrowbricks_names = arrowbricks_names # `*_sql` variables passed to an arrowbricks call (no postgres house style)
648
759
  self.findings: list[Finding] = []
649
760
  self._scopes: list[dict[str, list[ast.expr]]] = [{}]
650
761
  self._sqlglot_vars: set[str] = set() # names bound from a sqlglot expression chain
@@ -688,6 +799,8 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
688
799
  `.execute()` call in this file (e.g. it's passed to a helper) -- apply the complexity rule."""
689
800
  if not (_is_sql_named(name) and isinstance(value, ast.Constant) and isinstance(value.value, str)):
690
801
  return
802
+ if name in self._arrowbricks_names:
803
+ return
691
804
  parsed = parse_sql_text(value.value)
692
805
  if parsed is not None:
693
806
  self.findings.extend(_complexity_findings(parsed, value.value, self.path, lineno))
@@ -700,9 +813,9 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
700
813
 
701
814
  def visit_Call(self, node: ast.Call) -> None:
702
815
  self.findings.extend(_sqlglot_builder_findings(node, self.path, self.trust, self._lookup, self._sqlglot_vars))
703
- query_arg = _execute_query_arg(node, self.trust)
816
+ query_arg, trust = _call_trust(node, self.trust)
704
817
  if query_arg is not None:
705
- found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, self.trust, self.review)
818
+ found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, trust, self.review)
706
819
  if isinstance(query_arg, ast.Name) and _is_sql_named(query_arg.id) and self._all_bare_strings(query_arg.id):
707
820
  found = [f for f in found if f.rule != "sql-inline-too-complex"] # already reported at the assignment
708
821
  self.findings.extend(found)
@@ -726,6 +839,16 @@ def check_sql_file(path: Path, source: str | None = None, *, review: bool = Fals
726
839
  tree = ast.parse(text, filename=str(path))
727
840
  except SyntaxError:
728
841
  return []
729
- visitor = _ExecuteCallVisitor(path, _file_trust(tree), review)
842
+ trust = _file_trust(tree)
843
+ arrowbricks_names: set[str] = set() # `*_sql` variables handed to an arrowbricks call ...
844
+ other_names: set[str] = set() # ... minus any name that is also handed to some other sink (it could be a psycopg query)
845
+ for call in ast.walk(tree):
846
+ if not isinstance(call, ast.Call):
847
+ continue
848
+ if isinstance(arg := _arrowbricks_sink_arg(call, trust), ast.Name):
849
+ arrowbricks_names.add(arg.id)
850
+ elif isinstance(arg := _execute_query_arg(call, trust), ast.Name):
851
+ other_names.add(arg.id)
852
+ visitor = _ExecuteCallVisitor(path, trust, review, frozenset(arrowbricks_names - other_names))
730
853
  visitor.visit(tree)
731
854
  return visitor.findings
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.35.0"
13
+ version = "0.36.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
@@ -5,7 +5,7 @@ description: >
5
5
  commands or repo-local scripts for: checking PR build/check status, creating
6
6
  a PR, creating a git worktree, committing and pushing files (with pre-flight
7
7
  checks), claiming an issue / work item (`bdt issue take`), a one-shot PR summary
8
- (`bdt pr info`: link, build state, closed issues), querying Azure logs, and static checks (`bdt lint`: SQL/psycopg rules, pydantic placement, tooling; `bdt dead-code`:
8
+ (`bdt pr info`: link, build state, closed issues), querying Azure logs, and static checks (`bdt lint`: SQL/psycopg/arrowbricks rules, pydantic placement, tooling; `bdt dead-code`:
9
9
  unreferenced .sql files and backend routes nothing calls), listing which env vars a repo defines (`bdt env get-keys`, names only). `bdt pr *` works against both Azure DevOps
10
10
  and GitHub — it auto-detects which one from the `origin` remote. Trigger
11
11
  whenever the user asks to check a build/PR status, create a PR, make a
@@ -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_*`/`execute`, `PostgresJsonResponse`), pydantic-model placement, hand-wired HTTP in TypeScript, baseline tooling.
126
+ - `bdt lint [paths]` -- SQL/psycopg rules (`.execute()`, pgdevkit `fetch_*`/`execute`, `PostgresJsonResponse`, arrowbricks `execute_streamed`/`query_agent`/`stream_query_json`), 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`;
@@ -163,6 +163,20 @@ def test_pgdevkit_execute_is_a_sql_sink_for_find_injection(tmp_path: Path) -> No
163
163
  assert [(f.path.name, f.rule) for f in result.reviews] == [("db.py", "sql-unverified-call")]
164
164
 
165
165
 
166
+ def test_arrowbricks_calls_are_sql_sinks_for_find_injection(tmp_path: Path) -> None:
167
+ (tmp_path / "ab.py").write_text(
168
+ "from arrowbricks import unsafe_sql\n\n"
169
+ 'async def a(client, x):\n await client.query_agent(f"select * from t where id = {x}")\n\n'
170
+ "async def b(client, x):\n await client.query_agent(build(x))\n\n"
171
+ "async def c(client, request_sql):\n await client.query_agent(unsafe_sql(request_sql))\n\n"
172
+ 'async def d(client, x):\n await client.query_agent(unsafe_sql(f"select * from t where id = {x}"))\n\n'
173
+ 'async def e(client):\n await client.query_agent(unsafe_sql("select 1"))\n await client.query_agent(t"select {1}")\n'
174
+ )
175
+ result = _run(tmp_path)
176
+ assert [(f.rule, f.line) for f in result.errors] == [("sql-fstring-injection", 4), ("sql-fstring-injection", 13)]
177
+ assert [(f.rule, f.line) for f in result.reviews] == [("sql-unverified-call", 7), ("sql-unverified-cast", 10)]
178
+
179
+
166
180
  def test_tests_and_missing_paths(tmp_path: Path) -> None:
167
181
  (tmp_path / "tests").mkdir()
168
182
  (tmp_path / "tests" / "test_x.py").write_text("eval(x)\n")
@@ -863,3 +863,224 @@ def f(cur, v):
863
863
  cur.execute(f"Update complete, {v} rows changed")
864
864
  '''
865
865
  assert _findings(source, tmp_path / "a.py") == []
866
+
867
+
868
+ # --- arrowbricks (Databricks SQL client): execute_streamed / query_agent / stream_query_json -------------------------------
869
+
870
+
871
+ def test_arrowbricks_fstring_in_every_sql_taking_call_is_flagged(tmp_path: Path) -> None:
872
+ source = '''
873
+ async def a(client, cur, x):
874
+ await client.query_agent(f"select * from t where id = {x}")
875
+ async for _ in cur.execute_streamed(f"select * from t where id = {x}"):
876
+ pass
877
+ async for _ in client.stream_query_json(f"select * from t where id = {x}"):
878
+ pass
879
+ '''
880
+ findings = _findings(source, tmp_path / "a.py")
881
+ assert [f.rule for f in findings] == ["sql-fstring-injection"] * 3
882
+ assert [f.line for f in findings] == [3, 4, 6]
883
+ assert all("arrowbricks t-string" in f.message and "psycopg" not in f.message for f in findings)
884
+
885
+
886
+ def test_arrowbricks_free_stream_query_json_takes_sql_as_second_argument(tmp_path: Path) -> None:
887
+ source = '''
888
+ import arrowbricks
889
+ import arrowbricks as ab
890
+ from arrowbricks import stream_query_json
891
+
892
+ async def a(client, x):
893
+ stream_query_json(client, f"select * from t where id = {x}")
894
+ arrowbricks.stream_query_json(client, f"select * from t where id = {x}")
895
+ ab.stream_query_json(client, f"select * from t where id = {x}")
896
+ client.stream_query_json(f"select * from t where id = {x}")
897
+ '''
898
+ assert [f.line for f in _findings(source, tmp_path / "a.py")] == [7, 8, 9, 10]
899
+
900
+
901
+ def test_arrowbricks_free_stream_query_json_with_a_safe_sql_is_clean(tmp_path: Path) -> None:
902
+ source = '''
903
+ from arrowbricks import stream_query_json
904
+
905
+ async def a(client, x):
906
+ stream_query_json(client, "select * from t where id = :id", params=[{"name": "id", "value": "1"}])
907
+ stream_query_json(f"not sql {x}", "select 1") # the client slot is not the SQL slot
908
+ '''
909
+ assert _findings(source, tmp_path / "a.py") == []
910
+
911
+
912
+ def test_arrowbricks_sql_keyword_argument_is_checked(tmp_path: Path) -> None:
913
+ source = '''
914
+ async def a(client, x):
915
+ await client.query_agent(sql=f"select * from t where id = {x}", max_rows=5)
916
+ '''
917
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
918
+
919
+
920
+ def test_arrowbricks_concat_percent_and_format_are_flagged(tmp_path: Path) -> None:
921
+ source = '''
922
+ async def a(client, x):
923
+ await client.query_agent("select * from t where id = " + x)
924
+ await client.query_agent("select * from t where id = %s" % x)
925
+ await client.query_agent("select * from t where id = {}".format(x))
926
+ '''
927
+ assert sorted(_rules(_findings(source, tmp_path / "a.py"))) == [
928
+ "sql-concat-injection",
929
+ "sql-format-injection",
930
+ "sql-percent-format-injection",
931
+ ]
932
+
933
+
934
+ def test_arrowbricks_t_string_is_clean(tmp_path: Path) -> None:
935
+ source = '''
936
+ async def a(client, cur, table, x):
937
+ await client.query_agent(t"select * from {table:i} where id = {x}")
938
+ await cur.execute(t"select * from {table:i} where id = {x}")
939
+ async for _ in client.stream_query_json(t"select * from t where id = {x}"):
940
+ pass
941
+ '''
942
+ assert _findings(source, tmp_path / "a.py") == []
943
+
944
+
945
+ def test_databricks_only_syntax_is_recognised_for_arrowbricks_calls(tmp_path: Path) -> None:
946
+ # Backticks and QUALIFY don't parse as postgres, so an f-string like this used to slip through unnoticed.
947
+ source = '''
948
+ async def a(client, t):
949
+ await client.query_agent(f"select `a` from `{t}` qualify row_number() over (order by `a`) = 1")
950
+ '''
951
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
952
+
953
+
954
+ def test_databricks_syntax_in_a_plain_execute_is_flagged_only_in_a_file_that_imports_arrowbricks(tmp_path: Path) -> None:
955
+ body = '''
956
+ async def a(cur, t):
957
+ await cur.execute(f"select `a` from `{t}` qualify row_number() over (order by `a`) = 1")
958
+ '''
959
+ assert _findings(body, tmp_path / "plain.py") == [] # unchanged: could be any driver
960
+ assert _rules(_findings("from arrowbricks import connect\n" + body, tmp_path / "ab.py")) == {"sql-fstring-injection"}
961
+
962
+
963
+ def test_postgres_house_style_is_not_applied_to_arrowbricks_calls(tmp_path: Path) -> None:
964
+ sql = "select a.x, b.y from a right join b on a.id = b.id where a.k = %s and a.m in (select m from c)\\n"
965
+ source = f'''
966
+ async def a(client, cur, x):
967
+ await client.query_agent("{sql}")
968
+ await cur.execute("{sql}")
969
+ await client.query_agent(f"select 1 from t where id = {{x}}") # recognised: the injection rules do apply
970
+ '''
971
+ findings = _findings(source, tmp_path / "a.py")
972
+ assert {(f.line, f.rule) for f in findings} == {
973
+ (4, "sql-forbidden-join"), # only the plain .execute() gets the psycopg house style, as before
974
+ (4, "sql-positional-param"),
975
+ (4, "sql-inline-too-complex"),
976
+ (5, "sql-fstring-injection"),
977
+ }
978
+
979
+
980
+ def test_long_sql_variable_passed_to_an_arrowbricks_call_is_not_too_complex(tmp_path: Path) -> None:
981
+ source = '''
982
+ LONG_SQL = """
983
+ with a as (select 1 as x),
984
+ b as (select 2 as x)
985
+ select a.x, b.x from a join b on a.x < b.x
986
+ where a.x > 0
987
+ """
988
+
989
+ async def a(client, cur):
990
+ await client.query_agent(LONG_SQL)
991
+ '''
992
+ assert _findings(source, tmp_path / "a.py") == []
993
+ assert _rules(_findings(source.replace("client.query_agent(", "cur.execute("), tmp_path / "b.py")) == {"sql-inline-too-complex"}
994
+
995
+
996
+ def test_unsafe_sql_is_checked_like_cast_literal_string(tmp_path: Path) -> None:
997
+ source = '''
998
+ from arrowbricks import unsafe_sql
999
+
1000
+ async def a(client, x, request_sql):
1001
+ await client.query_agent(unsafe_sql(f"select * from t where id = {x}"))
1002
+ await client.query_agent(unsafe_sql(request_sql))
1003
+ await client.query_agent(unsafe_sql("select 1"))
1004
+ '''
1005
+ path = tmp_path / "a.py"
1006
+ path.write_text(source)
1007
+ assert [(f.rule, f.line) for f in check_sql_file(path)] == [("sql-fstring-injection", 5)]
1008
+ assert sorted((f.rule, f.line) for f in check_sql_file(path, review=True)) == [
1009
+ ("sql-fstring-injection", 5),
1010
+ ("sql-unverified-cast", 6),
1011
+ ]
1012
+
1013
+
1014
+ def test_arrowbricks_unresolvable_argument_stays_quiet(tmp_path: Path) -> None:
1015
+ source = '''
1016
+ async def a(client, sql):
1017
+ await client.query_agent(sql)
1018
+ async for _ in client.stream_query_json(build_sql()):
1019
+ pass
1020
+ '''
1021
+ path = tmp_path / "a.py"
1022
+ path.write_text(source)
1023
+ assert check_sql_file(path) == []
1024
+ assert [f.rule for f in check_sql_file(path, review=True)] == ["sql-unverified-call"] # the call, not the bare parameter
1025
+
1026
+
1027
+ def test_arrowbricks_import_aliases_and_attribute_chains_find_the_free_function(tmp_path: Path) -> None:
1028
+ source = '''
1029
+ import arrowbricks
1030
+ from arrowbricks import stream_query_json as sqj
1031
+ from arrowbricks import _streaming as streaming
1032
+
1033
+ async def a(client, x):
1034
+ sqj(client, f"select * from t where id = {x}")
1035
+ arrowbricks._streaming.stream_query_json(client, f"select * from t where id = {x}")
1036
+ streaming.stream_query_json(client, f"select * from t where id = {x}")
1037
+ arrowbricks.stream_query_json(client, sql=f"select * from t where id = {x}")
1038
+ '''
1039
+ assert [f.line for f in _findings(source, tmp_path / "a.py")] == [7, 8, 9, 10]
1040
+
1041
+
1042
+ def test_arrowbricks_starred_positional_falls_back_to_the_sql_keyword(tmp_path: Path) -> None:
1043
+ source = '''
1044
+ async def a(client, args, x):
1045
+ async for _ in client.stream_query_json(*args, sql=f"select * from t where id = {x}"):
1046
+ pass
1047
+ '''
1048
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
1049
+
1050
+
1051
+ def test_cursor_execute_sql_keyword_is_checked_in_an_arrowbricks_file_only(tmp_path: Path) -> None:
1052
+ body = '''
1053
+ async def a(cur, x):
1054
+ await cur.execute(sql=f"select * from t where id = {x}")
1055
+ '''
1056
+ assert _findings(body, tmp_path / "plain.py") == []
1057
+ assert _rules(_findings("from arrowbricks import connect\n" + body, tmp_path / "ab.py")) == {"sql-fstring-injection"}
1058
+
1059
+
1060
+ def test_fix_hint_names_the_right_parameter_keyword_and_driver(tmp_path: Path) -> None:
1061
+ source = '''
1062
+ from arrowbricks import connect
1063
+
1064
+ async def a(client, cur, x):
1065
+ await client.query_agent(f"select * from t where id = {x}")
1066
+ await cur.execute(f"select * from t where id = {x}")
1067
+ '''
1068
+ agent, execute = _findings(source, tmp_path / "a.py")
1069
+ assert "params=[...]" in agent.message and "parameters=[...]" in agent.message # both spellings, per method
1070
+ assert "arrowbricks t-string" in agent.message and "psycopg" not in agent.message
1071
+ assert "psycopg/arrowbricks t-string" in execute.message # a plain .execute() could be either driver's cursor
1072
+
1073
+
1074
+ def test_arrowbricks_name_reused_for_a_psycopg_query_keeps_the_complexity_check(tmp_path: Path) -> None:
1075
+ # Same variable name in two functions: the psycopg use must not be silenced by the arrowbricks use.
1076
+ source = '''
1077
+ async def f1(client):
1078
+ sql = LONG_SQL_PLACEHOLDER
1079
+ await client.query_agent(sql)
1080
+
1081
+ async def f2(cur):
1082
+ sql = LONG_SQL_PLACEHOLDER
1083
+ await cur.execute(sql)
1084
+ '''.replace("LONG_SQL_PLACEHOLDER", '"""select a.x, b.x from a join b on a.x < b.x where a.x > 0"""')
1085
+ findings = _findings(source, tmp_path / "a.py")
1086
+ assert [f.rule for f in findings] == ["sql-inline-too-complex", "sql-inline-too-complex"]
@@ -22,7 +22,7 @@ wheels = [
22
22
 
23
23
  [[package]]
24
24
  name = "bmsdna-devtools"
25
- version = "0.35.0"
25
+ version = "0.36.0"
26
26
  source = { editable = "." }
27
27
  dependencies = [
28
28
  { name = "pgdevkit", extra = ["db"] },