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

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""" 

9 

10from __future__ import annotations 

11 

12import os 

13 

14import httpx 

15 

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 

21 

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" 

25 

26_SESSION_COOKIE_NAME = "session" 

27 

28 

29class PipelineAnalyticsClient: 

30 """A pipeline-analytics API client. 

31 

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. 

43 

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 """ 

50 

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 {} 

63 

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 

76 

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) 

86 

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) 

92 

93 @property 

94 def raw(self) -> _GeneratedClient: 

95 """The underlying generated ``Client``.""" 

96 return self._raw 

97 

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 

110 

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 

119 

120 def close(self) -> None: 

121 self._raw.get_httpx_client().close() 

122 

123 async def aclose(self) -> None: 

124 await self._raw.get_async_httpx_client().aclose() 

125 

126 def __enter__(self) -> PipelineAnalyticsClient: 

127 return self 

128 

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() 

134 

135 async def __aenter__(self) -> PipelineAnalyticsClient: 

136 return self 

137 

138 async def __aexit__(self, *exc_info: object) -> None: 

139 await self.aclose()