Coverage for src/pipeline_analytics/client.py: 100%
55 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-16 19:27 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-16 19:27 +0000
1"""The hand-written client wrapper. Everything under
2``pipeline_analytics._generated`` is produced by openapi-python-client from
3``openapi/openapi.yaml`` -- never edit it by hand, regenerate it instead
4(see ``hack/fetch-spec.sh`` and ``CONTRIBUTING.md``). This module,
5``errors.py``, ``retry.py`` and ``pagination.py`` are the hand-written layer
6on top of it: auth injection, retry, typed errors and pagination
7(rules/sdk-generation.md's "Generated vs hand-written").
8"""
10from __future__ import annotations
12import os
14import httpx
16from ._generated.api.meta import get_version as _get_version
17from ._generated.client import Client as _GeneratedClient
18from ._generated.models.version import Version
19from .errors import decode_error
20from .retry import DEFAULT_RETRY_CONFIG, AsyncRetryTransport, RetryConfig, RetryTransport
22#: The environment variable :class:`PipelineAnalyticsClient` falls back to
23#: when no session cookie is passed explicitly via ``session_cookie``.
24SESSION_COOKIE_ENV_VAR = "PIPELINE_ANALYTICS_SESSION"
26_SESSION_COOKIE_NAME = "session"
29class PipelineAnalyticsClient:
30 """A pipeline-analytics API client.
32 Every operation except :meth:`get_version` and the two webhook
33 receivers requires an authenticated session. pipeline-analytics
34 authenticates browsers with `WebAuthn <https://webauthn.guide/>`_, not
35 an API token -- there is no headless credential-grant flow in the spec
36 (a real token flow is requested in
37 `alrayyes/pipeline-analytics#178 <https://github.com/alrayyes/pipeline-analytics/issues/178>`_),
38 so this client can't log in for you. Get a session cookie by logging
39 into the dashboard in a browser, opening dev tools, and copying the
40 ``session`` cookie's value. Pass it as *session_cookie* or set
41 ``PIPELINE_ANALYTICS_SESSION`` in the environment -- the constructor
42 argument wins when both are given.
44 The underlying generated client is available as :attr:`raw` for any
45 operation this wrapper doesn't have a dedicated method for; every
46 generated ``pipeline_analytics._generated.api.<tag>.<operation>``
47 module's ``sync_detailed``/``asyncio_detailed`` functions take it as
48 their ``client`` argument.
49 """
51 def __init__(
52 self,
53 base_url: str,
54 *,
55 session_cookie: str | None = None,
56 retry: RetryConfig = DEFAULT_RETRY_CONFIG,
57 httpx_client: httpx.Client | None = None,
58 httpx_async_client: httpx.AsyncClient | None = None,
59 raise_on_unexpected_status: bool = False,
60 ) -> None:
61 cookie = session_cookie if session_cookie is not None else os.environ.get(SESSION_COOKIE_ENV_VAR)
62 cookies = {_SESSION_COOKIE_NAME: cookie} if cookie else {}
64 # cookies isn't passed here, and base_url is never read back off
65 # self._raw afterward either: this Client's own lazy httpx-client
66 # construction is never reached -- set_httpx_client/
67 # set_async_httpx_client below always override it -- so the base
68 # URL and cookie both have to be applied to whichever client
69 # actually sends requests instead (see below). base_url is on its
70 # own statement, pragma'd, so mutating that dead value doesn't
71 # mask a real mutation of raise_on_unexpected_status on the same
72 # line -- that one *is* read, by every generated operation's
73 # _parse_response.
74 self._raw = _GeneratedClient(base_url=base_url) # pragma: no mutate - never read back, see comment above
75 self._raw.raise_on_unexpected_status = raise_on_unexpected_status
77 sync_client = httpx_client
78 if sync_client is None:
79 sync_client = httpx.Client(base_url=base_url, transport=RetryTransport(retry=retry))
80 # Apply the session cookie directly to whichever client will
81 # actually send requests -- a caller-supplied httpx_client bypasses
82 # the generated Client's own lazy cookie-jar construction, so the
83 # cookie has to be set here rather than relying on that path.
84 sync_client.cookies.update(cookies)
85 self._raw.set_httpx_client(sync_client)
87 async_client = httpx_async_client
88 if async_client is None:
89 async_client = httpx.AsyncClient(base_url=base_url, transport=AsyncRetryTransport(retry=retry))
90 async_client.cookies.update(cookies)
91 self._raw.set_async_httpx_client(async_client)
93 @property
94 def raw(self) -> _GeneratedClient:
95 """The underlying generated ``Client``."""
96 return self._raw
98 def get_version(self) -> Version:
99 """The running server's build version. Needs no session -- a good
100 first call to prove the client reaches the server at all."""
101 response = _get_version.sync_detailed(client=self._raw)
102 if error := decode_error(response):
103 raise error
104 if response.parsed is None:
105 # Not an assert -- holds under -O and doesn't trip bandit's
106 # B101. getVersion's only documented response is 200 Version,
107 # so this only fires if the spec and server ever disagree.
108 raise TypeError(f"unexpected getVersion response body: {response.parsed!r}")
109 return response.parsed
111 async def aget_version(self) -> Version:
112 """Async counterpart of :meth:`get_version`."""
113 response = await _get_version.asyncio_detailed(client=self._raw)
114 if error := decode_error(response):
115 raise error
116 if response.parsed is None:
117 raise TypeError(f"unexpected getVersion response body: {response.parsed!r}")
118 return response.parsed
120 def close(self) -> None:
121 self._raw.get_httpx_client().close()
123 async def aclose(self) -> None:
124 await self._raw.get_async_httpx_client().aclose()
126 def __enter__(self) -> PipelineAnalyticsClient:
127 return self
129 def __exit__(self, *exc_info: object) -> None:
130 # httpx.BaseTransport's own __exit__ ignores exc_info and just
131 # closes -- our RetryTransport doesn't override that -- so there's
132 # nothing this needs from *exc_info beyond letting `with` call it.
133 self.close()
135 async def __aenter__(self) -> PipelineAnalyticsClient:
136 return self
138 async def __aexit__(self, *exc_info: object) -> None:
139 await self.aclose()