duckdb-kql 0.0.1.dev3__tar.gz → 0.0.1.dev4__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 (111) hide show
  1. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/PKG-INFO +2 -2
  2. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/README.md +1 -1
  3. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/demo/demo.ipynb +1 -1
  4. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/api.md +1 -1
  5. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/cli.md +24 -19
  6. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/getting-started.md +1 -1
  7. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_version.py +2 -2
  8. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/cli.py +114 -84
  9. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_cli.py +92 -40
  10. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_server.py +10 -2
  11. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/.gitignore +0 -0
  12. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/CODE_OF_CONDUCT.md +0 -0
  13. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/CONTRIBUTING.md +0 -0
  14. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/LICENSE +0 -0
  15. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/SECURITY.md +0 -0
  16. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/THIRD-PARTY-NOTICES.md +0 -0
  17. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/demo/demo.kql +0 -0
  18. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/TRANSLATION.md +0 -0
  19. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/ai-cost-strategy.md +0 -0
  20. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/azure-monitor-profile.md +0 -0
  21. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/code-review/README.md +0 -0
  22. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/code-review/kusto-client-compat.md +0 -0
  23. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/code-review/public-api-and-typing.md +0 -0
  24. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/code-review/review-2026-08-04.md +0 -0
  25. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/code-review/security-and-injection.md +0 -0
  26. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/code-review/testing-oracle-and-fixtures.md +0 -0
  27. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/code-review/tooling-packaging-ci-docs.md +0 -0
  28. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/code-review/translation-correctness.md +0 -0
  29. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/frequency-scan-results.md +0 -0
  30. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/implementation-options.md +0 -0
  31. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/implementation-plan.md +0 -0
  32. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/kql-on-duckdb-landscape.md +0 -0
  33. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/kql-support.md +0 -0
  34. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/kusto-client.md +0 -0
  35. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/kusto-server.md +0 -0
  36. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/lessons-from-bun-rewrite.md +0 -0
  37. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/licensing.md +0 -0
  38. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/m0-grammar-spike.md +0 -0
  39. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/oracle-harness.md +0 -0
  40. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/docs/test-plan.md +0 -0
  41. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/grammar/Kql.g4 +0 -0
  42. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/grammar/KqlTokens.g4 +0 -0
  43. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/grammar/UPSTREAM.md +0 -0
  44. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/pyproject.toml +0 -0
  45. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/__init__.py +0 -0
  46. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/__main__.py +0 -0
  47. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_antlr/Kql.interp +0 -0
  48. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_antlr/Kql.tokens +0 -0
  49. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_antlr/KqlLexer.interp +0 -0
  50. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_antlr/KqlLexer.py +0 -0
  51. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_antlr/KqlLexer.tokens +0 -0
  52. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_antlr/KqlListener.py +0 -0
  53. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_antlr/KqlParser.py +0 -0
  54. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_antlr/KqlVisitor.py +0 -0
  55. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/_antlr/__init__.py +0 -0
  56. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/comparison.py +0 -0
  57. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/control.py +0 -0
  58. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/engine.py +0 -0
  59. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/errors.py +0 -0
  60. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/fixtures.py +0 -0
  61. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/ir.py +0 -0
  62. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/kusto/__init__.py +0 -0
  63. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/kusto/_models.py +0 -0
  64. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/kusto/client.py +0 -0
  65. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/kusto/client_request_properties.py +0 -0
  66. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/kusto/exceptions.py +0 -0
  67. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/kusto/helpers.py +0 -0
  68. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/kusto/response.py +0 -0
  69. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/lower.py +0 -0
  70. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/oracle.py +0 -0
  71. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/params.py +0 -0
  72. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/parser.py +0 -0
  73. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/py.typed +0 -0
  74. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/schema.py +0 -0
  75. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/server.py +0 -0
  76. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/translate/__init__.py +0 -0
  77. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/translate/functions.py +0 -0
  78. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/src/duckdb_kql/types.py +0 -0
  79. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/conftest.py +0 -0
  80. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/profiles/azure-monitor.json +0 -0
  81. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_behavior.py +0 -0
  82. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_column_order_and_null_sort.py +0 -0
  83. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_comparison.py +0 -0
  84. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_control_commands.py +0 -0
  85. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_corpus.py +0 -0
  86. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_datetime_traps.py +0 -0
  87. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_demo_notebook.py +0 -0
  88. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_devcontainers.py +0 -0
  89. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_docs.py +0 -0
  90. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_dynamic.py +0 -0
  91. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_fixtures.py +0 -0
  92. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_getschema.py +0 -0
  93. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_join.py +0 -0
  94. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_kusto_client.py +0 -0
  95. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_let.py +0 -0
  96. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_null_semantics.py +0 -0
  97. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_parse.py +0 -0
  98. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_profile_azure_monitor.py +0 -0
  99. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_query_parameters.py +0 -0
  100. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_range_in_render.py +0 -0
  101. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_summarize.py +0 -0
  102. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_support_matrix.py +0 -0
  103. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_typing.py +0 -0
  104. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tests/test_workflows.py +0 -0
  105. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tools/check_profile.py +0 -0
  106. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tools/frequency_scan.py +0 -0
  107. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tools/gen_support_matrix.py +0 -0
  108. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tools/harvest_docs.py +0 -0
  109. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tools/make_fixtures.py +0 -0
  110. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tools/regen_expectations.py +0 -0
  111. {duckdb_kql-0.0.1.dev3 → duckdb_kql-0.0.1.dev4}/tools/regen_parser.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: duckdb-kql
3
- Version: 0.0.1.dev3
3
+ Version: 0.0.1.dev4
4
4
  Summary: Run Kusto KQL queries on DuckDB, from Python
5
5
  Project-URL: Homepage, https://github.com/mmaitre314/duckdb-kql
6
6
  Project-URL: Documentation, https://github.com/mmaitre314/duckdb-kql/blob/main/docs/getting-started.md
@@ -133,7 +133,7 @@ Translate at build time and the output has no dependency on this package at all
133
133
  — not even Python. Only your CI machine installs it.
134
134
 
135
135
  ```bash
136
- duckdb-kql queries/ -o build/sql/ --check # fails the build if a .sql is stale
136
+ duckdb-kql translate queries/ -o build/sql/ --check # fails the build if a .sql is stale
137
137
  ```
138
138
 
139
139
  See [Build-time translation](https://github.com/mmaitre314/duckdb-kql/blob/main/docs/cli.md).
@@ -52,7 +52,7 @@ Translate at build time and the output has no dependency on this package at all
52
52
  — not even Python. Only your CI machine installs it.
53
53
 
54
54
  ```bash
55
- duckdb-kql queries/ -o build/sql/ --check # fails the build if a .sql is stale
55
+ duckdb-kql translate queries/ -o build/sql/ --check # fails the build if a .sql is stale
56
56
  ```
57
57
 
58
58
  See [Build-time translation](https://github.com/mmaitre314/duckdb-kql/blob/main/docs/cli.md).
@@ -457,7 +457,7 @@
457
457
  }
458
458
  ],
459
459
  "source": [
460
- "!duckdb-kql demo.kql"
460
+ "!duckdb-kql translate demo.kql"
461
461
  ]
462
462
  },
463
463
  {
@@ -277,7 +277,7 @@ translates `.kql` files to `.sql` so the output can be run without this package
277
277
  installed at all. `--check` makes a stale generated file fail CI.
278
278
 
279
279
  ```bash
280
- duckdb-kql queries/ -o build/sql/ --check
280
+ duckdb-kql translate queries/ -o build/sql/ --check
281
281
  ```
282
282
 
283
283
  Full reference, including the generated header and how to bind the placeholders
@@ -1,19 +1,18 @@
1
- # Build-time translation (`duckdb-kql`)
1
+ # Build-time translation (`duckdb-kql translate`)
2
2
 
3
3
  Translate `.kql` to `.sql` once, in CI. What ships is plain SQL, and **nothing
4
4
  that runs it needs this package** — not the transpiler, not Python.
5
5
 
6
- > There is one subcommand, `duckdb-kql serve`, which does something else
7
- > entirely: it runs a [local Kusto REST endpoint](kusto-server.md) over a DuckDB
8
- > database. Everything that is not the literal word `serve` is a file to
9
- > translate, so nothing on this page changes.
10
-
11
6
  ```bash
12
- pip install duckdb-kql # antlr4 only; no database
13
- duckdb-kql queries/ -o build/sql/ # translate
14
- duckdb-kql queries/ -o build/sql/ --check # fail the build if stale
7
+ pip install duckdb-kql # antlr4 only; no database
8
+ duckdb-kql translate queries/ -o build/sql/ # translate
9
+ duckdb-kql translate queries/ -o build/sql/ --check # fail the build if stale
15
10
  ```
16
11
 
12
+ > `translate` is one of two subcommands. The other,
13
+ > [`duckdb-kql serve`](kusto-server.md), runs a local Kusto REST endpoint over a
14
+ > DuckDB database — a different job with different dependencies.
15
+
17
16
  - [Why](#why)
18
17
  - [Usage](#usage)
19
18
  - [The generated header](#the-generated-header)
@@ -41,7 +40,7 @@ build time means:
41
40
  ## Usage
42
41
 
43
42
  ```
44
- duckdb-kql FILE... [-o PATH] [--check] [--schema FILE] [--no-header] [-v]
43
+ duckdb-kql translate FILE... [-o PATH] [--check] [--schema FILE] [--no-header] [-v]
45
44
  ```
46
45
 
47
46
  | | |
@@ -54,14 +53,20 @@ duckdb-kql FILE... [-o PATH] [--check] [--schema FILE] [--no-header] [-v]
54
53
  | `-v, --verbose` | Report each file written, on stderr. |
55
54
 
56
55
  ```bash
57
- duckdb-kql queries/errors.kql # to stdout
58
- duckdb-kql queries/errors.kql -o build/errors.sql # to one file
59
- duckdb-kql queries/ -o build/ # a directory of each
60
- echo 'print x = 1' | duckdb-kql - # from stdin
56
+ duckdb-kql translate queries/errors.kql # to stdout
57
+ duckdb-kql translate queries/errors.kql -o build/errors.sql # to one file
58
+ duckdb-kql translate queries/ -o build/ # a directory of each
59
+ echo 'print x = 1' | duckdb-kql translate - # from stdin
61
60
  ```
62
61
 
63
- The command is also `python -m duckdb_kql`, for CI jobs where the console script
64
- is not on `PATH`.
62
+ The command is also `python -m duckdb_kql translate`, for CI jobs where the
63
+ console script is not on `PATH`.
64
+
65
+ The verb is required: a bare `duckdb-kql queries/` is a usage error, not a
66
+ translation. It used to be the latter, and reading well was the whole argument
67
+ for it — but a filename in the verb slot is exactly the ambiguity a subcommand
68
+ removes, and the alternative is a second verb that silently collides with a file
69
+ of the same name.
65
70
 
66
71
  ## The generated header
67
72
 
@@ -128,7 +133,7 @@ Commit the `.sql` and let `--check` prove it matches:
128
133
 
129
134
  ```yaml
130
135
  - run: pip install duckdb-kql
131
- - run: duckdb-kql queries/ -o sql/ --check
136
+ - run: duckdb-kql translate queries/ -o sql/ --check
132
137
  ```
133
138
 
134
139
  An edited `.kql` whose `.sql` was not regenerated fails with exit 3 and a list
@@ -140,7 +145,7 @@ If you would rather not commit the output, translate into the build directory
140
145
  and skip `--check`:
141
146
 
142
147
  ```yaml
143
- - run: pip install duckdb-kql && duckdb-kql queries/ -o build/sql/
148
+ - run: pip install duckdb-kql && duckdb-kql translate queries/ -o build/sql/
144
149
  ```
145
150
 
146
151
  Errors are reported as `file:line:column: error: …`, which GitHub Actions and
@@ -160,7 +165,7 @@ time there is no connection, so pass a JSON file:
160
165
  ```
161
166
 
162
167
  ```bash
163
- duckdb-kql queries/ -o build/sql/ --schema schema.json
168
+ duckdb-kql translate queries/ -o build/sql/ --schema schema.json
164
169
  ```
165
170
 
166
171
  Everything else translates schema-free. A `join` without a schema fails with a
@@ -169,7 +169,7 @@ There is a command for exactly this. Translate your `.kql` files to `.sql` in
169
169
  CI, ship the SQL, and nothing at runtime needs this package:
170
170
 
171
171
  ```bash
172
- duckdb-kql queries/ -o build/sql/ --check
172
+ duckdb-kql translate queries/ -o build/sql/ --check
173
173
  ```
174
174
 
175
175
  See [Build-time translation](cli.md).
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.0.1.dev3'
22
- __version_tuple__ = version_tuple = (0, 0, 1, 'dev3')
21
+ __version__ = version = '0.0.1.dev4'
22
+ __version_tuple__ = version_tuple = (0, 0, 1, 'dev4')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1,30 +1,31 @@
1
- """``duckdb-kql`` — translate ``.kql`` files to ``.sql`` at build time.
2
-
3
- The point of this command is that the *output* has no dependencies. Translate
4
- your queries in CI, commit or ship the ``.sql``, and the thing that runs them
5
- needs nothing from this package — not even DuckDB's Python bindings. A Go
6
- service, a dbt model, a psql script and a notebook can all read the same file.
7
-
8
- Translation is Layer 0 only: no database is opened and ``duckdb`` is never
9
- imported, so ``pip install duckdb-kql`` alone is enough to run it.
1
+ """``duckdb-kql`` — the command line, one subcommand per job.
10
2
 
11
3
  ::
12
4
 
13
- duckdb-kql queries/*.kql -o build/sql/ # translate
14
- duckdb-kql queries/*.kql -o build/sql/ --check # ... and fail if stale
15
-
16
- The ``--check`` mode is the one that belongs in CI: it regenerates in memory and
17
- compares, so a ``.kql`` edited without regenerating its ``.sql`` fails the build
18
- instead of shipping a stale query.
19
-
20
- There is one subcommand, ``serve``, which is a different job entirely — it runs
21
- a local Kusto-compatible HTTP endpoint over a DuckDB database so that Kusto
22
- tools, including the Azure Data Explorer web UI, can query it::
23
-
24
- duckdb-kql serve logs.duckdb
25
-
26
- It needs the ``duckdb`` extra. Everything that is not the literal word ``serve``
27
- is a file to translate, so the interface above is unchanged.
5
+ duckdb-kql translate queries/ -o build/sql/ # KQL files -> SQL files
6
+ duckdb-kql translate queries/ -o build/sql/ --check # ... and fail if stale
7
+ duckdb-kql serve logs.duckdb # a local Kusto endpoint
8
+
9
+ **translate** is the build-time path, and the point of it is that the *output*
10
+ has no dependencies. Translate your queries in CI, commit or ship the ``.sql``,
11
+ and the thing that runs them needs nothing from this package — not even DuckDB's
12
+ Python bindings. A Go service, a dbt model, a psql script and a notebook can all
13
+ read the same file. ``--check`` is the mode that belongs in CI: it regenerates in
14
+ memory and compares, so a ``.kql`` edited without regenerating its ``.sql`` fails
15
+ the build instead of shipping a stale query.
16
+
17
+ It is Layer 0 only — no database is opened and ``duckdb`` is never imported, so
18
+ ``pip install duckdb-kql`` alone is enough to run it.
19
+
20
+ **serve** is a different job entirely: a local Kusto-compatible HTTP endpoint
21
+ over a DuckDB database, so Kusto tools — including the Azure Data Explorer web
22
+ UI — can query it. It needs the ``duckdb`` extra.
23
+
24
+ Every subcommand is explicit. An earlier version took a bare list of files
25
+ (``duckdb-kql queries/ -o build/``), which read well while translation was the
26
+ only thing this command did, but leaves no room for a second verb: any new one
27
+ would be ambiguous with a file of the same name, and the ambiguity would be
28
+ silent. Naming the verb costs one word and keeps the space open.
28
29
  """
29
30
 
30
31
  from __future__ import annotations
@@ -33,9 +34,9 @@ import argparse
33
34
  import datetime as dt
34
35
  import json
35
36
  import sys
36
- from collections.abc import Sequence
37
+ from collections.abc import Callable, Sequence
37
38
  from pathlib import Path
38
- from typing import Any
39
+ from typing import Any, cast
39
40
 
40
41
  from . import __version__, to_sql
41
42
  from .errors import KqlError, KqlSyntaxError
@@ -56,15 +57,17 @@ EXIT_STALE = 3
56
57
 
57
58
  def main(argv: Sequence[str] | None = None) -> int:
58
59
  """Entry point. Returns a process exit code rather than raising."""
59
- argv = list(sys.argv[1:] if argv is None else argv)
60
- # Dispatched by hand rather than with argparse subparsers, because
61
- # subparsers would make the existing form — a bare list of files — a
62
- # subcommand too, and every documented invocation would have to change.
63
- if argv and argv[0] == "serve":
64
- return _serve(argv[1:])
65
-
66
60
  args = _parser().parse_args(argv)
61
+ return cast("Callable[[argparse.Namespace], int]", args.run)(args)
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # translate
66
+ # ---------------------------------------------------------------------------
67
+
67
68
 
69
+ def _translate_command(args: argparse.Namespace) -> int:
70
+ """``duckdb-kql translate`` — KQL files in, SQL files out."""
68
71
  try:
69
72
  schema = _load_schema(args.schema)
70
73
  except (OSError, ValueError) as exc:
@@ -129,9 +132,10 @@ def main(argv: Sequence[str] | None = None) -> int:
129
132
  # ---------------------------------------------------------------------------
130
133
 
131
134
 
132
- def _serve(argv: Sequence[str]) -> int:
135
+ def _serve_command(args: argparse.Namespace) -> int:
133
136
  """``duckdb-kql serve`` — a local Kusto endpoint over a DuckDB database."""
134
- args = _serve_parser().parse_args(argv)
137
+ # Imported here, not at module scope: this is the only subcommand that needs
138
+ # a database, and `translate` is documented to run without one installed.
135
139
  from .server import serve # noqa: PLC0415
136
140
 
137
141
  origins = tuple(args.allow_origin) if args.allow_origin else ADX_ORIGINS
@@ -148,48 +152,6 @@ def _serve(argv: Sequence[str]) -> int:
148
152
  return EXIT_OK
149
153
 
150
154
 
151
- def _serve_parser() -> argparse.ArgumentParser:
152
- parser = argparse.ArgumentParser(
153
- prog="duckdb-kql serve",
154
- description=(
155
- "Serve a DuckDB database over the Kusto REST API, so Kusto tools "
156
- "can query it. Open https://dataexplorer.azure.com, choose Add "
157
- "connection, and give it the URL this prints."
158
- ),
159
- epilog=(
160
- "Listens on 127.0.0.1 only and cannot be made to listen anywhere "
161
- "else: it answers unauthenticated queries, so reaching it has to "
162
- "mean already being on this machine."
163
- ),
164
- formatter_class=argparse.RawDescriptionHelpFormatter,
165
- )
166
- parser.add_argument(
167
- "database",
168
- nargs="?",
169
- default=":memory:",
170
- metavar="DATABASE",
171
- help="DuckDB database file to serve. Omit for an empty in-memory one.",
172
- )
173
- parser.add_argument(
174
- "-p",
175
- "--port",
176
- type=int,
177
- default=DEFAULT_PORT,
178
- metavar="PORT",
179
- help=f"TCP port to listen on (default: {DEFAULT_PORT})",
180
- )
181
- parser.add_argument(
182
- "--allow-origin",
183
- action="append",
184
- metavar="ORIGIN",
185
- help=(
186
- "additionally allow a browser origin to make cross-origin requests. "
187
- "Repeatable. Replaces the Azure Data Explorer default list, and is a "
188
- "decision about who may read this database from another browser tab."
189
- ),
190
- )
191
- parser.add_argument("--version", action="version", version=f"duckdb-kql {__version__}")
192
- return parser
193
155
 
194
156
 
195
157
  # ---------------------------------------------------------------------------
@@ -360,8 +322,34 @@ def _load_schema(path: str | None) -> dict[str, list[str]] | None:
360
322
 
361
323
 
362
324
  def _parser() -> argparse.ArgumentParser:
325
+ """The whole command line. Each subparser stores its handler in ``run``.
326
+
327
+ Dispatching through ``set_defaults(run=...)`` rather than a chain of
328
+ ``if args.command == ...`` means a new subcommand is added in exactly one
329
+ place, and cannot be registered without being wired up.
330
+ """
363
331
  parser = argparse.ArgumentParser(
364
332
  prog="duckdb-kql",
333
+ description=(
334
+ "Run KQL on DuckDB. `translate` turns .kql files into .sql at build "
335
+ "time; `serve` puts a local Kusto REST endpoint in front of a DuckDB "
336
+ "database."
337
+ ),
338
+ epilog=(
339
+ "exit codes: 0 ok; 1 a query failed to translate, or the server "
340
+ "could not start; 2 bad usage; 3 --check found a missing or stale "
341
+ "output"
342
+ ),
343
+ formatter_class=argparse.RawDescriptionHelpFormatter,
344
+ )
345
+ parser.add_argument("--version", action="version", version=f"duckdb-kql {__version__}")
346
+ # `required` so a bare `duckdb-kql` prints usage rather than a traceback
347
+ # about a missing `run` attribute.
348
+ subcommands = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
349
+
350
+ translate = subcommands.add_parser(
351
+ "translate",
352
+ help="translate .kql files to .sql",
365
353
  description=(
366
354
  "Translate KQL files to DuckDB SQL. The generated SQL has no "
367
355
  "dependency on this package, so queries can be translated once at "
@@ -373,13 +361,14 @@ def _parser() -> argparse.ArgumentParser:
373
361
  ),
374
362
  formatter_class=argparse.RawDescriptionHelpFormatter,
375
363
  )
376
- parser.add_argument(
364
+ translate.set_defaults(run=_translate_command)
365
+ translate.add_argument(
377
366
  "files",
378
367
  nargs="+",
379
368
  metavar="FILE",
380
369
  help="KQL files or directories to translate; '-' reads stdin",
381
370
  )
382
- parser.add_argument(
371
+ translate.add_argument(
383
372
  "-o",
384
373
  "--output",
385
374
  metavar="PATH",
@@ -388,7 +377,7 @@ def _parser() -> argparse.ArgumentParser:
388
377
  "stdout."
389
378
  ),
390
379
  )
391
- parser.add_argument(
380
+ translate.add_argument(
392
381
  "--check",
393
382
  action="store_true",
394
383
  help=(
@@ -396,7 +385,7 @@ def _parser() -> argparse.ArgumentParser:
396
385
  "in CI so an edited .kql cannot ship with a stale .sql."
397
386
  ),
398
387
  )
399
- parser.add_argument(
388
+ translate.add_argument(
400
389
  "--schema",
401
390
  metavar="FILE",
402
391
  help=(
@@ -404,18 +393,59 @@ def _parser() -> argparse.ArgumentParser:
404
393
  "Only `join` needs it."
405
394
  ),
406
395
  )
407
- parser.add_argument(
396
+ translate.add_argument(
408
397
  "--no-header",
409
398
  action="store_true",
410
399
  help="omit the generated-file comment block",
411
400
  )
412
- parser.add_argument(
401
+ translate.add_argument(
413
402
  "-v",
414
403
  "--verbose",
415
404
  action="store_true",
416
405
  help="report each file written, on stderr",
417
406
  )
418
- parser.add_argument("--version", action="version", version=f"duckdb-kql {__version__}")
407
+
408
+ serve = subcommands.add_parser(
409
+ "serve",
410
+ help="serve a DuckDB database over the Kusto REST API",
411
+ description=(
412
+ "Serve a DuckDB database over the Kusto REST API, so Kusto tools "
413
+ "can query it. Open https://dataexplorer.azure.com, choose Add "
414
+ "connection, and give it the URL this prints."
415
+ ),
416
+ epilog=(
417
+ "Listens on 127.0.0.1 only and cannot be made to listen anywhere "
418
+ "else: it answers unauthenticated queries, so reaching it has to "
419
+ "mean already being on this machine."
420
+ ),
421
+ formatter_class=argparse.RawDescriptionHelpFormatter,
422
+ )
423
+ serve.set_defaults(run=_serve_command)
424
+ serve.add_argument(
425
+ "database",
426
+ nargs="?",
427
+ default=":memory:",
428
+ metavar="DATABASE",
429
+ help="DuckDB database file to serve. Omit for an empty in-memory one.",
430
+ )
431
+ serve.add_argument(
432
+ "-p",
433
+ "--port",
434
+ type=int,
435
+ default=DEFAULT_PORT,
436
+ metavar="PORT",
437
+ help=f"TCP port to listen on (default: {DEFAULT_PORT})",
438
+ )
439
+ serve.add_argument(
440
+ "--allow-origin",
441
+ action="append",
442
+ metavar="ORIGIN",
443
+ help=(
444
+ "additionally allow a browser origin to make cross-origin requests. "
445
+ "Repeatable. Replaces the Azure Data Explorer default list, and is a "
446
+ "decision about who may read this database from another browser tab."
447
+ ),
448
+ )
419
449
  return parser
420
450
 
421
451
 
@@ -10,6 +10,7 @@ promised not to need.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import argparse
13
14
  import re
14
15
  import subprocess
15
16
  import sys
@@ -45,7 +46,7 @@ def test_writes_to_stdout_by_default(
45
46
  project: Path, capsys: pytest.CaptureFixture[str]
46
47
  ) -> None:
47
48
  """Writing files unasked would be a surprising default for a translator."""
48
- assert main([str(project / "queries" / "count.kql")]) == EXIT_OK
49
+ assert main(["translate", str(project / "queries" / "count.kql")]) == EXIT_OK
49
50
  out = capsys.readouterr().out
50
51
  assert "SELECT" in out
51
52
  assert not list(project.glob("**/*.sql"))
@@ -53,20 +54,20 @@ def test_writes_to_stdout_by_default(
53
54
 
54
55
  def test_single_input_and_a_file_target(project: Path) -> None:
55
56
  target = project / "out" / "count.sql"
56
- assert main([str(project / "queries" / "count.kql"), "-o", str(target)]) == EXIT_OK
57
+ assert main(["translate", str(project / "queries" / "count.kql"), "-o", str(target)]) == EXIT_OK
57
58
  assert "SELECT" in target.read_text(encoding="utf-8")
58
59
 
59
60
 
60
61
  def test_a_directory_input_expands_to_its_kql_files(project: Path) -> None:
61
62
  """A build script has a directory, not a shell-expanded list."""
62
63
  out = project / "build"
63
- assert main([str(project / "queries"), "-o", str(out)]) == EXIT_OK
64
+ assert main(["translate", str(project / "queries"), "-o", str(out)]) == EXIT_OK
64
65
  assert sorted(p.name for p in out.glob("*.sql")) == ["by_state.sql", "count.sql"]
65
66
 
66
67
 
67
68
  def test_output_directory_is_created(project: Path) -> None:
68
69
  out = project / "deep" / "nested" / "build"
69
- assert main([str(project / "queries"), "-o", str(out)]) == EXIT_OK
70
+ assert main(["translate", str(project / "queries"), "-o", str(out)]) == EXIT_OK
70
71
  assert (out / "count.sql").is_file()
71
72
 
72
73
 
@@ -76,7 +77,7 @@ def test_stdin_is_readable(
76
77
  import io # noqa: PLC0415
77
78
 
78
79
  monkeypatch.setattr("sys.stdin", io.StringIO("print x = 1"))
79
- assert main(["-"]) == EXIT_OK
80
+ assert main(["translate", "-"]) == EXIT_OK
80
81
  out = capsys.readouterr().out
81
82
  assert "<stdin>" in out
82
83
  assert 'AS "x"' in out
@@ -89,41 +90,41 @@ def test_stdin_is_readable(
89
90
 
90
91
  def test_check_passes_on_freshly_generated_output(project: Path) -> None:
91
92
  out = project / "build"
92
- assert main([str(project / "queries"), "-o", str(out)]) == EXIT_OK
93
- assert main([str(project / "queries"), "-o", str(out), "--check"]) == EXIT_OK
93
+ assert main(["translate", str(project / "queries"), "-o", str(out)]) == EXIT_OK
94
+ assert main(["translate", str(project / "queries"), "-o", str(out), "--check"]) == EXIT_OK
94
95
 
95
96
 
96
97
  def test_check_fails_when_the_kql_changed(project: Path) -> None:
97
98
  """The point of the mode: an edited query cannot ship a stale .sql."""
98
99
  out = project / "build"
99
- main([str(project / "queries"), "-o", str(out)])
100
+ main(["translate", str(project / "queries"), "-o", str(out)])
100
101
  (project / "queries" / "count.kql").write_text(SIMPLE + "| take 5\n", encoding="utf-8")
101
- assert main([str(project / "queries"), "-o", str(out), "--check"]) == EXIT_STALE
102
+ assert main(["translate", str(project / "queries"), "-o", str(out), "--check"]) == EXIT_STALE
102
103
 
103
104
 
104
105
  def test_check_fails_when_the_output_is_missing(project: Path) -> None:
105
106
  assert (
106
- main([str(project / "queries"), "-o", str(project / "nothing"), "--check"])
107
+ main(["translate", str(project / "queries"), "-o", str(project / "nothing"), "--check"])
107
108
  == EXIT_STALE
108
109
  )
109
110
 
110
111
 
111
112
  def test_check_writes_nothing(project: Path) -> None:
112
113
  out = project / "build"
113
- main([str(project / "queries"), "-o", str(out), "--check"])
114
+ main(["translate", str(project / "queries"), "-o", str(out), "--check"])
114
115
  assert not out.exists()
115
116
 
116
117
 
117
118
  def test_check_without_an_output_is_a_usage_error(project: Path) -> None:
118
119
  """There is nothing to compare stdout against; saying so beats exiting 0."""
119
- assert main([str(project / "queries"), "--check"]) == 2
120
+ assert main(["translate", str(project / "queries"), "--check"]) == 2
120
121
 
121
122
 
122
123
  def test_check_names_the_stale_files(
123
124
  project: Path, capsys: pytest.CaptureFixture[str]
124
125
  ) -> None:
125
126
  out = project / "build"
126
- main([str(project / "queries"), "-o", str(out), "--check"])
127
+ main(["translate", str(project / "queries"), "-o", str(out), "--check"])
127
128
  assert "count.sql" in capsys.readouterr().err
128
129
 
129
130
 
@@ -139,7 +140,7 @@ def test_header_warns_about_the_time_zone(project: Path) -> None:
139
140
  Someone running generated SQL has no library to set it for them.
140
141
  """
141
142
  out = project / "build"
142
- main([str(project / "queries"), "-o", str(out)])
143
+ main(["translate", str(project / "queries"), "-o", str(out)])
143
144
  assert "SET TimeZone='UTC'" in (out / "count.sql").read_text(encoding="utf-8")
144
145
 
145
146
 
@@ -151,7 +152,7 @@ def test_header_carries_no_version_or_timestamp(project: Path) -> None:
151
152
  from duckdb_kql import __version__ # noqa: PLC0415
152
153
 
153
154
  out = project / "build"
154
- main([str(project / "queries"), "-o", str(out)])
155
+ main(["translate", str(project / "queries"), "-o", str(out)])
155
156
  header = (out / "count.sql").read_text(encoding="utf-8").split("\n\n")[0]
156
157
  assert __version__ not in header
157
158
  assert "20" not in header.replace("UTC", "") # no year, no date
@@ -160,7 +161,7 @@ def test_header_carries_no_version_or_timestamp(project: Path) -> None:
160
161
  def test_header_maps_placeholders_back_to_parameter_names(project: Path) -> None:
161
162
  """``$kqlp0`` alone tells a caller nothing about what to bind."""
162
163
  out = project / "build"
163
- main([str(project / "queries"), "-o", str(out)])
164
+ main(["translate", str(project / "queries"), "-o", str(out)])
164
165
  sql = (out / "by_state.sql").read_text(encoding="utf-8")
165
166
 
166
167
  assert "$kqlp0" in sql and "state" in sql
@@ -172,13 +173,13 @@ def test_header_maps_placeholders_back_to_parameter_names(project: Path) -> None
172
173
 
173
174
  def test_no_header_produces_bare_sql(project: Path) -> None:
174
175
  out = project / "build"
175
- main([str(project / "queries"), "-o", str(out), "--no-header"])
176
+ main(["translate", str(project / "queries"), "-o", str(out), "--no-header"])
176
177
  assert (out / "count.sql").read_text(encoding="utf-8").startswith("WITH")
177
178
 
178
179
 
179
180
  def test_a_query_without_parameters_gets_no_parameter_block(project: Path) -> None:
180
181
  out = project / "build"
181
- main([str(project / "queries"), "-o", str(out)])
182
+ main(["translate", str(project / "queries"), "-o", str(out)])
182
183
  assert "Query parameters" not in (out / "count.sql").read_text(encoding="utf-8")
183
184
 
184
185
 
@@ -193,7 +194,7 @@ def test_a_syntax_error_reports_file_line_and_column(
193
194
  """``file:line:col:`` is what editors and CI annotators parse."""
194
195
  bad = tmp_path / "bad.kql"
195
196
  bad.write_text("StormEvents | where State ==\n", encoding="utf-8")
196
- assert main([str(bad)]) == EXIT_TRANSLATION_ERROR
197
+ assert main(["translate", str(bad)]) == EXIT_TRANSLATION_ERROR
197
198
  err = capsys.readouterr().err
198
199
  # The shape is what matters, not which line the parser stopped on.
199
200
  assert re.match(rf"^{re.escape(bad.as_posix())}:\d+:\d+: error: ", err), err
@@ -204,7 +205,7 @@ def test_an_unsupported_construct_is_an_error_not_a_guess(
204
205
  ) -> None:
205
206
  bad = tmp_path / "unsupported.kql"
206
207
  bad.write_text("StormEvents | parse State with * 'x' *\n", encoding="utf-8")
207
- assert main([str(bad)]) == EXIT_TRANSLATION_ERROR
208
+ assert main(["translate", str(bad)]) == EXIT_TRANSLATION_ERROR
208
209
  assert "error:" in capsys.readouterr().err
209
210
 
210
211
 
@@ -214,14 +215,14 @@ def test_one_bad_file_does_not_stop_the_others(
214
215
  """A build reporting one error at a time is a slow build."""
215
216
  (project / "queries" / "bad.kql").write_text("| where", encoding="utf-8")
216
217
  out = project / "build"
217
- assert main([str(project / "queries"), "-o", str(out)]) == EXIT_TRANSLATION_ERROR
218
+ assert main(["translate", str(project / "queries"), "-o", str(out)]) == EXIT_TRANSLATION_ERROR
218
219
  assert (out / "count.sql").is_file()
219
220
 
220
221
 
221
222
  def test_a_missing_file_is_reported_not_raised(
222
223
  tmp_path: Path, capsys: pytest.CaptureFixture[str]
223
224
  ) -> None:
224
- assert main([str(tmp_path / "nope.kql")]) == EXIT_TRANSLATION_ERROR
225
+ assert main(["translate", str(tmp_path / "nope.kql")]) == EXIT_TRANSLATION_ERROR
225
226
  assert "nope.kql" in capsys.readouterr().err
226
227
 
227
228
 
@@ -239,6 +240,7 @@ def test_schema_file_enables_join(tmp_path: Path) -> None:
239
240
  assert (
240
241
  main(
241
242
  [
243
+ "translate",
242
244
  str(tmp_path / "j.kql"),
243
245
  "-o",
244
246
  str(tmp_path / "j.sql"),
@@ -257,7 +259,7 @@ def test_a_malformed_schema_says_what_was_expected(
257
259
  (tmp_path / "q.kql").write_text("print 1", encoding="utf-8")
258
260
  (tmp_path / "schema.json").write_text('{"T": "not a list"}', encoding="utf-8")
259
261
  assert (
260
- main([str(tmp_path / "q.kql"), "--schema", str(tmp_path / "schema.json")])
262
+ main(["translate", str(tmp_path / "q.kql"), "--schema", str(tmp_path / "schema.json")])
261
263
  == EXIT_TRANSLATION_ERROR
262
264
  )
263
265
  assert "list of column" in capsys.readouterr().err
@@ -280,7 +282,7 @@ def test_the_cli_does_not_import_duckdb(tmp_path: Path) -> None:
280
282
  probe.write_text(
281
283
  "import sys\n"
282
284
  "from duckdb_kql.cli import main\n"
283
- f"main([{str(query)!r}, '-o', {str(tmp_path / 'q.sql')!r}])\n"
285
+ f"main(['translate', {str(query)!r}, '-o', {str(tmp_path / 'q.sql')!r}])\n"
284
286
  "assert 'duckdb' not in sys.modules, 'the CLI imported duckdb'\n"
285
287
  "print('clean')\n",
286
288
  encoding="utf-8",
@@ -300,7 +302,7 @@ def test_python_m_duckdb_kql_works(tmp_path: Path) -> None:
300
302
  query = tmp_path / "q.kql"
301
303
  query.write_text(SIMPLE, encoding="utf-8")
302
304
  proc = subprocess.run( # noqa: S603 - fixed argv, no shell
303
- [sys.executable, "-m", "duckdb_kql", str(query)],
305
+ [sys.executable, "-m", "duckdb_kql", "translate", str(query)],
304
306
  capture_output=True,
305
307
  text=True,
306
308
  check=False,
@@ -317,28 +319,69 @@ def test_the_console_script_is_declared() -> None:
317
319
 
318
320
 
319
321
  # ---------------------------------------------------------------------------
320
- # The `serve` subcommand
322
+ # Subcommands
321
323
  # ---------------------------------------------------------------------------
322
324
 
325
+ #: Every verb this command answers to. A new one that is not listed here is a
326
+ #: new one nobody checked the wiring of.
327
+ SUBCOMMANDS = ["translate", "serve"]
323
328
 
324
- def test_serve_is_the_only_word_that_is_not_a_filename(project: Path) -> None:
325
- """Adding a subcommand must not turn the documented form into one.
326
329
 
327
- `duckdb-kql queries/ -o out/` has to keep working exactly as before, which
328
- is why the dispatch is a single literal rather than argparse subparsers.
330
+ def test_the_subcommands_are_the_documented_ones() -> None:
331
+ from duckdb_kql.cli import _parser # noqa: PLC0415
332
+
333
+ (subparsers,) = [
334
+ action
335
+ for action in _parser()._actions
336
+ if isinstance(action, argparse._SubParsersAction)
337
+ ]
338
+ assert sorted(subparsers.choices) == sorted(SUBCOMMANDS)
339
+
340
+
341
+ @pytest.mark.parametrize("command", SUBCOMMANDS)
342
+ def test_every_subcommand_is_wired_to_a_handler(command: str) -> None:
343
+ """Registering a subparser and forgetting `set_defaults(run=...)` would
344
+ parse fine and then fail on the attribute, in front of the user."""
345
+ from duckdb_kql.cli import _parser # noqa: PLC0415
346
+
347
+ args = _parser().parse_args([command] + (["x.kql"] if command == "translate" else []))
348
+ assert callable(args.run)
349
+
350
+
351
+ def test_a_bare_invocation_is_a_usage_error_not_a_traceback() -> None:
352
+ """`duckdb-kql` with no verb has nothing to do; 2 is argparse's usage code."""
353
+ with pytest.raises(SystemExit) as exit_code:
354
+ main([])
355
+ assert exit_code.value.code == 2
356
+
357
+
358
+ def test_an_unknown_subcommand_lists_the_known_ones() -> None:
359
+ with pytest.raises(SystemExit) as exit_code:
360
+ main(["transalte", "q.kql"])
361
+ assert exit_code.value.code == 2
362
+
363
+
364
+ def test_a_kql_file_is_no_longer_a_verb(tmp_path: Path) -> None:
365
+ """The breaking change, stated as a test.
366
+
367
+ `duckdb-kql queries/` used to translate. It is now a usage error, because a
368
+ filename in the verb slot is exactly the ambiguity subcommands remove — and
369
+ a silent reinterpretation would be worse than a refusal.
329
370
  """
330
- out = project / "out"
331
- assert main([str(project / "queries"), "-o", str(out)]) == EXIT_OK
332
- assert (out / "count.sql").is_file()
371
+ query = tmp_path / "q.kql"
372
+ query.write_text(SIMPLE, encoding="utf-8")
373
+ with pytest.raises(SystemExit) as exit_code:
374
+ main([str(query)])
375
+ assert exit_code.value.code == 2
333
376
 
334
377
 
335
378
  def test_a_kql_file_called_serve_is_still_translated(
336
379
  tmp_path: Path, capsys: pytest.CaptureFixture[str]
337
380
  ) -> None:
338
- """Only the bare word dispatches; `serve.kql` is a file like any other."""
381
+ """The verb slot is the only place `serve` is a verb."""
339
382
  query = tmp_path / "serve.kql"
340
383
  query.write_text(SIMPLE, encoding="utf-8")
341
- assert main([str(query)]) == EXIT_OK
384
+ assert main(["translate", str(query)]) == EXIT_OK
342
385
  assert "SELECT" in capsys.readouterr().out
343
386
 
344
387
 
@@ -355,15 +398,24 @@ def test_serve_help_describes_the_local_only_guarantee(
355
398
 
356
399
 
357
400
  def test_serve_defaults_to_an_in_memory_database() -> None:
358
- from duckdb_kql.cli import _serve_parser # noqa: PLC0415
401
+ from duckdb_kql.cli import _parser # noqa: PLC0415
359
402
 
360
- args = _serve_parser().parse_args([])
403
+ args = _parser().parse_args(["serve"])
361
404
  assert args.database == ":memory:"
362
405
  assert args.port == 31415
363
406
 
364
407
 
365
408
  def test_serve_takes_a_port_override() -> None:
366
- from duckdb_kql.cli import _serve_parser # noqa: PLC0415
409
+ from duckdb_kql.cli import _parser # noqa: PLC0415
410
+
411
+ assert _parser().parse_args(["serve", "--port", "9000"]).port == 9000
412
+ assert _parser().parse_args(["serve", "-p", "9000"]).port == 9000
367
413
 
368
- assert _serve_parser().parse_args(["--port", "9000"]).port == 9000
369
- assert _serve_parser().parse_args(["-p", "9000"]).port == 9000
414
+
415
+ def test_the_top_level_help_names_both_jobs(capsys: pytest.CaptureFixture[str]) -> None:
416
+ """Someone typing `duckdb-kql` blind should learn what it can do."""
417
+ with pytest.raises(SystemExit):
418
+ main(["--help"])
419
+ printed = capsys.readouterr().out
420
+ for command in SUBCOMMANDS:
421
+ assert command in printed
@@ -109,9 +109,17 @@ def test_it_binds_to_loopback_only(server) -> None:
109
109
 
110
110
  def test_the_cli_cannot_ask_for_a_different_bind_address() -> None:
111
111
  """`--host` must not exist. A flag is all it would take to undo the above."""
112
- from duckdb_kql.cli import _serve_parser
112
+ import argparse # noqa: PLC0415
113
113
 
114
- flags = {option for action in _serve_parser()._actions for option in action.option_strings}
114
+ from duckdb_kql.cli import _parser # noqa: PLC0415
115
+
116
+ (subparsers,) = [
117
+ action
118
+ for action in _parser()._actions
119
+ if isinstance(action, argparse._SubParsersAction)
120
+ ]
121
+ serve = subparsers.choices["serve"]
122
+ flags = {option for action in serve._actions for option in action.option_strings}
115
123
  assert not flags & {"--host", "--bind", "--interface", "--address"}
116
124
 
117
125
 
File without changes