diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9353145..97ee1f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,3 +134,20 @@ jobs: with: files: ./coverage.xml fail_ci_if_error: false + + arrow-httpx-027: + name: Arrow transport (HTTPX 0.27) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + version: "latest" + - run: uv python install 3.11 + - run: uv sync --extra dev --extra all + - name: Install compatible legacy transport test dependencies + run: uv pip install --no-deps httpx==0.27.2 pytest-httpx==0.30.0 sniffio==1.3.1 + - name: Verify real HTTPX 0.27 wire compatibility + run: | + uv run --no-sync python -c "import httpx; print(httpx.__version__)" + uv run --no-sync pytest tests/unit/http/test_arrow_metadata.py --no-cov -v --tb=short diff --git a/CHANGELOG.md b/CHANGELOG.md index bc56ffe..5e49e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ plus bug fixes and the first real HTTP-layer test coverage. ### Added +- Arrow completion metadata in the existing `pyarrow.Table` schema for sync and + async queries: policy cap and execution time are retained; failed, malformed, + conflicting or unobservable announced completion metadata is rejected. + Native column types and unrelated schema metadata remain intact. Trailer + capture uses explicitly bounded HTTPX/httpcore/h11 compatibility versions. + Promoted timing headers cannot prove uncapped completion when final trailers + are missing. + + - **MessagePack query** (`query.query_msgpack`, experimental): decodes the columnar MessagePack response with native timestamp handling and returns the same `QueryResponse` shape as `query()` (with per-column Arrow `types`). Raises diff --git a/README.md b/README.md index 961996e..862974b 100644 --- a/README.md +++ b/README.md @@ -242,9 +242,27 @@ pl_df = client.query.query_polars("SELECT * FROM default.cpu LIMIT 1000") ### PyArrow Table (Zero-Copy) ```python -table = client.query.query_arrow("SELECT * FROM default.cpu LIMIT 1000") +table = client.query.query_arrow("SELECT * FROM cpu LIMIT 1000") +metadata = table.schema.metadata or {} +if metadata.get(b"arc.rows_capped") == b"true": + print("Policy capped the result at", int(metadata[b"arc.row_cap"])) ``` +`query_arrow()` still returns a `pyarrow.Table`, with authoritative completion +metadata under reserved schema keys `arc.rows_capped`, `arc.row_cap` and +`arc.execution_time_ms` (byte keys and values). A missing cap flag means unknown; +`false` means the supported server completed its uncapped trailer contract. +Timing promoted into initial headers cannot prove uncapped completion after +final trailers disappear. +Native column types and unrelated schema metadata are preserved. Truncated, +malformed, conflicting or unobservable announced metadata raises `ArcQueryError`. +DataFrame conversions can drop schema metadata; inspect the Arrow table first +when completeness matters. + +Trailer capture is tested with HTTPX 0.27/0.28, httpcore 1.0.9 and h11 0.16. +Those dependency bounds are deliberate: the adapter observes HTTP/1.1 parser +completion without replacing HTTPX's TLS, proxy, timeout or pooling behavior. + ### MessagePack Response (experimental) A compact columnar MessagePack response with native timestamp decoding. Returns @@ -498,3 +516,41 @@ See the [full documentation](https://docs.basekick.net/arc/sdks/python]) for det ## License MIT License - see [LICENSE](LICENSE) for details. + + +## Server compatibility and safe management updates + +The SDK distinguishes partial updates from complete replacements: + +- `update()` sends only supplied non-`None` fields, without fetching a definition. + Use this on servers with partial PUT semantics. Existing `None` arguments remain omitted; + use `from arc_client import CLEAR` to send JSON null for nullable update fields. +- `replace()` sends every mutable field explicitly in one PUT. Arc 26.09.3 uses + full-definition PUT semantics for retention policies and continuous queries, so use + `replace()` on that release. The method also works on newer servers. It intentionally + overwrites the whole definition; coordinate concurrent edits before calling it. + +There is no server-capability guessing or retry with a different update payload. +Both sync and async clients provide the same methods. CQ replacements include +`tag_columns`; retention replacements include `database`, `measurement`, and `buffer_days`. + +Query results retain `truncated`, `truncation_reason`, `rows_capped`, and `row_cap` +when their wire format supplies them. Experimental `query_msgpack()` returns UTC +`datetime` values when a timestamp is exactly representable at microsecond resolution. +Submicrosecond or out-of-range timestamps remain `msgpack.Timestamp`, preserving +`.seconds`, `.nanoseconds`, and `.to_unix_nano()`. Such cells require an explicit +serialization choice when exporting JSON. Arrow completion metadata is available +in the returned table schema as described above. + +DELETE results expose failed, unreachable, unflushed, and cold-tier omission details. +HTTP 207 remains a result that callers must inspect. Non-2xx typed exceptions retain +structured details in `error.response_body`. Data DELETE requests are never automatically +replayed after a server or transport failure, because work may already have committed. + +Automatic 429/503 and ambiguous transport retries apply only to read-only HTTP methods. +Other mutations retry only connection-establishment failures; unknown write outcomes +are surfaced for the caller to reconcile, rather than replayed automatically. + +Query requests already carry the configured database in `x-arc-database`. Use +unqualified measurement names in those SQL requests, for example +`client.query.query("SELECT * FROM cpu LIMIT 10")`. diff --git a/pyproject.toml b/pyproject.toml index 860982e..0b5f2bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,9 @@ classifiers = [ ] dependencies = [ - "httpx>=0.27.0", + "httpx>=0.27.0,<0.29", + "httpcore==1.0.9", + "h11>=0.16.0,<0.17", "msgpack>=1.0.7", "pyarrow>=15.0.0", "pydantic>=2.6.0", diff --git a/src/arc_client/http/arrow_trailers.py b/src/arc_client/http/arrow_trailers.py new file mode 100644 index 0000000..916a694 --- /dev/null +++ b/src/arc_client/http/arrow_trailers.py @@ -0,0 +1,84 @@ +"""Bounded HTTPX/httpcore HTTP/1.1 compatibility adapter for Arrow trailers. + +HTTPX 0.27/0.28 and httpcore 1.0.9 discard h11 EndOfMessage headers. The +response hook runs before body consumption, even if the body is already in +the parser's receive buffer. Only the owning connection's parser is observed; +TLS, proxies, timeouts, framing and pool ownership remain with HTTPX/httpcore. +An unsupported stream shape is detected by the Arrow decoder, never guessed. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional, cast + +import h11 +import httpcore +import httpx + +TRAILERS_EXTENSION = "arc.arrow_trailers" + + +@dataclass +class ArrowTrailers: + headers: list[tuple[bytes, bytes]] = field(default_factory=list) + complete: bool = False + + +class _ObservedState: + """Delegate protocol state unchanged, retaining only the current sink.""" + + def __init__(self, state: Any) -> None: + self._state = state + self.sink: Optional[ArrowTrailers] = None + + def __getattr__(self, name: str) -> Any: + return getattr(self._state, name) + + def next_event(self) -> Any: + event = self._state.next_event() + if isinstance(event, h11.EndOfMessage): + sink, self.sink = self.sink, None + if sink is not None: + sink.headers = list(event.headers.raw_items()) + sink.complete = True + return event + + def start_next_cycle(self) -> None: + self.sink = None + self._state.start_next_cycle() + + +def capture_arrow_trailers(response: httpx.Response) -> None: + """Attach a fresh per-response sink to the supported HTTP/1.1 parser.""" + if ( + response.request.method != "POST" + or response.request.url.path.rstrip("/") != "/api/v1/query/arrow" + ): + return + sink = ArrowTrailers() + response.extensions[TRAILERS_EXTENSION] = sink + # Bound{Sync,Async}Stream -> HTTPX response stream -> PoolByteStream -> + # HTTP11ConnectionByteStream -> owning HTTP11 connection. Deliberately + # bounded to the dependency versions declared in pyproject.toml. + owner: Any = response.stream + for attribute in ("_stream", "_httpcore_stream", "_stream", "_connection"): + owner = getattr(owner, attribute, None) + if owner is None: + return + if not isinstance(owner, (httpcore.HTTP11Connection, httpcore.AsyncHTTP11Connection)): + return + state: Any = owner._h11_state + if isinstance(state, _ObservedState): + observer = state + elif isinstance(state, h11.Connection): + observer = _ObservedState(state) + cast(Any, owner)._h11_state = observer + else: + return + observer.sink = sink + + +async def capture_async_arrow_trailers(response: httpx.Response) -> None: + """Async HTTPX hook; observing parser events itself performs no I/O.""" + capture_arrow_trailers(response) diff --git a/src/arc_client/http/async_http.py b/src/arc_client/http/async_http.py index bb6598c..17d9fee 100644 --- a/src/arc_client/http/async_http.py +++ b/src/arc_client/http/async_http.py @@ -8,6 +8,7 @@ import httpx from arc_client.config import ClientConfig +from arc_client.http.arrow_trailers import capture_async_arrow_trailers from arc_client.http.base import ( RETRYABLE_TRANSPORT_ERRORS, HTTPClientBase, @@ -33,6 +34,7 @@ def _get_client(self) -> httpx.AsyncClient: base_url=self._base_url, timeout=self.config.timeout, verify=self.config.verify_ssl, + event_hooks={"response": [capture_async_arrow_trailers]}, ) return self._client diff --git a/src/arc_client/http/sync_http.py b/src/arc_client/http/sync_http.py index 2e46e52..49ac570 100644 --- a/src/arc_client/http/sync_http.py +++ b/src/arc_client/http/sync_http.py @@ -8,6 +8,7 @@ import httpx from arc_client.config import ClientConfig +from arc_client.http.arrow_trailers import capture_arrow_trailers from arc_client.http.base import ( RETRYABLE_TRANSPORT_ERRORS, HTTPClientBase, @@ -33,6 +34,7 @@ def _get_client(self) -> httpx.Client: base_url=self._base_url, timeout=self.config.timeout, verify=self.config.verify_ssl, + event_hooks={"response": [capture_arrow_trailers]}, ) return self._client diff --git a/src/arc_client/query/arrow_response.py b/src/arc_client/query/arrow_response.py new file mode 100644 index 0000000..de8f50e --- /dev/null +++ b/src/arc_client/query/arrow_response.py @@ -0,0 +1,89 @@ +"""Decode native Arrow data and authoritative completion metadata.""" + +from __future__ import annotations + +import math +import re + +import httpx +import pyarrow as pa +import pyarrow.ipc as ipc + +from arc_client.exceptions import ArcQueryError +from arc_client.http.arrow_trailers import TRAILERS_EXTENSION, ArrowTrailers + +_CAP = b"arc-rows-capped" +_TRUNCATED = b"arc-stream-truncated" +_TIME = b"arc-execution-time-ms" +_FIELDS = {_CAP, _TRUNCATED, _TIME} +_RESERVED = {b"arc.rows_capped", b"arc.row_cap", b"arc.execution_time_ms"} + + +def decode_arrow_response(response: httpx.Response) -> pa.Table: + """Keep the Table API and record server metadata in its schema. + + Missing legacy metadata is unknown. An announced Arc trailer requires a + completed capture; malformed or truncated responses never become tables. + """ + if "application/json" in response.headers.get("content-type", ""): + raise ArcQueryError(response.json().get("error", "Query failed with unknown error")) + + announced = { + name.strip().lower().encode("ascii") + for value in response.headers.get_list("trailer") + for name in value.split(",") + } + sink = response.extensions.get(TRAILERS_EXTENSION) + complete = isinstance(sink, ArrowTrailers) and sink.complete + if announced & _FIELDS and not complete: + raise ArcQueryError( + "Arrow completion metadata could not be captured by this HTTP transport" + ) + + values: dict[bytes, bytes] = {} + fields = list(response.headers.raw) + final_timing = False + if isinstance(sink, ArrowTrailers) and sink.complete: + fields.extend(sink.headers) + final_timing = any(name.lower() == _TIME and value.strip() for name, value in sink.headers) + for name, value in fields: + name, value = name.lower(), value.strip() + if name not in _FIELDS or not value: + continue + if name in values and values[name] != value: + raise ArcQueryError("Conflicting Arrow completion metadata") + values[name] = value + + if _TRUNCATED in values: + reason = values[_TRUNCATED].decode("utf-8", errors="replace") + raise ArcQueryError("Arrow stream truncated: " + reason) + + metadata: dict[bytes, bytes] = {} + if _CAP in values: + cap = values[_CAP] + if not re.fullmatch(rb"[0-9]{1,19}", cap) or not 0 < int(cap) <= 2**63 - 1: + raise ArcQueryError("Invalid Arrow row-cap metadata") + metadata[b"arc.rows_capped"] = b"true" + metadata[b"arc.row_cap"] = str(int(cap)).encode("ascii") + elif complete and _CAP in announced and final_timing: + # Both supported Arc versions announce this trailer on every result; + # an empty (release) or absent (main) cap means uncapped. Require the + # always-sent execution marker in FINAL trailers too: a gateway that + # promotes timing but drops trailers cannot prove uncapped completion. + metadata[b"arc.rows_capped"] = b"false" + if _TIME in values: + try: + elapsed = float(values[_TIME]) + except ValueError as error: + raise ArcQueryError("Invalid Arrow execution-time metadata") from error + if not math.isfinite(elapsed) or elapsed < 0: + raise ArcQueryError("Invalid Arrow execution-time metadata") + metadata[b"arc.execution_time_ms"] = values[_TIME] + + with ipc.open_stream(response.content) as reader: + table = reader.read_all() + # These names are SDK-reserved. Wire completion metadata takes precedence + # over schema content, including when the server supplied no cap signal. + schema_metadata = {k: v for k, v in (table.schema.metadata or {}).items() if k not in _RESERVED} + schema_metadata.update(metadata) + return table.replace_schema_metadata(schema_metadata or None) diff --git a/src/arc_client/query/async_executor.py b/src/arc_client/query/async_executor.py index 692b9b2..ff06fcb 100644 --- a/src/arc_client/query/async_executor.py +++ b/src/arc_client/query/async_executor.py @@ -5,12 +5,12 @@ from typing import TYPE_CHECKING, Any, Optional import pyarrow as pa -import pyarrow.ipc as ipc from arc_client.config import ClientConfig from arc_client.exceptions import ArcNotSupportedError, ArcQueryError, ArcValidationError from arc_client.http.async_http import AsyncHTTPClient from arc_client.models.query import EstimateResponse, MeasurementInfo, QueryResponse +from arc_client.query.arrow_response import decode_arrow_response from arc_client.query.msgpack_response import decode_msgpack_query_response from arc_client.validation import validate_name @@ -196,7 +196,9 @@ async def query_arrow(self, sql: str, database: Optional[str] = None) -> pa.Tabl database: Target database. Uses client default if not specified. Returns: - pyarrow.Table with query results. + pyarrow.Table with query results and completion metadata in + its schema: arc.rows_capped, arc.row_cap, arc.execution_time_ms. + Missing cap metadata means unknown. Truncation raises ArcQueryError. Raises: ArcQueryError: If the query fails. @@ -218,15 +220,7 @@ async def query_arrow(self, sql: str, database: Optional[str] = None) -> pa.Tabl headers=headers, ) - # Check if we got JSON error response instead of Arrow - content_type = response.headers.get("content-type", "") - if "application/json" in content_type: - error_data = response.json() - raise ArcQueryError(error_data.get("error", "Query failed with unknown error")) - - # Parse Arrow IPC stream - reader = ipc.open_stream(response.content) - return reader.read_all() + return decode_arrow_response(response) except ArcQueryError: raise diff --git a/src/arc_client/query/executor.py b/src/arc_client/query/executor.py index 9941e07..c9c64c1 100644 --- a/src/arc_client/query/executor.py +++ b/src/arc_client/query/executor.py @@ -5,12 +5,12 @@ from typing import TYPE_CHECKING, Any, Optional import pyarrow as pa -import pyarrow.ipc as ipc from arc_client.config import ClientConfig from arc_client.exceptions import ArcNotSupportedError, ArcQueryError, ArcValidationError from arc_client.http.sync_http import SyncHTTPClient from arc_client.models.query import EstimateResponse, MeasurementInfo, QueryResponse +from arc_client.query.arrow_response import decode_arrow_response from arc_client.query.msgpack_response import decode_msgpack_query_response from arc_client.validation import validate_name @@ -211,7 +211,9 @@ def query_arrow(self, sql: str, database: Optional[str] = None) -> pa.Table: database: Target database. Uses client default if not specified. Returns: - pyarrow.Table with query results. + pyarrow.Table with query results and completion metadata in + its schema: arc.rows_capped, arc.row_cap, arc.execution_time_ms. + Missing cap metadata means unknown. Truncation raises ArcQueryError. Raises: ArcQueryError: If the query fails. @@ -238,15 +240,7 @@ def query_arrow(self, sql: str, database: Optional[str] = None) -> pa.Table: headers=headers, ) - # Check if we got JSON error response instead of Arrow - content_type = response.headers.get("content-type", "") - if "application/json" in content_type: - error_data = response.json() - raise ArcQueryError(error_data.get("error", "Query failed with unknown error")) - - # Parse Arrow IPC stream - reader = ipc.open_stream(response.content) - return reader.read_all() + return decode_arrow_response(response) except ArcQueryError: raise diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..caac9df --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1 @@ +The self-signed localhost certificate and key are public test fixtures. They are used only by the SDK loopback TLS tests, and must never be used for a deployment. diff --git a/tests/fixtures/localhost-cert.pem b/tests/fixtures/localhost-cert.pem new file mode 100644 index 0000000..49cd593 --- /dev/null +++ b/tests/fixtures/localhost-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUJqcHZZftPHtgzYG8++TMxRzCzc4wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyODAwNDMwOFoXDTM2MTAy +NTAwNDMwOFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA1M5IxoiF0S+0mzB1ImQTqEhyYYpjpVv5HglZmJXzg0Ub +EL7c1YY+CFvgk9xPMjZTG3YBkThHZQClW1qPc3jT45YWKCjCNMmeFuC5sG/lIsTJ +Z8mjjds0KVojiOh/PhhQrYXZ+Sfdn4YwDOL9BKCMu4lFLms9+LFYfu6LpAhWJ9G6 +AL9kKCU12JyLDY8O0fowxDz5k19UDURHHamPeFwCPkz31MhcPWaueJ0bRUaB6QHO +dFq1oMGmoJKs5mfnp9owP/k06caz0/g8f0jvPVn7vFCUzL7vQE8H+nOqDtpx9NQV +mI+fjY0mfLHgl4I7R4bXD9ifx2Fb0fq2p1mokhGDjwIDAQABo28wbTAdBgNVHQ4E +FgQU6QqpovpIguq2834q0/xBw8y+MTcwHwYDVR0jBBgwFoAU6QqpovpIguq2834q +0/xBw8y+MTcwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAGp2ayJjEhq4AEswOC3FnRgpVLrSqu71 +3ZkRGVjQI8vjbv6rIHOFdIqSLR2nibT9gg2yVQNksS1DZF0PK4IZAXg3X2zNMfbr +elh0meLguhWH27pQigo74zahHicFcm4AZZiaudBp5mGPxdxjVO5N4SMEwpPsLbX3 +BrBV0+HENSdaU+Dcuw5mqHa1Z92NJ541mwJEHouS0gWVRjQeirmRnwNtV4dw8gJO +QmJCdNrQvhvCQG6Tz5sLKmrtTn0atnBYpBt4NL6f0CtsHc7LPVU+K8GqmwBdnU4M +WLkIg/yhCQijD8cJm9kcl5cg7w77T8JCBy4K0/zkiqbGg8quXRu/ElQ= +-----END CERTIFICATE----- diff --git a/tests/fixtures/localhost-key.pem b/tests/fixtures/localhost-key.pem new file mode 100644 index 0000000..9a398ff --- /dev/null +++ b/tests/fixtures/localhost-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDUzkjGiIXRL7Sb +MHUiZBOoSHJhimOlW/keCVmYlfODRRsQvtzVhj4IW+CT3E8yNlMbdgGROEdlAKVb +Wo9zeNPjlhYoKMI0yZ4W4Lmwb+UixMlnyaON2zQpWiOI6H8+GFCthdn5J92fhjAM +4v0EoIy7iUUuaz34sVh+7oukCFYn0boAv2QoJTXYnIsNjw7R+jDEPPmTX1QNREcd +qY94XAI+TPfUyFw9Zq54nRtFRoHpAc50WrWgwaagkqzmZ+en2jA/+TTpxrPT+Dx/ +SO89Wfu8UJTMvu9ATwf6c6oO2nH01BWYj5+NjSZ8seCXgjtHhtcP2J/HYVvR+ran +WaiSEYOPAgMBAAECggEAFNcGxXqFMFEtrKz4K6AFVMuTd/3neEAji097GLhGmqpf +1in60mZNGOB2g8RldRNg3CWYTbJnO3rutp2Nws8oYPdHwDMNocHE25SyWpb6p4cB ++koC3FeFKi+Ln1K1c01KSVP8XqwKxh6MAhcQlC1s2si8n6kc7offlL6jqjKuzUNM +42acE0Bw6dsoaQPZobrQlrR9dG/ofkk1RikqfCtWGDoe6KsEDXtGyWOKcUQG/HHg +z7CkFhuQjNDGFFKCr6houF4HVccNMzmc8MzbXiUWcFdIGyPYnZwjZ5tvxV619GzU +RA9YMVc/kr+lRAz307sTRjJfFMmBDwKaKfDWV//hEQKBgQDz6E9OkTTkwHhZCxp3 +OeYkxP2OM8T2C5SDtL/HD5UKqfmCIF7i3Kiy5e8ZVZNp1Qa946eeyeXL0qw9gM3M +cTtLdwZ7hn9Fijamb6UZvhIRk/XYd+Ii8Z+HCcFI5ruSvk22v0uf7NFxXMmF8hHG +k93FisexG4YpjeLXNf/0j/454wKBgQDfWzrlF2/KqRdC1dm4zCdrbYjOf2ENGzoI +M5sEvwWUmGLPqQHRHHseujxBFiH43fAqRSyEQvf9QJCPlR/t+U8uyShYY2xT83x+ +bld7Y/q/lIqVTeyGC/aLFPYN1rrwo3eVnnyMpNL3LdHbViIKkoR9uX4sh+8RDw5V +K4G9OowvZQKBgQCiGAdsrW+7fnX8pzZleeMLGKdxc9I3uiZvCRZSknZ4adHZuu2U +UxUCH3ExIwAe22XpmHvzOTYwqMey8ADVF4NSeb2jJhU6/8ml/ph4bSrQ58YAoFt2 +4aElO0Rq0Hxj33kCNEtr8xSWBBYhoGhT0j0WZU6k0yfsQdE5OTfrLi7EBQKBgAnq +5RyTA9W92FiRYk+AUSfF/1Mb+NU6JkOC9xko2jiU455Q5NHCL/1XS6GgN/Xy9N0L +oURmLNjD6ydU+cv5S/jJ2c7C62Z240pB4ASMle+3yseCZEQ3d8UJMzu+EyLoP0EX +fnRIRU3QbQcONgi+JL/oYp/bkVPylTJW8jXSybK5AoGAeVr3Ko2mEypjwcK6j1gA +nxx5EjlG+EiWvYflthrK0jNcjEUBncN3PE/rJf2q0qviwDanz5gvVgyKuWz4ay6J +Zh97QLYOZ3atLUGpctRKHqbKlSB/BOqty+eaI3tRUVkXMCDnj+60A3jYSl2eKVzb +c8HnJ+QUXn6J5nMfFTDhkzw= +-----END PRIVATE KEY----- diff --git a/tests/integration/test_contract_compatibility.py b/tests/integration/test_contract_compatibility.py new file mode 100644 index 0000000..7aa360e --- /dev/null +++ b/tests/integration/test_contract_compatibility.py @@ -0,0 +1,195 @@ +"""Opt-in compatibility checks against isolated released and partial-PUT servers.""" + +from __future__ import annotations + +import inspect +import os +import time +import uuid +from contextlib import AsyncExitStack +from urllib.parse import urlparse + +import pytest + +from arc_client import CLEAR, ArcClient, AsyncArcClient +from arc_client.exceptions import ArcError + +SERVERS = [ + ("release", os.environ.get("ARC_COMPAT_RELEASE_URL", "")), + ("main", os.environ.get("ARC_COMPAT_MAIN_URL", "")), +] + + +async def result(value): + return await value if inspect.isawaitable(value) else value + + +@pytest.fixture(params=SERVERS, ids=["released-full-PUT", "main-partial-PUT"]) +def target(request): + name, url = request.param + if not url: + pytest.skip("Set both ARC_COMPAT_RELEASE_URL and ARC_COMPAT_MAIN_URL for Docker checks") + return name, urlparse(url) + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def async_mode(request): + return request.param + + +async def client_for(stack, target, async_mode, token=None): + _, url = target + kwargs = { + "host": url.hostname, + "port": url.port or 8000, + "token": token if token is not None else os.environ["ARC_TEST_TOKEN"], + "max_retries": 0, + } + if async_mode: + return await stack.enter_async_context(AsyncArcClient(**kwargs)) + return stack.enter_context(ArcClient(**kwargs)) + + +async def test_retention_lifecycle(target, async_mode): + name = "sdkc_" + uuid.uuid4().hex[:10] + async with AsyncExitStack() as stack: + c = await client_for(stack, target, async_mode) + policy = await result( + c.retention.create( + name=name, + database="default", + retention_days=30, + measurement="sdkc_cpu", + buffer_days=0, + is_active=False, + ) + ) + try: + assert policy.measurement == "sdkc_cpu" and not policy.is_active + assert any(p.id == policy.id for p in await result(c.retention.list())) + assert await result(c.retention.get_executions(policy.id)) == [] + replaced = await result( + c.retention.replace( + policy.id, + name=name, + database="default", + retention_days=60, + measurement="sdkc_cpu", + buffer_days=0, + is_active=False, + ) + ) + assert replaced.retention_days == 60 and replaced.buffer_days == 0 + if target[0] == "main": + patched = await result( + c.retention.update(policy.id, measurement=CLEAR, is_active=False) + ) + assert patched.measurement is None and patched.retention_days == 60 + else: + with pytest.raises(ArcError): + await result(c.retention.update(policy.id, retention_days=90)) + unchanged = await result(c.retention.get(policy.id)) + assert unchanged.retention_days == 60 and unchanged.measurement == "sdkc_cpu" + finally: + await result(c.retention.delete(policy.id)) + + +async def test_cq_lifecycle(target, async_mode): + name = "sdkc_" + uuid.uuid4().hex[:10] + sql = ( + "SELECT time_bucket(INTERVAL '1 hour', time) AS time, host, avg(value) AS avg_value " + "FROM default.sdkc_cpu WHERE time >= {start_time} AND time < {end_time} " + "GROUP BY time, host" + ) + args = { + "name": name, + "database": "default", + "source_measurement": "sdkc_cpu", + "destination_measurement": "sdkc_rollup", + "query": sql, + "interval": "1h", + "description": "keep", + "retention_days": 30, + "delete_source_after_days": 60, + "is_active": False, + "tag_columns": ["host"], + } + async with AsyncExitStack() as stack: + c = await client_for(stack, target, async_mode) + cq = await result(c.continuous_queries.create(**args)) + try: + assert cq.query == sql and cq.tag_columns == ["host"] + assert any(q.id == cq.id for q in await result(c.continuous_queries.list())) + assert await result(c.continuous_queries.get_executions(cq.id)) == [] + args["interval"] = "30m" + replaced = await result(c.continuous_queries.replace(cq.id, **args)) + assert replaced.interval == "30m" and replaced.tag_columns == ["host"] + if target[0] == "main": + patched = await result( + c.continuous_queries.update( + cq.id, description=CLEAR, tag_columns=CLEAR, is_active=False + ) + ) + assert patched.description is None and patched.tag_columns is None + assert patched.query == sql and patched.interval == "30m" + else: + with pytest.raises(ArcError): + await result(c.continuous_queries.update(cq.id, interval="15m")) + unchanged = await result(c.continuous_queries.get(cq.id)) + assert unchanged.interval == "30m" and unchanged.query == sql + finally: + await result(c.continuous_queries.delete(cq.id)) + + +async def test_json_msgpack_integer_null_fidelity(target, async_mode): + sql = ( + "SELECT 9223372036854775807::BIGINT AS hi, " + "CAST('-9223372036854775808' AS BIGINT) AS lo, NULL::BIGINT AS empty" + ) + async with AsyncExitStack() as stack: + c = await client_for(stack, target, async_mode) + json_result = await result(c.query.query(sql)) + packed = await result(c.query.query_msgpack(sql)) + assert ( + json_result.data == packed.data == [[9223372036854775807, -9223372036854775808, None]] + ) + assert not json_result.rows_capped and json_result.row_cap is None + assert not packed.rows_capped and packed.row_cap is None + + +async def test_columnar_ingest_query_and_delete_dry_run(target, async_mode): + measurement = "sdkc_" + uuid.uuid4().hex[:10] + stamp = int(time.time() * 1_000_000) + async with AsyncExitStack() as stack: + c = await client_for(stack, target, async_mode) + await result( + c.write.write_columnar( + measurement, {"time": [stamp, stamp + 1], "value": [1, 2], "host": ["a", "b"]} + ) + ) + rows = None + # Arc acknowledges ingestion before its default five-second disk flush. + # Wait for visibility without weakening the row-integrity assertion. + for _ in range(120): + try: + rows = await result( + c.query.query(f"SELECT value FROM {measurement} ORDER BY value") + ) + if rows.data == [[1], [2]]: + break + except ArcError: + pass + await __import__("asyncio").sleep(0.1) + assert rows is not None and rows.data == [[1], [2]] + dry = await result(c.delete.delete("default", measurement, "value < 0", dry_run=True)) + assert dry.dry_run and dry.deleted_count == 0 + assert dry.failed_files == [] + + +async def test_auth_failure_retains_body(target, async_mode): + async with AsyncExitStack() as stack: + c = await client_for(stack, target, async_mode, token="invalid-local-test-token") + with pytest.raises(ArcError) as failure: + await result(c.retention.list()) + assert isinstance(failure.value.response_body, dict) + assert "error" in failure.value.response_body diff --git a/tests/integration/test_live.py b/tests/integration/test_live.py index 3beea66..0bb11f9 100644 --- a/tests/integration/test_live.py +++ b/tests/integration/test_live.py @@ -1,7 +1,9 @@ """Opt-in integration tests against a live Arc server. These run only when ``ARC_TEST_URL`` (and usually ``ARC_TEST_TOKEN``) are set; -otherwise every test is skipped. They exercise the new 0.2.0 surface end-to-end. +otherwise every test is skipped. Set ``ARC_TEST_GOVERNANCE=disabled`` only +for a target without query governance; licensed targets must exercise it. +They exercise the new 0.2.0 surface end-to-end. Run with, e.g.:: @@ -16,11 +18,12 @@ import contextlib import os import time +import uuid import pytest from arc_client import ArcClient -from arc_client.exceptions import ArcLicenseError, ArcNotSupportedError +from arc_client.exceptions import ArcLicenseError, ArcNotFoundError, ArcNotSupportedError pytestmark = pytest.mark.skipif( not os.environ.get("ARC_TEST_URL"), @@ -36,21 +39,27 @@ def test_health_and_ready(sync_client: ArcClient) -> None: def test_columnar_write_then_query_msgpack(sync_client: ArcClient) -> None: base = int(time.time() * 1_000_000) - measurement = "sdk_it_cpu" + measurement = "sdk_it_cpu_" + uuid.uuid4().hex[:10] sync_client.write.write_columnar( measurement, {"time": [base, base + 1_000_000], "host": ["a", "b"], "usage": [1.0, 2.0]}, ) - # Give the server a moment to flush. - time.sleep(1.0) - - try: - result = sync_client.query.query_msgpack(f"SELECT * FROM {measurement} ORDER BY time") - except ArcNotSupportedError: - pytest.skip("Server built without the duckdb_arrow build tag") + # Writes acknowledge buffering; the default server flush interval is five seconds. + deadline = time.monotonic() + 12 + while True: + try: + result = sync_client.query.query_msgpack( + f"SELECT host, usage FROM {measurement} ORDER BY time" + ) + except ArcNotSupportedError: + pytest.skip("Server built without the duckdb_arrow build tag") + if result.row_count == 2 or time.monotonic() >= deadline: + break + time.sleep(0.25) assert result.success - assert "usage" in result.columns + assert result.columns == ["host", "usage"] + assert result.data == [["a", 1.0], ["b", 2.0]] def test_databases_lifecycle(sync_client: ArcClient) -> None: @@ -79,5 +88,9 @@ def test_enterprise_governance_usage_or_license_error(sync_client: ArcClient) -> # license/permission error rather than a crash. try: sync_client.governance.list_policies() - except ArcLicenseError: - pytest.skip("Server has no query_governance license feature (expected on OSS)") + except (ArcLicenseError, ArcNotFoundError): + # OSS may omit the route entirely. Only an explicitly unlicensed target + # may skip: a missing/broken route on a licensed target must still fail. + if os.environ.get("ARC_TEST_GOVERNANCE") != "disabled": + raise + pytest.skip("Governance explicitly disabled for this test target") diff --git a/tests/unit/http/test_arrow_metadata.py b/tests/unit/http/test_arrow_metadata.py new file mode 100644 index 0000000..64236b7 --- /dev/null +++ b/tests/unit/http/test_arrow_metadata.py @@ -0,0 +1,501 @@ +"""Arrow metadata must survive the real HTTP/1.1 trailer boundary.""" + +from __future__ import annotations + +import asyncio +import contextlib +import select +import socket +import socketserver +import ssl +import threading +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from urllib.parse import urlsplit + +import pyarrow as pa +import pyarrow.ipc as ipc +import pytest + +from arc_client import ArcClient, AsyncArcClient +from arc_client.exceptions import ArcQueryError + +# Synthetic localhost-only TLS fixture; never used outside these tests. +CERT = Path(__file__).parents[2] / "fixtures/localhost-cert.pem" +KEY = Path(__file__).parents[2] / "fixtures/localhost-key.pem" + + +def arrow_bytes(values=(1, 2), metadata=None): + table = pa.table({"value": pa.array(values, type=pa.int64())}).replace_schema_metadata(metadata) + sink = pa.BufferOutputStream() + with ipc.new_stream(sink, table.schema) as writer: + writer.write_table(table) + return sink.getvalue().to_pybytes() + + +def wire(body, trailers=b"Arc-Rows-Capped: 2\r\nArc-Execution-Time-Ms: 7\r\n", announce=True): + headers = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/vnd.apache.arrow.stream\r\n" + b"Transfer-Encoding: chunked\r\n" + ) + if announce: + headers += b"Trailer: Arc-Rows-Capped, Arc-Stream-Truncated, Arc-Execution-Time-Ms\r\n" + return ( + headers + b"\r\n" + f"{len(body):x}\r\n".encode() + body + b"\r\n0\r\n" + trailers + b"\r\n" + ) + + +@contextlib.contextmanager +def server(replies, *, fragment=False, tls=False, close=False): + lock = threading.Lock() + seen = [] + + class Handler(socketserver.StreamRequestHandler): + def handle(self): + while True: + line = self.rfile.readline() + if not line: + return + headers = {} + while True: + h = self.rfile.readline() + if h == b"\r\n": + break + k, v = h.split(b":", 1) + headers[k.lower()] = v.strip() + self.rfile.read(int(headers.get(b"content-length", b"0"))) + with lock: + seen.append((line, self.client_address, headers)) + reply = replies.pop(0) + if callable(reply): + reply = reply() + try: + if fragment: + for start in range(0, len(reply), 3): + self.wfile.write(reply[start : start + 3]) + self.wfile.flush() + else: + for piece in [reply] if isinstance(reply, bytes) else reply: + self.wfile.write(piece) + self.wfile.flush() + if close: + return + except (BrokenPipeError, ConnectionResetError): + return + + class TCP(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + srv = TCP(("127.0.0.1", 0), Handler) + if tls: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(CERT, KEY) + srv.socket = ctx.wrap_socket(srv.socket, server_side=True) + thread = threading.Thread(target=srv.serve_forever, daemon=True) + thread.start() + try: + yield srv.server_address[1], seen + finally: + srv.shutdown() + srv.server_close() + thread.join() + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("fragment", [False, True]) +async def test_capped_native_arrow_and_schema_metadata(async_mode, fragment): + body = arrow_bytes((9223372036854775807, None), {b"application": b"preserved"}) + with server([wire(body)], fragment=fragment) as (port, seen): + if async_mode: + async with AsyncArcClient(host="127.0.0.1", port=port, token="synthetic") as c: + t = await c.query.query_arrow("SELECT value") + else: + with ArcClient(host="127.0.0.1", port=port, token="synthetic") as c: + t = c.query.query_arrow("SELECT value") + assert isinstance(t, pa.Table) + assert t.column("value").to_pylist() == [9223372036854775807, None] + assert t.schema.metadata[b"application"] == b"preserved" + assert t.schema.metadata[b"arc.rows_capped"] == b"true" + assert t.schema.metadata[b"arc.row_cap"] == b"2" + assert t.schema.metadata[b"arc.execution_time_ms"] == b"7" + assert len(seen) == 1 + assert seen[0][2][b"authorization"] == b"Bearer synthetic" + + +@pytest.mark.parametrize("async_mode", [False, True]) +async def test_keepalive_does_not_leak_cap(async_mode): + with server( + [ + wire(arrow_bytes()), + wire(arrow_bytes(), b"Arc-Rows-Capped: \r\nArc-Execution-Time-Ms: 0\r\n"), + ] + ) as ( + port, + seen, + ): + if async_mode: + async with AsyncArcClient(host="127.0.0.1", port=port) as c: + a = await c.query.query_arrow("SELECT capped") + b = await c.query.query_arrow("SELECT uncapped") + else: + with ArcClient(host="127.0.0.1", port=port) as c: + a = c.query.query_arrow("SELECT capped") + b = c.query.query_arrow("SELECT uncapped") + assert a.schema.metadata[b"arc.row_cap"] == b"2" + assert b.schema.metadata[b"arc.rows_capped"] == b"false" + assert b"arc.row_cap" not in b.schema.metadata + assert a.schema.metadata[b"arc.row_cap"] == b"2" + assert seen[0][1] == seen[1][1] + + +@pytest.mark.parametrize( + "trailer", + [ + b"Arc-Rows-Capped: nope\r\n", + b"Arc-Rows-Capped: -2\r\n", + b"Arc-Rows-Capped: 0\r\n", + b"Arc-Rows-Capped: 2\r\nArc-Rows-Capped: 3\r\n", + b"Arc-Stream-Truncated: cancelled\r\n", + b"Arc-Execution-Time-Ms: NaN\r\n", + ], +) +@pytest.mark.parametrize("async_mode", [False, True]) +async def test_invalid_or_failed_arrow_never_returns_table(trailer, async_mode): + with server([wire(arrow_bytes(), trailer)]) as (port, seen): + if async_mode: + async with AsyncArcClient(host="127.0.0.1", port=port, max_retries=3) as c: + with pytest.raises(ArcQueryError): + await c.query.query_arrow("SELECT value") + else: + with ( + ArcClient(host="127.0.0.1", port=port, max_retries=3) as c, + pytest.raises(ArcQueryError), + ): + c.query.query_arrow("SELECT value") + assert len(seen) == 1 + + +def test_legacy_without_metadata_does_not_guess_cap(): + body = arrow_bytes(metadata={b"arc.row_cap": b"999", b"application": b"preserved"}) + with ( + server([wire(body, b"", announce=False)]) as (port, _), + ArcClient(host="127.0.0.1", port=port) as c, + ): + t = c.query.query_arrow("SELECT value LIMIT 2") + assert b"arc.rows_capped" not in (t.schema.metadata or {}) + assert b"arc.row_cap" not in (t.schema.metadata or {}) + assert t.schema.metadata[b"application"] == b"preserved" + + +@pytest.mark.parametrize("async_mode", [False, True]) +async def test_concurrent_metadata_is_per_response(async_mode): + replies = [ + wire(arrow_bytes(), b"Arc-Rows-Capped: 2\r\n"), + wire(arrow_bytes((3,)), b"Arc-Rows-Capped: 1\r\n"), + ] + with server(replies) as (port, _): + if async_mode: + async with AsyncArcClient(host="127.0.0.1", port=port) as c: + tables = await asyncio.gather( + c.query.query_arrow("SELECT a"), c.query.query_arrow("SELECT b") + ) + else: + with ArcClient(host="127.0.0.1", port=port) as c, ThreadPoolExecutor(2) as pool: + tables = list(pool.map(c.query.query_arrow, ["SELECT a", "SELECT b"])) + for t in tables: + assert int(t.schema.metadata[b"arc.row_cap"]) == t.num_rows + + +async def test_async_cancellation_then_query_has_fresh_metadata(): + entered = threading.Event() + release = threading.Event() + + def blocked(): + entered.set() + release.wait(5) + return wire(arrow_bytes(), b"Arc-Rows-Capped: 999\r\n") + + with server([blocked, wire(arrow_bytes((1,)), b"Arc-Rows-Capped: 1\r\n")]) as (port, _): + async with AsyncArcClient(host="127.0.0.1", port=port) as c: + task = asyncio.create_task(c.query.query_arrow("SELECT blocked")) + try: + assert await asyncio.to_thread(entered.wait, 3) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + t = await c.query.query_arrow("SELECT fresh") + assert t.schema.metadata[b"arc.row_cap"] == b"1" + finally: + release.set() + + +@pytest.mark.parametrize("async_mode", [False, True]) +async def test_unobservable_announced_metadata_fails_closed(httpx_mock, async_mode): + httpx_mock.add_response(content=arrow_bytes(), headers={"Trailer": "Arc-Rows-Capped"}) + if async_mode: + async with AsyncArcClient(host="h", max_retries=0) as c: + with pytest.raises(ArcQueryError, match="could not be captured"): + await c.query.query_arrow("SELECT value") + else: + with ( + ArcClient(host="h", max_retries=0) as c, + pytest.raises(ArcQueryError, match="could not be captured"), + ): + c.query.query_arrow("SELECT value") + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.parametrize("async_mode", [False, True]) +async def test_gateway_promoted_headers(httpx_mock, async_mode): + httpx_mock.add_response( + content=arrow_bytes(), headers={"Arc-Rows-Capped": "2", "Arc-Execution-Time-Ms": "0"} + ) + if async_mode: + async with AsyncArcClient(host="h") as c: + t = await c.query.query_arrow("SELECT value") + else: + with ArcClient(host="h") as c: + t = c.query.query_arrow("SELECT value") + assert t.schema.metadata[b"arc.row_cap"] == b"2" + assert t.schema.metadata[b"arc.execution_time_ms"] == b"0" + + +def test_header_trailer_conflict_is_rejected(): + reply = wire(arrow_bytes()).replace( + b"Transfer-Encoding:", b"Arc-Rows-Capped: 3\r\nTransfer-Encoding:" + ) + with ( + server([reply]) as (port, _), + ArcClient(host="127.0.0.1", port=port) as c, + pytest.raises(ArcQueryError, match="Conflicting"), + ): + c.query.query_arrow("SELECT value") + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("bad_body", [True, False]) +async def test_poisoned_or_incomplete_stream_is_rejected(async_mode, bad_body): + if bad_body: + body = arrow_bytes()[:-8] + b"\xff\xff\xff\xff\x40\x00\x00\x00\x00" + reply = wire(body, b"") + else: + reply = wire(arrow_bytes())[:-30] + with server([reply], close=True) as (port, seen): + if async_mode: + async with AsyncArcClient(host="127.0.0.1", port=port, max_retries=3) as c: + with pytest.raises(ArcQueryError): + await c.query.query_arrow("SELECT value") + else: + with ( + ArcClient(host="127.0.0.1", port=port, max_retries=3) as c, + pytest.raises(ArcQueryError), + ): + c.query.query_arrow("SELECT value") + assert len(seen) == 1 + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("verify", [True, False]) +async def test_tls_validation_is_preserved(async_mode, verify, monkeypatch): + # Explicitly trust the fixture CA; verify=False must remain opt-out too. + monkeypatch.setenv("SSL_CERT_FILE", str(CERT)) + with server([wire(arrow_bytes())], tls=True) as (port, _): + if async_mode: + async with AsyncArcClient( + host="127.0.0.1", port=port, ssl=True, verify_ssl=verify + ) as c: + t = await c.query.query_arrow("SELECT value") + else: + with ArcClient(host="127.0.0.1", port=port, ssl=True, verify_ssl=verify) as c: + t = c.query.query_arrow("SELECT value") + assert t.schema.metadata[b"arc.row_cap"] == b"2" + + +@pytest.mark.parametrize("async_mode", [False, True]) +async def test_untrusted_tls_certificate_still_fails(async_mode, monkeypatch): + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + with server([], tls=True) as (port, seen): + if async_mode: + async with AsyncArcClient(host="127.0.0.1", port=port, ssl=True, max_retries=0) as c: + with pytest.raises(ArcQueryError): + await c.query.query_arrow("SELECT value") + else: + with ( + ArcClient(host="127.0.0.1", port=port, ssl=True, max_retries=0) as c, + pytest.raises(ArcQueryError), + ): + c.query.query_arrow("SELECT value") + assert not seen + + +@contextlib.contextmanager +def proxy(): + requests = [] + + class Handler(socketserver.StreamRequestHandler): + def handle(self): + request = self.rfile.readline() + method, target, _ = request.decode().split() + headers = [] + while True: + line = self.rfile.readline() + if line == b"\r\n": + break + headers.append(line) + requests.append((method, target, headers)) + if method == "CONNECT": + host, port = target.rsplit(":", 1) + initial = b"" + else: + url = urlsplit(target) + host, port = url.hostname, url.port + length = next( + ( + int(h.split(b":", 1)[1]) + for h in headers + if h.lower().startswith(b"content-length:") + ), + 0, + ) + initial = f"{method} {url.path} HTTP/1.1\r\n".encode() + initial += ( + b"".join( + h for h in headers if not h.lower().startswith(b"proxy-authorization:") + ) + + b"\r\n" + + self.rfile.read(length) + ) + with socket.create_connection((host, int(port)), timeout=5) as upstream: + if method == "CONNECT": + self.wfile.write(b"HTTP/1.1 200 Connection established\r\n\r\n") + self.wfile.flush() + else: + upstream.sendall(initial) + while True: + ready, _, _ = select.select([self.connection, upstream], [], [], 5) + if not ready: + return + for source in ready: + data = source.recv(65536) + if not data: + return + (upstream if source is self.connection else self.connection).sendall(data) + + class TCP(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + srv = TCP(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=srv.serve_forever, daemon=True) + thread.start() + try: + yield srv.server_address[1], requests + finally: + srv.shutdown() + srv.server_close() + thread.join() + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("tls", [False, True]) +@pytest.mark.parametrize("bypass", [False, True]) +async def test_environment_proxy_and_no_proxy(async_mode, tls, bypass, monkeypatch): + monkeypatch.setenv("SSL_CERT_FILE", str(CERT)) + with server([wire(arrow_bytes())], tls=tls) as (port, _), proxy() as (proxy_port, requests): + monkeypatch.setenv("HTTP_PROXY", f"http://127.0.0.1:{proxy_port}") + monkeypatch.setenv("HTTPS_PROXY", f"http://127.0.0.1:{proxy_port}") + monkeypatch.setenv("NO_PROXY", "127.0.0.1" if bypass else "") + monkeypatch.delenv("ALL_PROXY", raising=False) + if async_mode: + async with AsyncArcClient(host="127.0.0.1", port=port, ssl=tls) as c: + t = await c.query.query_arrow("SELECT value") + else: + with ArcClient(host="127.0.0.1", port=port, ssl=tls) as c: + t = c.query.query_arrow("SELECT value") + assert t.schema.metadata[b"arc.row_cap"] == b"2" + assert len(requests) == (0 if bypass else 1) + if requests: + assert requests[0][0] == ("CONNECT" if tls else "POST") + + +async def test_cancellation_during_captured_body_then_query(monkeypatch): + from arc_client.http import async_http + from arc_client.http.arrow_trailers import TRAILERS_EXTENSION + + hooked = asyncio.Event() + release = threading.Event() + responses = [] + original_hook = async_http.capture_async_arrow_trailers + + async def hook(response): + await original_hook(response) + responses.append(response) + hooked.set() + + monkeypatch.setattr(async_http, "capture_async_arrow_trailers", hook) + + def slow_body(): + reply = wire(arrow_bytes(), b"Arc-Rows-Capped: 999\r\n") + split = reply.index(b"\r\n\r\n") + 4 + yield reply[:split] + release.wait(5) + yield reply[split:] + + with server([slow_body, wire(arrow_bytes((1,)), b"Arc-Rows-Capped: 1\r\n")]) as (port, _): + async with AsyncArcClient(host="127.0.0.1", port=port) as c: + task = asyncio.create_task(c.query.query_arrow("SELECT slow")) + try: + await asyncio.wait_for(hooked.wait(), timeout=3) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not responses[0].extensions[TRAILERS_EXTENSION].complete + t = await c.query.query_arrow("SELECT fresh") + assert t.schema.metadata[b"arc.row_cap"] == b"1" + assert not responses[0].extensions[TRAILERS_EXTENSION].complete + assert responses[1].extensions[TRAILERS_EXTENSION].complete + finally: + release.set() + + +def test_non_arrow_hook_is_inert(httpx_mock): + from arc_client.config import ClientConfig + from arc_client.http.arrow_trailers import TRAILERS_EXTENSION + from arc_client.http.sync_http import SyncHTTPClient + + httpx_mock.add_response(json={"success": True}, headers={"Trailer": "Arc-Rows-Capped"}) + with SyncHTTPClient(ClientConfig(host="h")) as c: + response = c.post("/api/v1/query", json={"sql": "SELECT 1"}) + assert TRAILERS_EXTENSION not in response.extensions + + +@pytest.mark.parametrize("tail", [b"", b"Arc-Rows-Capped: \r\n"]) +def test_gateway_dropping_all_completion_values_does_not_mean_uncapped(tail): + with ( + server([wire(arrow_bytes(), tail)]) as (port, _), + ArcClient(host="127.0.0.1", port=port) as c, + ): + t = c.query.query_arrow("SELECT value LIMIT 2") + assert b"arc.rows_capped" not in (t.schema.metadata or {}) + + +def test_main_omitted_cap_with_execution_marker_is_uncapped(): + with ( + server([wire(arrow_bytes(), b"Arc-Execution-Time-Ms: 0\r\n")]) as (port, _), + ArcClient(host="127.0.0.1", port=port) as c, + ): + t = c.query.query_arrow("SELECT value") + assert t.schema.metadata[b"arc.rows_capped"] == b"false" + assert b"arc.row_cap" not in t.schema.metadata + + +def test_promoted_timing_header_cannot_prove_uncapped_after_trailer_loss(): + reply = wire(arrow_bytes(), b"").replace( + b"Transfer-Encoding:", b"Arc-Execution-Time-Ms: 7\r\nTransfer-Encoding:" + ) + with server([reply]) as (port, _), ArcClient(host="127.0.0.1", port=port) as c: + t = c.query.query_arrow("SELECT value") + assert b"arc.rows_capped" not in (t.schema.metadata or {}) + assert t.schema.metadata[b"arc.execution_time_ms"] == b"7" diff --git a/uv.lock b/uv.lock index 464c4c8..77268af 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.12'", @@ -61,6 +61,8 @@ wheels = [ name = "arc-tsdb-client" source = { editable = "." } dependencies = [ + { name = "h11" }, + { name = "httpcore" }, { name = "httpx" }, { name = "msgpack" }, { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -106,7 +108,9 @@ polars = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.27.0" }, + { name = "h11", specifier = ">=0.16.0,<0.17" }, + { name = "httpcore", specifier = "==1.0.9" }, + { name = "httpx", specifier = ">=0.27.0,<0.29" }, { name = "msgpack", specifier = ">=1.0.7" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=2.0.0" }, @@ -178,7 +182,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "webencodings", marker = "python_full_version < '3.10'" }, + { name = "webencodings" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" } wheels = [ @@ -187,7 +191,7 @@ wheels = [ [package.optional-dependencies] css = [ - { name = "tinycss2", marker = "python_full_version < '3.10'" }, + { name = "tinycss2" }, ] [[package]] @@ -200,7 +204,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "webencodings", marker = "python_full_version >= '3.10'" }, + { name = "webencodings" }, ] sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } wheels = [ @@ -209,7 +213,7 @@ wheels = [ [package.optional-dependencies] css = [ - { name = "tinycss2", marker = "python_full_version >= '3.10'" }, + { name = "tinycss2" }, ] [[package]] @@ -226,7 +230,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -571,7 +575,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "tomli" }, ] [[package]] @@ -680,7 +684,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -715,7 +719,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -826,7 +830,7 @@ name = "importlib-metadata" version = "8.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.10'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ @@ -927,9 +931,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pywin32", marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, - { name = "traitlets", marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923, upload-time = "2025-05-27T07:38:16.655Z" } wheels = [ @@ -946,8 +950,8 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "platformdirs", version = "4.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "traitlets", marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "traitlets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } wheels = [ @@ -1162,7 +1166,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "markdown-it-py", marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } wheels = [ @@ -1179,7 +1183,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "markdown-it-py", marker = "python_full_version >= '3.10'" }, + { name = "markdown-it-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } wheels = [ @@ -1345,12 +1349,12 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "docutils", marker = "python_full_version < '3.10'" }, - { name = "jinja2", marker = "python_full_version < '3.10'" }, - { name = "markdown-it-py", marker = "python_full_version < '3.10'" }, - { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pyyaml", marker = "python_full_version < '3.10'" }, - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392, upload-time = "2024-04-28T20:22:42.116Z" } wheels = [ @@ -1367,12 +1371,12 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "docutils", marker = "python_full_version >= '3.10'" }, - { name = "jinja2", marker = "python_full_version >= '3.10'" }, - { name = "markdown-it-py", marker = "python_full_version >= '3.10'" }, - { name = "mdit-py-plugins", version = "0.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pyyaml", marker = "python_full_version >= '3.10'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins", version = "0.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } @@ -1839,11 +1843,11 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "identify", marker = "python_full_version < '3.10'" }, - { name = "nodeenv", marker = "python_full_version < '3.10'" }, - { name = "pyyaml", marker = "python_full_version < '3.10'" }, - { name = "virtualenv", marker = "python_full_version < '3.10'" }, + { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } wheels = [ @@ -1860,11 +1864,11 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "cfgv", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "identify", marker = "python_full_version >= '3.10'" }, - { name = "nodeenv", marker = "python_full_version >= '3.10'" }, - { name = "pyyaml", marker = "python_full_version >= '3.10'" }, - { name = "virtualenv", marker = "python_full_version >= '3.10'" }, + { name = "cfgv", version = "3.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" } wheels = [ @@ -2177,9 +2181,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, - { name = "pytest", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "backports-asyncio-runner" }, + { name = "pytest" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ @@ -2196,9 +2200,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, - { name = "pytest", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ @@ -2448,9 +2452,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "attrs", marker = "python_full_version < '3.10'" }, - { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "attrs" }, + { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } wheels = [ @@ -2467,9 +2471,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "attrs", marker = "python_full_version >= '3.10'" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -2853,24 +2857,24 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "babel", marker = "python_full_version < '3.10'" }, - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.10'" }, - { name = "imagesize", marker = "python_full_version < '3.10'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, - { name = "jinja2", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "requests", marker = "python_full_version < '3.10'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } wheels = [ @@ -2885,23 +2889,23 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "babel", marker = "python_full_version == '3.10.*'" }, - { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version == '3.10.*'" }, - { name = "imagesize", marker = "python_full_version == '3.10.*'" }, - { name = "jinja2", marker = "python_full_version == '3.10.*'" }, - { name = "packaging", marker = "python_full_version == '3.10.*'" }, - { name = "pygments", marker = "python_full_version == '3.10.*'" }, - { name = "requests", marker = "python_full_version == '3.10.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -2917,23 +2921,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "babel", marker = "python_full_version >= '3.11'" }, - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "imagesize", marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "requests", marker = "python_full_version >= '3.11'" }, - { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [ @@ -2948,7 +2952,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/cd/03e7b917230dc057922130a79ba0240df1693bfd76727ea33fae84b39138/sphinx_autodoc_typehints-2.3.0.tar.gz", hash = "sha256:535c78ed2d6a1bad393ba9f3dfa2602cf424e2631ee207263e07874c38fde084", size = 40709, upload-time = "2024-08-29T16:25:48.343Z" } wheels = [ @@ -2963,7 +2967,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/f0/43c6a5ff3e7b08a8c3b32f81b859f1b518ccc31e45f22e2b41ced38be7b9/sphinx_autodoc_typehints-3.0.1.tar.gz", hash = "sha256:b9b40dd15dee54f6f810c924f863f9cf1c54f9f3265c495140ea01be7f44fa55", size = 36282, upload-time = "2025-01-16T18:25:30.958Z" } wheels = [ @@ -2979,7 +2983,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/4f/4fd5583678bb7dc8afa69e9b309e6a99ee8d79ad3a4728f4e52fd7cb37c7/sphinx_autodoc_typehints-3.5.2.tar.gz", hash = "sha256:5fcd4a3eb7aa89424c1e2e32bedca66edc38367569c9169a80f4b3e934171fdb", size = 37839, upload-time = "2025-10-16T00:50:15.743Z" } wheels = [