Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/regenerate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ jobs:
- name: Patch default client exports (enhanced query/results)
run: python3 scripts/patch_query_exports.py

- name: Patch transport request defaults (Accept-Encoding, User-Agent)
run: python3 scripts/patch_request_defaults.py

# The API-token -> JWT key exchange is deprecated: the configured API token
# is the bearer credential and goes on the wire verbatim. This check is the
# inverse of the old "did the exchange survive?" guard — it fails if the
Expand Down
5 changes: 4 additions & 1 deletion .openapi-generator-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ setup.py
# truth for "hand-maintained, don't touch": arrow.py (Arrow IPC result fetch),
# query.py (429 retry + truncation auto-follow, #688), _retry.py (pre-response
# connection-reset retry on all methods, #118), uploads.py (transparent
# presigned direct-to-storage upload flow).
# presigned direct-to-storage upload flow), _useragent.py (runtime-resolved
# User-Agent; the generator's httpUserAgent property can only bake a literal at
# generation time, which goes stale between releases).
hotdata/arrow.py
hotdata/query.py
hotdata/_retry.py
hotdata/uploads.py
hotdata/_useragent.py

# Hand-written test for the patched ApiClient.close()/context-manager behavior
# (re-applied by scripts/patch_api_client_close.py). It lives in the generated
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
compatibility. Generated docstrings pick up the new wording on the next client
regeneration from the updated OpenAPI spec.
- feat(query): add dialect parameter to query request
- perf(transport): request compressed responses. urllib3 defaults every
connection to `Accept-Encoding: identity`, which asks the server *not* to
compress, so every JSON response came back uncompressed. The client now
advertises `urllib3.util.request.ACCEPT_ENCODING` — exactly the codecs the
installed urllib3 can transparently decode. Response bodies are unchanged;
a caller or operation can still pass an explicit `Accept-Encoding` (matched
case-insensitively). Arrow IPC fetches opt out and stay uncompressed, since
their record batches are frequently compressed by the writer already.
- feat(transport): send an SDK `User-Agent`. Requests identified themselves as
`OpenAPI-Generator/1.0.0/python`; they now send
`hotdata-python/<version> (Python/<py>; urllib3/<urllib3>)`. Setting
`ApiClient.user_agent` still overrides it.

## [0.10.0] - 2026-08-18

Expand Down
52 changes: 52 additions & 0 deletions hotdata/_useragent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""The SDK's default ``User-Agent``.

The generator's default is ``OpenAPI-Generator/1.0.0/python``, which identifies
neither the SDK nor its version — server-side telemetry cannot tell a hotdata
client from any other generated client, let alone one release from another.

openapi-generator *does* expose an ``httpUserAgent`` property for this, but it
bakes a literal string in at **generation** time. Regeneration is driven by
OpenAPI spec changes, not by releases, so a baked version string reports
whatever the version happened to be at the last regen — it would go stale
silently and misattribute traffic, which is worse than the generic default. The
version is therefore resolved at import time from installed package metadata,
the same source :mod:`hotdata.__init__` uses for ``__version__``.

This lives outside the generated modules (see ``.openapi-generator-ignore``) so
a regeneration cannot overwrite it; ``scripts/patch_request_defaults.py`` only
has to re-point ``ApiClient`` at the constant.
"""

from __future__ import annotations

import importlib.metadata
import platform

import urllib3


def _default_user_agent() -> str:
"""``hotdata-python/<version> (Python/<py>; urllib3/<urllib3>)``.

The runtime versions ride along because the transport stack is where
client-side failures usually originate: knowing which urllib3 a bug report
came from is the difference between reproducing a problem and guessing.
"""
try:
sdk_version = importlib.metadata.version("hotdata")
except importlib.metadata.PackageNotFoundError:
# Running from a source checkout without an install.
sdk_version = "0.0.0+unknown"
return (
f"hotdata-python/{sdk_version} "
f"(Python/{platform.python_version()}; urllib3/{urllib3.__version__})"
)


#: Sent as ``User-Agent`` on every request. ``ApiClient.user_agent`` still
#: overrides it per client, which is the supported way for an application to
#: identify itself.
USER_AGENT = _default_user_agent()


__all__ = ["USER_AGENT", "_default_user_agent"]
3 changes: 2 additions & 1 deletion hotdata/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from hotdata.configuration import Configuration
from hotdata.api_response import ApiResponse, T as ApiResponseT
from hotdata._useragent import USER_AGENT
import hotdata.models
from hotdata import rest
from hotdata.exceptions import (
Expand Down Expand Up @@ -91,7 +92,7 @@ def __init__(
self.default_headers[header_name] = header_value
self.cookie = cookie
# Set default User-Agent.
self.user_agent = 'OpenAPI-Generator/1.0.0/python'
self.user_agent = USER_AGENT
self.client_side_validation = configuration.client_side_validation

def __enter__(self):
Expand Down
11 changes: 10 additions & 1 deletion hotdata/arrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,16 @@ def _call_arrow(
# Override only what we need: the Accept header and the format query.
# `GET /v1/results/{id}` is database-scoped, so the required
# X-Database-Id header flows through the generated serializer too.
headers: Dict[str, Any] = {"Accept": ARROW_STREAM_MEDIA_TYPE}
# `Accept-Encoding: identity` opts this path out of the client-wide
# response compression default (hotdata/rest.py). Arrow IPC record
# batches are frequently LZ4/ZSTD-compressed by the writer already, so
# a gzip pass over the stream burns CPU on both ends for little size
# gain. Drop it if the endpoint is measured to serve uncompressed
# batches, where columnar data does compress well.
headers: Dict[str, Any] = {
"Accept": ARROW_STREAM_MEDIA_TYPE,
"Accept-Encoding": "identity",
}
params = self._get_result_serialize(
id=id,
x_database_id=x_database_id,
Expand Down
20 changes: 20 additions & 0 deletions hotdata/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import ssl

import urllib3
from urllib3.util.request import ACCEPT_ENCODING

from hotdata.exceptions import ApiException, ApiValueError

Expand Down Expand Up @@ -164,6 +165,25 @@ def request(
post_params = post_params or {}
headers = headers or {}

# Ask for compressed responses. urllib3 defaults every connection to
# `Accept-Encoding: identity`, which is not "no preference" but an
# explicit request *not* to compress, and a spec-compliant server
# honors it. ACCEPT_ENCODING is built from the codecs the installed
# urllib3 can actually decode (gzip/deflate, plus br/zstd when their
# backends are present), so the server can never negotiate an encoding
# that reaches us as undecodable bytes. urllib3 decodes the body
# transparently, so callers are unaffected.
#
# A default, not an override: an operation whose payload is already
# compressed end-to-end passes `identity` and stays in control. The
# check is case-insensitive because header names are -- a caller
# passing `accept-encoding` would otherwise leave both keys in the
# dict and urllib3 would emit two header lines, so the server would
# see the opt-out *and* the compressed set. urllib3 lowercases names
# the same way before adding its own Accept-Encoding.
if not any(key.lower() == 'accept-encoding' for key in headers):
headers['Accept-Encoding'] = ACCEPT_ENCODING

timeout = None
if _request_timeout:
if isinstance(_request_timeout, (int, float)):
Expand Down
103 changes: 103 additions & 0 deletions scripts/patch_request_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Re-apply the SDK's transport request defaults after OpenAPI regeneration.

Two defaults the generator leaves on urllib3's (server-observable) behavior:

* ``Accept-Encoding``: urllib3 puts ``identity`` on every connection, which
asks the server *not* to compress. We advertise
``urllib3.util.request.ACCEPT_ENCODING`` instead — exactly the codecs the
installed urllib3 can transparently decode.
* ``User-Agent``: ``OpenAPI-Generator/1.0.0/python`` identifies neither the SDK
nor its version, so server-side telemetry cannot attribute traffic.
"""

from __future__ import annotations

import pathlib
import sys

ROOT = pathlib.Path(__file__).resolve().parents[1]


def patch_accept_encoding() -> None:
"""Advertise compression on every request made through RESTClientObject."""
path = ROOT / "hotdata" / "rest.py"
src = path.read_text()

if "ACCEPT_ENCODING" in src:
return

import_needle = "import urllib3\n"
import_replacement = "import urllib3\nfrom urllib3.util.request import ACCEPT_ENCODING\n"
if import_needle not in src:
sys.exit(f"Failed to patch {path}: urllib3 import anchor not found")
src = src.replace(import_needle, import_replacement, 1)

needle = " post_params = post_params or {}\n headers = headers or {}\n"
replacement = (
" post_params = post_params or {}\n"
" headers = headers or {}\n\n"
" # Ask for compressed responses. urllib3 defaults every connection to\n"
" # `Accept-Encoding: identity`, which is not \"no preference\" but an\n"
" # explicit request *not* to compress, and a spec-compliant server\n"
" # honors it. ACCEPT_ENCODING is built from the codecs the installed\n"
" # urllib3 can actually decode (gzip/deflate, plus br/zstd when their\n"
" # backends are present), so the server can never negotiate an encoding\n"
" # that reaches us as undecodable bytes. urllib3 decodes the body\n"
" # transparently, so callers are unaffected.\n"
" #\n"
" # A default, not an override: an operation whose payload is already\n"
" # compressed end-to-end passes `identity` and stays in control. The\n"
" # check is case-insensitive because header names are -- a caller\n"
" # passing `accept-encoding` would otherwise leave both keys in the\n"
" # dict and urllib3 would emit two header lines, so the server would\n"
" # see the opt-out *and* the compressed set. urllib3 lowercases names\n"
" # the same way before adding its own Accept-Encoding.\n"
" if not any(key.lower() == 'accept-encoding' for key in headers):\n"
" headers['Accept-Encoding'] = ACCEPT_ENCODING\n"
)
if needle not in src:
sys.exit(f"Failed to patch {path}: request() header anchor not found")
src = src.replace(needle, replacement, 1)

path.write_text(src)


def patch_user_agent() -> None:
"""Point ApiClient at the hand-maintained User-Agent constant.

The string itself lives in ``hotdata/_useragent.py`` (generator-ignored), so
this patch is just an import plus the assignment — two anchors instead of
carrying the logic inside generated output.
"""
path = ROOT / "hotdata" / "api_client.py"
src = path.read_text()

if "_useragent" in src:
return

import_needle = "from hotdata.api_response import ApiResponse, T as ApiResponseT\n"
import_replacement = (
"from hotdata.api_response import ApiResponse, T as ApiResponseT\n"
"from hotdata._useragent import USER_AGENT\n"
)
if import_needle not in src:
sys.exit(f"Failed to patch {path}: api_response import anchor not found")
src = src.replace(import_needle, import_replacement, 1)

ua_needle = " self.user_agent = 'OpenAPI-Generator/1.0.0/python'\n"
ua_replacement = " self.user_agent = USER_AGENT\n"
if ua_needle not in src:
sys.exit(f"Failed to patch {path}: default User-Agent anchor not found")
src = src.replace(ua_needle, ua_replacement, 1)

path.write_text(src)


def main() -> None:
patch_accept_encoding()
patch_user_agent()


if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions tests/test_arrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ def test_get_result_arrow_returns_table(monkeypatch: pytest.MonkeyPatch) -> None
assert call["headers"]["Accept"] == ARROW_STREAM_MEDIA_TYPE
# Results are database-scoped: the required X-Database-Id header is sent.
assert call["headers"]["X-Database-Id"] == "db_x"
# Arrow opts out of the client-wide response compression default: IPC
# record batches are frequently LZ4/ZSTD-compressed already, so a gzip
# pass over the stream costs CPU on both ends for little size gain.
assert call["headers"]["Accept-Encoding"] == "identity"


def test_get_result_arrow_forwards_offset_and_limit(
Expand Down
Loading
Loading