duckdb-kql 0.0.1.dev4__tar.gz → 0.0.1.dev5__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.dev4 → duckdb_kql-0.0.1.dev5}/PKG-INFO +2 -1
  2. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/kusto-server.md +7 -0
  3. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/pyproject.toml +2 -1
  4. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_version.py +2 -2
  5. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/server.py +35 -7
  6. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_server.py +100 -6
  7. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_workflows.py +4 -3
  8. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/.gitignore +0 -0
  9. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/CODE_OF_CONDUCT.md +0 -0
  10. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/CONTRIBUTING.md +0 -0
  11. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/LICENSE +0 -0
  12. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/README.md +0 -0
  13. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/SECURITY.md +0 -0
  14. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/THIRD-PARTY-NOTICES.md +0 -0
  15. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/demo/demo.ipynb +0 -0
  16. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/demo/demo.kql +0 -0
  17. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/TRANSLATION.md +0 -0
  18. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/ai-cost-strategy.md +0 -0
  19. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/api.md +0 -0
  20. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/azure-monitor-profile.md +0 -0
  21. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/cli.md +0 -0
  22. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/code-review/README.md +0 -0
  23. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/code-review/kusto-client-compat.md +0 -0
  24. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/code-review/public-api-and-typing.md +0 -0
  25. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/code-review/review-2026-08-04.md +0 -0
  26. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/code-review/security-and-injection.md +0 -0
  27. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/code-review/testing-oracle-and-fixtures.md +0 -0
  28. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/code-review/tooling-packaging-ci-docs.md +0 -0
  29. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/code-review/translation-correctness.md +0 -0
  30. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/frequency-scan-results.md +0 -0
  31. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/getting-started.md +0 -0
  32. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/implementation-options.md +0 -0
  33. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/implementation-plan.md +0 -0
  34. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/kql-on-duckdb-landscape.md +0 -0
  35. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/kql-support.md +0 -0
  36. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/kusto-client.md +0 -0
  37. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/lessons-from-bun-rewrite.md +0 -0
  38. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/licensing.md +0 -0
  39. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/m0-grammar-spike.md +0 -0
  40. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/oracle-harness.md +0 -0
  41. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/docs/test-plan.md +0 -0
  42. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/grammar/Kql.g4 +0 -0
  43. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/grammar/KqlTokens.g4 +0 -0
  44. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/grammar/UPSTREAM.md +0 -0
  45. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/__init__.py +0 -0
  46. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/__main__.py +0 -0
  47. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_antlr/Kql.interp +0 -0
  48. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_antlr/Kql.tokens +0 -0
  49. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_antlr/KqlLexer.interp +0 -0
  50. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_antlr/KqlLexer.py +0 -0
  51. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_antlr/KqlLexer.tokens +0 -0
  52. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_antlr/KqlListener.py +0 -0
  53. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_antlr/KqlParser.py +0 -0
  54. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_antlr/KqlVisitor.py +0 -0
  55. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/_antlr/__init__.py +0 -0
  56. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/cli.py +0 -0
  57. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/comparison.py +0 -0
  58. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/control.py +0 -0
  59. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/engine.py +0 -0
  60. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/errors.py +0 -0
  61. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/fixtures.py +0 -0
  62. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/ir.py +0 -0
  63. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/kusto/__init__.py +0 -0
  64. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/kusto/_models.py +0 -0
  65. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/kusto/client.py +0 -0
  66. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/kusto/client_request_properties.py +0 -0
  67. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/kusto/exceptions.py +0 -0
  68. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/kusto/helpers.py +0 -0
  69. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/kusto/response.py +0 -0
  70. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/lower.py +0 -0
  71. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/oracle.py +0 -0
  72. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/params.py +0 -0
  73. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/parser.py +0 -0
  74. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/py.typed +0 -0
  75. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/schema.py +0 -0
  76. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/translate/__init__.py +0 -0
  77. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/translate/functions.py +0 -0
  78. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/src/duckdb_kql/types.py +0 -0
  79. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/conftest.py +0 -0
  80. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/profiles/azure-monitor.json +0 -0
  81. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_behavior.py +0 -0
  82. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_cli.py +0 -0
  83. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_column_order_and_null_sort.py +0 -0
  84. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_comparison.py +0 -0
  85. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_control_commands.py +0 -0
  86. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_corpus.py +0 -0
  87. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_datetime_traps.py +0 -0
  88. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_demo_notebook.py +0 -0
  89. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_devcontainers.py +0 -0
  90. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_docs.py +0 -0
  91. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_dynamic.py +0 -0
  92. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_fixtures.py +0 -0
  93. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_getschema.py +0 -0
  94. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_join.py +0 -0
  95. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_kusto_client.py +0 -0
  96. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_let.py +0 -0
  97. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_null_semantics.py +0 -0
  98. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_parse.py +0 -0
  99. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_profile_azure_monitor.py +0 -0
  100. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_query_parameters.py +0 -0
  101. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_range_in_render.py +0 -0
  102. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_summarize.py +0 -0
  103. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_support_matrix.py +0 -0
  104. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tests/test_typing.py +0 -0
  105. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tools/check_profile.py +0 -0
  106. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tools/frequency_scan.py +0 -0
  107. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tools/gen_support_matrix.py +0 -0
  108. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tools/harvest_docs.py +0 -0
  109. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tools/make_fixtures.py +0 -0
  110. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/tools/regen_expectations.py +0 -0
  111. {duckdb_kql-0.0.1.dev4 → duckdb_kql-0.0.1.dev5}/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.dev4
3
+ Version: 0.0.1.dev5
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
@@ -44,6 +44,7 @@ Classifier: Programming Language :: Python :: 3.10
44
44
  Classifier: Programming Language :: Python :: 3.11
45
45
  Classifier: Programming Language :: Python :: 3.12
46
46
  Classifier: Programming Language :: Python :: 3.13
47
+ Classifier: Programming Language :: Python :: 3.14
47
48
  Classifier: Topic :: Database
48
49
  Classifier: Topic :: Database :: Front-Ends
49
50
  Classifier: Topic :: Scientific/Engineering :: Information Analysis
@@ -66,6 +66,13 @@ https://dataexplorer.azure.us
66
66
  `--allow-origin` replaces that list. Widening it is a decision about who may read
67
67
  this database from another browser tab, not a formatting preference.
68
68
 
69
+ The **origin** is the whole check. Request *headers* are not: a preflight echoes
70
+ back whatever `Access-Control-Request-Headers` asked for, because naming a header
71
+ authorises nothing and a hand-written list is only a second place to be wrong
72
+ about someone else's client. It was wrong once — the list said `x-ms-user` where
73
+ the web UI sends `x-ms-user-id`, and the browser answered by failing the real
74
+ POST with a bare `net::ERR_FAILED` after a preflight that returned 204.
75
+
69
76
  **Nothing it serves can write.** The translated surface is read-only: no
70
77
  supported KQL operator produces a statement that modifies the database, and
71
78
  control commands that would administer a cluster are refused rather than
@@ -31,6 +31,7 @@ classifiers = [
31
31
  "Programming Language :: Python :: 3.11",
32
32
  "Programming Language :: Python :: 3.12",
33
33
  "Programming Language :: Python :: 3.13",
34
+ "Programming Language :: Python :: 3.14",
34
35
  "Topic :: Database",
35
36
  "Topic :: Database :: Front-Ends",
36
37
  "Topic :: Software Development :: Compilers",
@@ -167,7 +168,7 @@ files = ["src/duckdb_kql"]
167
168
  # way, and the next dependency to adopt the syntax would do it again. Letting
168
169
  # each interpreter check against itself is more robust *and* better coverage:
169
170
  # CI runs the floor and the ceiling, so 3.10-incompatible code fails on the
170
- # 3.10 lane while the newest semantics are exercised on the 3.13 one.
171
+ # 3.10 lane while the newest semantics are exercised on the newest one.
171
172
  disallow_untyped_defs = true
172
173
  disallow_incomplete_defs = true
173
174
  disallow_untyped_calls = true
@@ -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.dev4'
22
- __version_tuple__ = version_tuple = (0, 0, 1, 'dev4')
21
+ __version__ = version = '0.0.1.dev5'
22
+ __version_tuple__ = version_tuple = (0, 0, 1, 'dev5')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -41,6 +41,7 @@ import uuid
41
41
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
42
42
  from typing import TYPE_CHECKING, Any, NamedTuple, cast
43
43
 
44
+ from . import __version__
44
45
  from .control import SCHEMA, CommandColumn, is_control_command, split_command
45
46
  from .errors import KqlError
46
47
  from .types import kusto_type, rest_datatype
@@ -347,11 +348,18 @@ def error_response(message: str, *, code: str = "General_BadRequest") -> dict[st
347
348
 
348
349
  _LOOPBACK = re.compile(r"^(127\.\d+\.\d+\.\d+|::1)$")
349
350
 
351
+ #: Only for a preflight that names no headers, which no browser sends — a
352
+ #: preflight exists *because* there is something non-simple to declare. Kept so
353
+ #: the header is never emitted empty.
354
+ _DEFAULT_ALLOW_HEADERS = "Authorization, Content-Type, Accept"
355
+
350
356
 
351
357
  class _Handler(BaseHTTPRequestHandler):
352
358
  """One request. The connection and the allow-list come from the server."""
353
359
 
354
- server_version = f"duckdb-kql/{CLUSTER_NAME}"
360
+ # `duckdb-kql/0.1.2`, not `duckdb-kql/duckdb-kql`: the slot after the slash
361
+ # is a version, and this used to interpolate the cluster name into it.
362
+ server_version = f"duckdb-kql/{__version__}"
355
363
  # Announce HTTP/1.1 so the web UI's keep-alive works; every response below
356
364
  # sets Content-Length, which is what makes that safe.
357
365
  protocol_version = "HTTP/1.1"
@@ -406,17 +414,37 @@ class _Handler(BaseHTTPRequestHandler):
406
414
  self.send_response(204)
407
415
  self.send_header("Access-Control-Allow-Origin", origin)
408
416
  self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
409
- self.send_header(
410
- "Access-Control-Allow-Headers",
411
- "Authorization, Content-Type, x-ms-client-request-id, "
412
- "x-ms-app, x-ms-user, x-ms-client-version, Accept, Connection",
413
- )
417
+ self.send_header("Access-Control-Allow-Headers", self._allow_headers())
418
+ # Only when asked. Chrome gates requests from a public page to a private
419
+ # address behind this; answering unprompted would claim a policy the
420
+ # browser did not ask about.
421
+ if self.headers.get("Access-Control-Request-Private-Network") == "true":
422
+ self.send_header("Access-Control-Allow-Private-Network", "true")
414
423
  self.send_header("Access-Control-Allow-Credentials", "true")
415
424
  self.send_header("Access-Control-Max-Age", "600")
416
- self.send_header("Vary", "Origin")
425
+ self.send_header("Vary", "Origin, Access-Control-Request-Headers")
417
426
  self.send_header("Content-Length", "0")
418
427
  self.end_headers()
419
428
 
429
+ def _allow_headers(self) -> str:
430
+ """The request headers the preflight permits: whatever was asked for.
431
+
432
+ Echoed rather than listed, because a hand-written list is a second place
433
+ to be wrong about someone else's client. It *was* wrong: the list said
434
+ `x-ms-user` where the Azure Data Explorer UI sends `x-ms-user-id`, CORS
435
+ matches header names exactly, and the browser answered by failing the
436
+ real POST with a bare `net::ERR_FAILED` — a preflight that returned 204
437
+ and still blocked the request. Every header the UI adds in future would
438
+ break it the same way.
439
+
440
+ This gives up nothing. Naming a header does not authorise anything; the
441
+ origin allow-list and the loopback bind are what decide who may talk to
442
+ this endpoint, and both are checked before we get here. `*` would be the
443
+ lazy version of this and is not equivalent — it is invalid alongside
444
+ `Allow-Credentials: true`, which is why the exact list is echoed back.
445
+ """
446
+ return self.headers.get("Access-Control-Request-Headers", _DEFAULT_ALLOW_HEADERS)
447
+
420
448
  def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's interface
421
449
  """A human landing page. Anything else 404s."""
422
450
  if not self._is_local():
@@ -146,16 +146,110 @@ def test_an_unknown_route_is_a_404_not_a_crash(server) -> None:
146
146
  # ---------------------------------------------------------------------------
147
147
 
148
148
 
149
+ #: What Chrome asks for on behalf of the Azure Data Explorer UI, captured
150
+ #: verbatim from a `dataexplorer.azure.com` HAR against this server.
151
+ ADX_REQUEST_HEADERS = "authorization,content-type,x-ms-app,x-ms-client-request-id,x-ms-user-id"
152
+
153
+
154
+ def preflight(server: Any, requested: str = ADX_REQUEST_HEADERS, *, origin: str = ALLOWED_ORIGIN):
155
+ request = urllib.request.Request(server.url + "/v1/rest/mgmt", method="OPTIONS")
156
+ request.add_header("Origin", origin)
157
+ request.add_header("Access-Control-Request-Method", "POST")
158
+ request.add_header("Access-Control-Request-Headers", requested)
159
+ try:
160
+ with urllib.request.urlopen(request, timeout=10) as response:
161
+ return response.status, dict(response.headers)
162
+ except urllib.error.HTTPError as exc:
163
+ with exc:
164
+ return exc.status, dict(exc.headers)
165
+
166
+
149
167
  def test_a_preflight_from_the_web_ui_is_allowed(server) -> None:
150
- status, headers, _ = call(server, "/v1/rest/mgmt", method="OPTIONS", origin=ALLOWED_ORIGIN)
168
+ status, headers = preflight(server)
151
169
  assert status == 204
152
170
  assert headers["Access-Control-Allow-Origin"] == ALLOWED_ORIGIN
153
171
  assert "POST" in headers["Access-Control-Allow-Methods"]
154
- # The UI sends these; a preflight that does not list them fails in the
155
- # browser before the real request is ever made.
156
- permitted = headers["Access-Control-Allow-Headers"].lower()
157
- assert "authorization" in permitted
158
- assert "x-ms-client-request-id" in permitted
172
+
173
+
174
+ def test_the_preflight_covers_every_header_the_web_ui_asks_for(server) -> None:
175
+ """The check a browser actually performs, done the way it performs it.
176
+
177
+ A preflight can return 204 and still block the request: the browser compares
178
+ `Access-Control-Request-Headers` against `Access-Control-Allow-Headers` and
179
+ fails the *real* call — as a bare `net::ERR_FAILED`, with nothing in the
180
+ response to point at. That happened, because the allow-list said `x-ms-user`
181
+ where the UI sends `x-ms-user-id`, and asserting "authorization is in the
182
+ string" did not catch it. Set coverage is the assertion that would have.
183
+ """
184
+ _, headers = preflight(server)
185
+ allowed = {h.strip().lower() for h in headers["Access-Control-Allow-Headers"].split(",")}
186
+ asked = [h.strip().lower() for h in ADX_REQUEST_HEADERS.split(",")]
187
+ assert not [h for h in asked if h not in allowed], (
188
+ f"preflight would block the request: {headers['Access-Control-Allow-Headers']}"
189
+ )
190
+
191
+
192
+ def test_a_header_the_ui_adds_later_does_not_break_the_preflight(server) -> None:
193
+ """Echoing is the point: the previous list broke the moment the UI changed."""
194
+ _, headers = preflight(server, "content-type,x-ms-invented-tomorrow")
195
+ allowed = {h.strip().lower() for h in headers["Access-Control-Allow-Headers"].split(",")}
196
+ assert "x-ms-invented-tomorrow" in allowed
197
+
198
+
199
+ def test_the_preflight_never_answers_with_a_wildcard(server) -> None:
200
+ """`*` is invalid next to `Allow-Credentials: true`, so it is not a shortcut.
201
+
202
+ Echoing the requested list is not a wildcard in disguise either — what
203
+ decides who may reach this endpoint is the origin allow-list and the
204
+ loopback bind, both checked before a header name is ever read.
205
+ """
206
+ _, headers = preflight(server)
207
+ assert headers["Access-Control-Allow-Headers"] != "*"
208
+ assert headers["Access-Control-Allow-Origin"] == ALLOWED_ORIGIN
209
+ assert "Access-Control-Request-Headers" in headers.get("Vary", "")
210
+
211
+
212
+ def test_the_preflight_answers_the_private_network_question_only_when_asked(server) -> None:
213
+ """Chrome gates a public page reaching a private address behind this."""
214
+ request = urllib.request.Request(server.url + "/v1/rest/mgmt", method="OPTIONS")
215
+ request.add_header("Origin", ALLOWED_ORIGIN)
216
+ request.add_header("Access-Control-Request-Method", "POST")
217
+ request.add_header("Access-Control-Request-Private-Network", "true")
218
+ with urllib.request.urlopen(request, timeout=10) as response:
219
+ assert response.headers["Access-Control-Allow-Private-Network"] == "true"
220
+
221
+ _, headers = preflight(server)
222
+ assert "Access-Control-Allow-Private-Network" not in headers
223
+
224
+
225
+ def test_the_post_the_web_ui_makes_after_the_preflight_succeeds(server) -> None:
226
+ """The request the browser blocked. End to end, with the UI's own headers."""
227
+ request = urllib.request.Request(
228
+ server.url + "/v1/rest/mgmt",
229
+ data=json.dumps({"csl": ".show version", "properties": None}).encode(),
230
+ headers={
231
+ "Content-Type": "application/json; charset=UTF-8",
232
+ "Accept": "application/json",
233
+ "Origin": ALLOWED_ORIGIN,
234
+ "x-ms-app": "Kusto.Web.KWE:2.259.0-5|embeddedIn:dataexplorer.azure.com",
235
+ "x-ms-client-request-id": "Kusto.Web.KWE.Query;29e3ba3d;740cd2cb",
236
+ "x-ms-user-id": "someone",
237
+ "Authorization": "Bearer ignored",
238
+ },
239
+ )
240
+ with urllib.request.urlopen(request, timeout=10) as response:
241
+ assert response.status == 200
242
+ assert response.headers["Access-Control-Allow-Origin"] == ALLOWED_ORIGIN
243
+ payload = json.loads(response.read())
244
+ assert payload["Tables"][0]["Columns"][0]["ColumnName"] == "BuildVersion"
245
+
246
+
247
+ def test_the_server_header_reports_a_version(server) -> None:
248
+ """It read `duckdb-kql/duckdb-kql` — the cluster name in the version slot."""
249
+ from duckdb_kql import __version__ # noqa: PLC0415
250
+
251
+ _, headers, _ = call(server, "/", method="GET")
252
+ assert headers["Server"].startswith(f"duckdb-kql/{__version__} ")
159
253
 
160
254
 
161
255
  def test_a_preflight_from_anywhere_else_is_refused(server) -> None:
@@ -52,9 +52,10 @@ def test_ci_runs_on_pushes_to_main() -> None:
52
52
  def test_the_ci_matrix_spans_the_supported_range() -> None:
53
53
  """The lanes must be the floor and the ceiling of what we claim to support.
54
54
 
55
- Claiming 3.10–3.13 and testing 3.10–3.11 is how a 3.13-only failure reaches
56
- ``main``: numpy 2.5 ships stubs using PEP 695 syntax and only resolves on
57
- the newer interpreters, so it broke a lane nobody had run locally.
55
+ Claiming a version and never running it is how a version-specific failure
56
+ reaches ``main``: numpy 2.5 ships stubs using PEP 695 syntax that only
57
+ resolves on the newer interpreters, and it broke a lane nobody had run
58
+ locally. So adding a classifier without adding the lane fails here.
58
59
  """
59
60
  from conftest import read_pyproject # noqa: PLC0415
60
61
 
File without changes