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.
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/PKG-INFO +19 -4
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/README.md +18 -3
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/cli.py +2 -1
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_sql.py +147 -24
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/skills/bmsdna-devtools/SKILL.md +2 -2
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_find_injection.py +14 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_sql.py +221 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/uv.lock +1 -1
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.gitignore +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/.python-version +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/justfile +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.36.0}/tests/test_translate.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
182
|
-
if
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|