kova-client 0.1.0rc7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ .pytest_cache/
2
+ .ruff_cache/
3
+ .mypy_cache/
4
+ .venv/
5
+ dist/
6
+ src/kova_client/_version.py
7
+ **/__pycache__/
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: kova-client
3
+ Version: 0.1.0rc7
4
+ Summary: Official Python client for the Kova Service API
5
+ Project-URL: Documentation, https://github.com/cofy-x/kova/blob/main/docs/service.md#python-sdk
6
+ Project-URL: Issues, https://github.com/cofy-x/kova/issues
7
+ Project-URL: Repository, https://github.com/cofy-x/kova
8
+ Author: Kova Authors
9
+ License-Expression: Apache-2.0
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx<1,>=0.27
22
+ Description-Content-Type: text/markdown
23
+
24
+ # kova-client
25
+
26
+ `kova-client` is the official, thin Python client for the Kova Service HTTP API v1.
27
+ It submits immutable sources for bounded image builds and returns server-verified manifest digests and immutable image references.
28
+ It does not own workflow recovery, artifact retention, datasets, episodes, or Axern/Axrun domain objects.
29
+
30
+ ```bash
31
+ python -m pip install kova-client
32
+ ```
33
+
34
+ ```python
35
+ from kova_client import ClientConfig, CreateBuildRequest, KovaClient
36
+
37
+ config = ClientConfig.from_env()
38
+ with KovaClient(config) as kova:
39
+ job = kova.create_build(
40
+ CreateBuildRequest(
41
+ source_uri="oci://registry.example.com/team/sources@sha256:<manifest-digest>",
42
+ source_digest="sha256:<source-content-digest>",
43
+ targets=("registry.example.com/team/seed:build-123",),
44
+ concurrency=1,
45
+ idempotency_key="build-123",
46
+ )
47
+ )
48
+ terminal = kova.wait_build(job.id, timeout=600)
49
+ if terminal.status == "succeeded":
50
+ for output in kova.get_results(job.id).outputs:
51
+ print(output.immutable_ref, output.manifest_digest)
52
+ ```
53
+
54
+ `ClientConfig.from_env()` explicitly reads `KOVA_SERVICE_URL`, `KOVA_SERVICE_TOKEN`, `KOVA_SERVICE_CA_FILE`, and `KOVA_SERVICE_INSECURE`.
55
+ Constructing `ClientConfig` or either client never reads a user directory or process environment implicitly.
56
+ Use a stable idempotency key if the caller may retry submission; `create_build` itself is never retried.
@@ -0,0 +1,33 @@
1
+ # kova-client
2
+
3
+ `kova-client` is the official, thin Python client for the Kova Service HTTP API v1.
4
+ It submits immutable sources for bounded image builds and returns server-verified manifest digests and immutable image references.
5
+ It does not own workflow recovery, artifact retention, datasets, episodes, or Axern/Axrun domain objects.
6
+
7
+ ```bash
8
+ python -m pip install kova-client
9
+ ```
10
+
11
+ ```python
12
+ from kova_client import ClientConfig, CreateBuildRequest, KovaClient
13
+
14
+ config = ClientConfig.from_env()
15
+ with KovaClient(config) as kova:
16
+ job = kova.create_build(
17
+ CreateBuildRequest(
18
+ source_uri="oci://registry.example.com/team/sources@sha256:<manifest-digest>",
19
+ source_digest="sha256:<source-content-digest>",
20
+ targets=("registry.example.com/team/seed:build-123",),
21
+ concurrency=1,
22
+ idempotency_key="build-123",
23
+ )
24
+ )
25
+ terminal = kova.wait_build(job.id, timeout=600)
26
+ if terminal.status == "succeeded":
27
+ for output in kova.get_results(job.id).outputs:
28
+ print(output.immutable_ref, output.manifest_digest)
29
+ ```
30
+
31
+ `ClientConfig.from_env()` explicitly reads `KOVA_SERVICE_URL`, `KOVA_SERVICE_TOKEN`, `KOVA_SERVICE_CA_FILE`, and `KOVA_SERVICE_INSECURE`.
32
+ Constructing `ClientConfig` or either client never reads a user directory or process environment implicitly.
33
+ Use a stable idempotency key if the caller may retry submission; `create_build` itself is never retried.
@@ -0,0 +1,81 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27", "hatch-vcs>=0.5"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kova-client"
7
+ dynamic = ["version"]
8
+ description = "Official Python client for the Kova Service API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "Kova Authors" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = ["httpx>=0.27,<1"]
26
+
27
+ [project.urls]
28
+ Documentation = "https://github.com/cofy-x/kova/blob/main/docs/service.md#python-sdk"
29
+ Issues = "https://github.com/cofy-x/kova/issues"
30
+ Repository = "https://github.com/cofy-x/kova"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "build>=1.2,<2",
35
+ "mypy>=1.18,<2",
36
+ "pytest>=8,<10",
37
+ "pyyaml>=6,<7",
38
+ "ruff>=0.12,<1",
39
+ ]
40
+
41
+ [tool.hatch.version]
42
+ source = "vcs"
43
+
44
+ [tool.hatch.version.raw-options]
45
+ fallback_version = "0.0.0.dev0"
46
+ local_scheme = "no-local-version"
47
+ search_parent_directories = true
48
+
49
+ [tool.hatch.build.hooks.vcs]
50
+ version-file = "src/kova_client/_version.py"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/kova_client"]
54
+
55
+ [tool.hatch.build.targets.sdist]
56
+ include = [
57
+ "/README.md",
58
+ "/pyproject.toml",
59
+ "/src/kova_client",
60
+ ]
61
+
62
+ [tool.hatch.build.targets.sdist.force-include]
63
+ "../../LICENSE" = "LICENSE"
64
+
65
+ [tool.pytest.ini_options]
66
+ addopts = "-ra"
67
+ testpaths = ["tests"]
68
+
69
+ [tool.mypy]
70
+ python_version = "3.10"
71
+ strict = true
72
+
73
+ [tool.ruff]
74
+ line-length = 100
75
+ target-version = "py310"
76
+
77
+ [tool.ruff.lint]
78
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
79
+
80
+ [tool.uv]
81
+ default-groups = ["dev"]
@@ -0,0 +1,44 @@
1
+ """Official Python client for the Kova Service HTTP API v1."""
2
+
3
+ from ._client import AsyncKovaClient, KovaClient
4
+ from ._config import ClientConfig
5
+ from ._errors import KovaAPIError, KovaProtocolError, KovaWaitCancelled
6
+ from ._models import (
7
+ BuildFailureCode,
8
+ BuildFormat,
9
+ BuildJob,
10
+ BuildOutput,
11
+ BuildResults,
12
+ CreateBuildRequest,
13
+ JobList,
14
+ JobStatus,
15
+ OutputFormat,
16
+ ReadyStatus,
17
+ VersionInfo,
18
+ )
19
+
20
+ try:
21
+ from ._version import __version__
22
+ except ImportError: # pragma: no cover - generated for wheels and editable installs
23
+ __version__ = "0.0.0.dev0"
24
+
25
+ __all__ = [
26
+ "AsyncKovaClient",
27
+ "BuildFailureCode",
28
+ "BuildFormat",
29
+ "BuildJob",
30
+ "BuildOutput",
31
+ "BuildResults",
32
+ "ClientConfig",
33
+ "CreateBuildRequest",
34
+ "JobList",
35
+ "JobStatus",
36
+ "KovaAPIError",
37
+ "KovaClient",
38
+ "KovaProtocolError",
39
+ "KovaWaitCancelled",
40
+ "OutputFormat",
41
+ "ReadyStatus",
42
+ "VersionInfo",
43
+ "__version__",
44
+ ]
@@ -0,0 +1,381 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import threading
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import Any, TypeVar
9
+
10
+ import httpx
11
+
12
+ from ._config import ClientConfig
13
+ from ._errors import KovaAPIError, KovaProtocolError, KovaWaitCancelled
14
+ from ._models import (
15
+ BuildJob,
16
+ BuildResults,
17
+ CreateBuildRequest,
18
+ JobList,
19
+ JobStatus,
20
+ ReadyStatus,
21
+ VersionInfo,
22
+ )
23
+ from ._transport import ERROR_BODY_LIMIT, api_error, decode_json, decode_text, read_bounded
24
+
25
+ Model = TypeVar("Model")
26
+ ModelFactory = Callable[[dict[str, Any]], Model]
27
+ _JOB_ID = re.compile(r"^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$")
28
+ _TERMINAL_STATUSES = {JobStatus.SUCCEEDED, JobStatus.FAILED, JobStatus.CANCELLED}
29
+
30
+
31
+ class KovaClient:
32
+ """Synchronous client for the Kova Service HTTP API v1."""
33
+
34
+ def __init__(self, config: ClientConfig, *, http_client: httpx.Client | None = None):
35
+ self._config = config
36
+ self._owns_client = http_client is None
37
+ self._http = http_client or httpx.Client(
38
+ timeout=config.request_timeout,
39
+ verify=config.ssl_context(),
40
+ follow_redirects=False,
41
+ )
42
+
43
+ def __enter__(self) -> KovaClient:
44
+ return self
45
+
46
+ def __exit__(self, *_: object) -> None:
47
+ self.close()
48
+
49
+ def close(self) -> None:
50
+ if self._owns_client:
51
+ self._http.close()
52
+
53
+ def version(self) -> VersionInfo:
54
+ return self._request_model("GET", "/version", VersionInfo.from_dict, authenticated=False)
55
+
56
+ def ready(self) -> ReadyStatus:
57
+ result = self._request_model("GET", "/readyz", ReadyStatus.from_dict, authenticated=False)
58
+ if result.status != "ready":
59
+ raise KovaProtocolError(f"unexpected readiness status {result.status!r}")
60
+ return result
61
+
62
+ def create_build(self, request: CreateBuildRequest) -> BuildJob:
63
+ return self._request_model("POST", "/v1/builds", BuildJob.from_dict, json=request.to_dict())
64
+
65
+ def get_build(self, build_id: str) -> BuildJob:
66
+ return self._get_model(_build_path(build_id), BuildJob.from_dict)
67
+
68
+ def list_builds(self, *, limit: int = 100, continue_token: str | None = None) -> JobList:
69
+ _validate_limit(limit)
70
+ params: dict[str, str | int] = {"limit": limit}
71
+ if continue_token:
72
+ params["continue"] = continue_token
73
+ return self._request_model("GET", "/v1/builds", JobList.from_dict, params=params)
74
+
75
+ def get_results(self, build_id: str) -> BuildResults:
76
+ return self._get_model(_build_path(build_id, "results"), BuildResults.from_dict)
77
+
78
+ def get_logs(self, build_id: str, *, tail_lines: int = 100) -> str:
79
+ _validate_tail_lines(tail_lines)
80
+ raw = self._request("GET", _build_path(build_id, "logs"), params={"tail_lines": tail_lines})
81
+ return decode_text(raw)
82
+
83
+ def cancel_build(self, build_id: str) -> BuildJob:
84
+ return self._request_model("POST", _build_path(build_id, "cancel"), BuildJob.from_dict)
85
+
86
+ def wait_build(
87
+ self,
88
+ build_id: str,
89
+ *,
90
+ timeout: float | None = None,
91
+ poll_interval: float = 2.0,
92
+ cancel_event: threading.Event | None = None,
93
+ ) -> BuildJob:
94
+ deadline = _deadline(timeout, poll_interval)
95
+ while True:
96
+ _check_sync_cancel(cancel_event)
97
+ try:
98
+ job = self.get_build(build_id)
99
+ except KovaAPIError as error:
100
+ if not error.retryable:
101
+ raise
102
+ delay = _retry_delay(error, poll_interval)
103
+ else:
104
+ if job.status in _TERMINAL_STATUSES:
105
+ return job
106
+ delay = poll_interval
107
+ _sync_sleep(delay, deadline, cancel_event)
108
+
109
+ def _get_model(self, path: str, factory: ModelFactory[Model]) -> Model:
110
+ return self._request_model("GET", path, factory)
111
+
112
+ def _request_model(
113
+ self,
114
+ method: str,
115
+ path: str,
116
+ factory: ModelFactory[Model],
117
+ authenticated: bool = True,
118
+ **kwargs: Any,
119
+ ) -> Model:
120
+ raw = self._request(method, path, authenticated=authenticated, **kwargs)
121
+ try:
122
+ return factory(decode_json(raw))
123
+ except KovaProtocolError:
124
+ raise
125
+ except (TypeError, ValueError) as error:
126
+ raise KovaProtocolError("Kova Service response does not satisfy API v1") from error
127
+
128
+ def _request(
129
+ self, method: str, path: str, *, authenticated: bool = True, **kwargs: Any
130
+ ) -> bytes:
131
+ with self._http.stream(
132
+ method,
133
+ self._config.base_url + path,
134
+ headers=_headers(self._config, authenticated=authenticated),
135
+ **kwargs,
136
+ ) as response:
137
+ limit = self._config.max_response_bytes if response.is_success else ERROR_BODY_LIMIT
138
+ try:
139
+ raw = read_bounded(response.iter_bytes(), limit)
140
+ except KovaProtocolError:
141
+ if response.is_success:
142
+ raise
143
+ raw = b""
144
+ if not response.is_success:
145
+ raise api_error(response.status_code, response.headers, raw)
146
+ return raw
147
+
148
+
149
+ class AsyncKovaClient:
150
+ """Asynchronous client with the same contract as KovaClient."""
151
+
152
+ def __init__(
153
+ self,
154
+ config: ClientConfig,
155
+ *,
156
+ http_client: httpx.AsyncClient | None = None,
157
+ ):
158
+ self._config = config
159
+ self._owns_client = http_client is None
160
+ self._http = http_client or httpx.AsyncClient(
161
+ timeout=config.request_timeout,
162
+ verify=config.ssl_context(),
163
+ follow_redirects=False,
164
+ )
165
+
166
+ async def __aenter__(self) -> AsyncKovaClient:
167
+ return self
168
+
169
+ async def __aexit__(self, *_: object) -> None:
170
+ await self.aclose()
171
+
172
+ async def aclose(self) -> None:
173
+ if self._owns_client:
174
+ await self._http.aclose()
175
+
176
+ async def version(self) -> VersionInfo:
177
+ return await self._request_model(
178
+ "GET", "/version", VersionInfo.from_dict, authenticated=False
179
+ )
180
+
181
+ async def ready(self) -> ReadyStatus:
182
+ result = await self._request_model(
183
+ "GET", "/readyz", ReadyStatus.from_dict, authenticated=False
184
+ )
185
+ if result.status != "ready":
186
+ raise KovaProtocolError(f"unexpected readiness status {result.status!r}")
187
+ return result
188
+
189
+ async def create_build(self, request: CreateBuildRequest) -> BuildJob:
190
+ return await self._request_model(
191
+ "POST", "/v1/builds", BuildJob.from_dict, json=request.to_dict()
192
+ )
193
+
194
+ async def get_build(self, build_id: str) -> BuildJob:
195
+ return await self._get_model(_build_path(build_id), BuildJob.from_dict)
196
+
197
+ async def list_builds(
198
+ self,
199
+ *,
200
+ limit: int = 100,
201
+ continue_token: str | None = None,
202
+ ) -> JobList:
203
+ _validate_limit(limit)
204
+ params: dict[str, str | int] = {"limit": limit}
205
+ if continue_token:
206
+ params["continue"] = continue_token
207
+ return await self._request_model("GET", "/v1/builds", JobList.from_dict, params=params)
208
+
209
+ async def get_results(self, build_id: str) -> BuildResults:
210
+ return await self._get_model(_build_path(build_id, "results"), BuildResults.from_dict)
211
+
212
+ async def get_logs(self, build_id: str, *, tail_lines: int = 100) -> str:
213
+ _validate_tail_lines(tail_lines)
214
+ raw = await self._request(
215
+ "GET", _build_path(build_id, "logs"), params={"tail_lines": tail_lines}
216
+ )
217
+ return decode_text(raw)
218
+
219
+ async def cancel_build(self, build_id: str) -> BuildJob:
220
+ return await self._request_model(
221
+ "POST", _build_path(build_id, "cancel"), BuildJob.from_dict
222
+ )
223
+
224
+ async def wait_build(
225
+ self,
226
+ build_id: str,
227
+ *,
228
+ timeout: float | None = None,
229
+ poll_interval: float = 2.0,
230
+ cancel_event: asyncio.Event | None = None,
231
+ ) -> BuildJob:
232
+ deadline = _deadline(timeout, poll_interval)
233
+ while True:
234
+ _check_async_cancel(cancel_event)
235
+ try:
236
+ job = await self.get_build(build_id)
237
+ except KovaAPIError as error:
238
+ if not error.retryable:
239
+ raise
240
+ delay = _retry_delay(error, poll_interval)
241
+ else:
242
+ if job.status in _TERMINAL_STATUSES:
243
+ return job
244
+ delay = poll_interval
245
+ await _async_sleep(delay, deadline, cancel_event)
246
+
247
+ async def _get_model(self, path: str, factory: ModelFactory[Model]) -> Model:
248
+ return await self._request_model("GET", path, factory)
249
+
250
+ async def _request_model(
251
+ self,
252
+ method: str,
253
+ path: str,
254
+ factory: ModelFactory[Model],
255
+ authenticated: bool = True,
256
+ **kwargs: Any,
257
+ ) -> Model:
258
+ raw = await self._request(method, path, authenticated=authenticated, **kwargs)
259
+ try:
260
+ return factory(decode_json(raw))
261
+ except KovaProtocolError:
262
+ raise
263
+ except (TypeError, ValueError) as error:
264
+ raise KovaProtocolError("Kova Service response does not satisfy API v1") from error
265
+
266
+ async def _request(
267
+ self, method: str, path: str, *, authenticated: bool = True, **kwargs: Any
268
+ ) -> bytes:
269
+ async with self._http.stream(
270
+ method,
271
+ self._config.base_url + path,
272
+ headers=_headers(self._config, authenticated=authenticated),
273
+ **kwargs,
274
+ ) as response:
275
+ limit = self._config.max_response_bytes if response.is_success else ERROR_BODY_LIMIT
276
+ content = bytearray()
277
+ async for chunk in response.aiter_bytes():
278
+ if len(content) + len(chunk) > limit:
279
+ if response.is_success:
280
+ raise KovaProtocolError(f"Kova Service response exceeds {limit} bytes")
281
+ content.clear()
282
+ break
283
+ content.extend(chunk)
284
+ raw = bytes(content)
285
+ if not response.is_success:
286
+ raise api_error(response.status_code, response.headers, raw)
287
+ return raw
288
+
289
+
290
+ def _headers(config: ClientConfig, *, authenticated: bool) -> dict[str, str]:
291
+ headers = {"Accept": "application/json", "User-Agent": "kova-client-python"}
292
+ if authenticated and config.token:
293
+ headers["Authorization"] = f"Bearer {config.token}"
294
+ return headers
295
+
296
+
297
+ def _build_path(build_id: str, action: str | None = None) -> str:
298
+ normalized = build_id.strip()
299
+ if len(normalized) > 253 or not _JOB_ID.fullmatch(normalized):
300
+ raise ValueError(f"invalid build ID {build_id!r}")
301
+ path = f"/v1/builds/{normalized}"
302
+ return f"{path}/{action}" if action else path
303
+
304
+
305
+ def _validate_limit(limit: int) -> None:
306
+ if not 1 <= limit <= 500:
307
+ raise ValueError("limit must be between 1 and 500")
308
+
309
+
310
+ def _validate_tail_lines(tail_lines: int) -> None:
311
+ if not 0 <= tail_lines <= 10_000:
312
+ raise ValueError("tail_lines must be between 0 and 10000")
313
+
314
+
315
+ def _deadline(timeout: float | None, poll_interval: float) -> float | None:
316
+ if poll_interval <= 0:
317
+ raise ValueError("poll_interval must be greater than zero")
318
+ if timeout is None:
319
+ return None
320
+ if timeout < 0:
321
+ raise ValueError("timeout must be non-negative")
322
+ return time.monotonic() + timeout
323
+
324
+
325
+ def _retry_delay(error: KovaAPIError, poll_interval: float) -> float:
326
+ if error.retry_after is None:
327
+ return poll_interval
328
+ return max(error.retry_after.total_seconds(), 0.0)
329
+
330
+
331
+ def _remaining(deadline: float | None, delay: float) -> float:
332
+ if deadline is None:
333
+ return delay
334
+ remaining = deadline - time.monotonic()
335
+ if remaining <= 0:
336
+ raise TimeoutError("timed out waiting for Kova build")
337
+ return min(delay, remaining)
338
+
339
+
340
+ def _check_sync_cancel(cancel_event: threading.Event | None) -> None:
341
+ if cancel_event is not None and cancel_event.is_set():
342
+ raise KovaWaitCancelled("wait for Kova build was cancelled")
343
+
344
+
345
+ def _sync_sleep(
346
+ delay: float,
347
+ deadline: float | None,
348
+ cancel_event: threading.Event | None,
349
+ ) -> None:
350
+ wait_for = _remaining(deadline, delay)
351
+ if cancel_event is not None:
352
+ if cancel_event.wait(wait_for):
353
+ raise KovaWaitCancelled("wait for Kova build was cancelled")
354
+ else:
355
+ time.sleep(wait_for)
356
+ if deadline is not None and time.monotonic() >= deadline:
357
+ raise TimeoutError("timed out waiting for Kova build")
358
+
359
+
360
+ def _check_async_cancel(cancel_event: asyncio.Event | None) -> None:
361
+ if cancel_event is not None and cancel_event.is_set():
362
+ raise KovaWaitCancelled("wait for Kova build was cancelled")
363
+
364
+
365
+ async def _async_sleep(
366
+ delay: float,
367
+ deadline: float | None,
368
+ cancel_event: asyncio.Event | None,
369
+ ) -> None:
370
+ wait_for = _remaining(deadline, delay)
371
+ if cancel_event is None:
372
+ await asyncio.sleep(wait_for)
373
+ else:
374
+ try:
375
+ await asyncio.wait_for(cancel_event.wait(), timeout=wait_for)
376
+ except asyncio.TimeoutError:
377
+ pass
378
+ else:
379
+ raise KovaWaitCancelled("wait for Kova build was cancelled")
380
+ if deadline is not None and time.monotonic() >= deadline:
381
+ raise TimeoutError("timed out waiting for Kova build")
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import ssl
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from urllib.parse import urlsplit, urlunsplit
8
+
9
+ DEFAULT_MAX_RESPONSE_BYTES = 64 << 20
10
+ MAX_MAX_RESPONSE_BYTES = 1 << 30
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ClientConfig:
15
+ """Explicit Kova Service connection settings."""
16
+
17
+ base_url: str
18
+ token: str | None = field(default=None, repr=False)
19
+ ca_file: str | None = None
20
+ insecure: bool = False
21
+ request_timeout: float = 30.0
22
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES
23
+
24
+ def __post_init__(self) -> None:
25
+ parsed = urlsplit(self.base_url.strip())
26
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
27
+ raise ValueError("service URL must be an absolute http or https URL")
28
+ if parsed.username is not None or parsed.password is not None:
29
+ raise ValueError("service URL must not contain credentials")
30
+ if parsed.query or parsed.fragment:
31
+ raise ValueError("service URL must not contain a query or fragment")
32
+ if self.ca_file and self.insecure:
33
+ raise ValueError("ca_file and insecure cannot be used together")
34
+ if self.request_timeout <= 0:
35
+ raise ValueError("request_timeout must be greater than zero")
36
+ if not 1 <= self.max_response_bytes <= MAX_MAX_RESPONSE_BYTES:
37
+ raise ValueError(f"max_response_bytes must be between 1 and {MAX_MAX_RESPONSE_BYTES}")
38
+ normalized = urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
39
+ object.__setattr__(self, "base_url", normalized)
40
+ token = self.token.strip() if self.token else None
41
+ object.__setattr__(self, "token", token or None)
42
+ ca_file = self.ca_file.strip() if self.ca_file else None
43
+ object.__setattr__(self, "ca_file", ca_file or None)
44
+
45
+ @classmethod
46
+ def from_env(cls, env: Mapping[str, str] | None = None) -> ClientConfig:
47
+ """Explicitly construct configuration from the documented KOVA_SERVICE_* variables."""
48
+
49
+ values = os.environ if env is None else env
50
+ base_url = values.get("KOVA_SERVICE_URL", "").strip()
51
+ if not base_url:
52
+ raise ValueError("KOVA_SERVICE_URL is required")
53
+ return cls(
54
+ base_url=base_url,
55
+ token=values.get("KOVA_SERVICE_TOKEN"),
56
+ ca_file=values.get("KOVA_SERVICE_CA_FILE"),
57
+ insecure=_parse_bool(values.get("KOVA_SERVICE_INSECURE", ""), "KOVA_SERVICE_INSECURE"),
58
+ )
59
+
60
+ def ssl_context(self) -> ssl.SSLContext | bool:
61
+ if self.insecure:
62
+ return False
63
+ if self.ca_file:
64
+ return ssl.create_default_context(cafile=self.ca_file)
65
+ return True
66
+
67
+
68
+ def _parse_bool(value: str, name: str) -> bool:
69
+ normalized = value.strip().lower()
70
+ if normalized in {"", "0", "false", "no", "off"}:
71
+ return False
72
+ if normalized in {"1", "true", "yes", "on"}:
73
+ return True
74
+ raise ValueError(f"{name} must be a boolean")
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+
6
+ class KovaAPIError(Exception):
7
+ """A structured error returned by the Kova Service."""
8
+
9
+ __slots__ = ("code", "message", "retry_after", "retryable", "status_code")
10
+
11
+ def __init__(
12
+ self,
13
+ *,
14
+ status_code: int,
15
+ code: str,
16
+ message: str,
17
+ retryable: bool,
18
+ retry_after: timedelta | None = None,
19
+ ) -> None:
20
+ self.status_code = status_code
21
+ self.code = code
22
+ self.message = message
23
+ self.retryable = retryable
24
+ self.retry_after = retry_after
25
+ super().__init__(message)
26
+
27
+ def __str__(self) -> str:
28
+ return f"Kova Service API {self.code} (HTTP {self.status_code}): {self.message}"
29
+
30
+
31
+ class KovaProtocolError(Exception):
32
+ """The Service returned a response that does not satisfy HTTP API v1."""
33
+
34
+
35
+ class KovaWaitCancelled(Exception):
36
+ """A caller-owned cancellation signal stopped wait_build."""
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ JSON = dict[str, Any]
10
+
11
+
12
+ class JobStatus(str, Enum):
13
+ QUEUED = "queued"
14
+ STARTING = "starting"
15
+ RUNNING = "running"
16
+ SUCCEEDED = "succeeded"
17
+ FAILED = "failed"
18
+ CANCELLED = "cancelled"
19
+
20
+
21
+ class BuildFailureCode(str, Enum):
22
+ INVALID_SOURCE = "invalid_source"
23
+ INVALID_TARGETS = "invalid_targets"
24
+ RUNNER_UNAVAILABLE = "runner_unavailable"
25
+ BUILD_SUBMISSION_FAILED = "build_submission_failed"
26
+ RESULT_VERIFICATION_FAILED = "result_verification_failed"
27
+ BUILD_FAILED = "build_failed"
28
+ CANCELLED = "cancelled"
29
+
30
+
31
+ class BuildFormat(str, Enum):
32
+ OCI = "oci"
33
+ NYDUS = "nydus"
34
+ BOTH = "both"
35
+
36
+
37
+ class OutputFormat(str, Enum):
38
+ OCI = "oci"
39
+ NYDUS = "nydus"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class CreateBuildRequest:
44
+ source_uri: str
45
+ source_digest: str
46
+ targets: tuple[str, ...]
47
+ concurrency: int
48
+ format: BuildFormat | str = BuildFormat.OCI
49
+ timeout: int | None = None
50
+ oom_cooldown: str | None = None
51
+ fail_fast: bool | None = None
52
+ verbose: bool | None = None
53
+ variables: tuple[str, ...] = ()
54
+ idempotency_key: str | None = None
55
+
56
+ def to_dict(self) -> JSON:
57
+ payload: JSON = {
58
+ "source_uri": self.source_uri,
59
+ "source_digest": self.source_digest,
60
+ "targets": list(self.targets),
61
+ "format": _enum_value(self.format),
62
+ "concurrency": self.concurrency,
63
+ }
64
+ _put_if_not_none(payload, "timeout", self.timeout)
65
+ _put_if_not_none(payload, "oom_cooldown", self.oom_cooldown)
66
+ _put_if_not_none(payload, "fail_fast", self.fail_fast)
67
+ _put_if_not_none(payload, "verbose", self.verbose)
68
+ if self.variables:
69
+ payload["variables"] = list(self.variables)
70
+ _put_if_not_none(payload, "idempotency_key", self.idempotency_key)
71
+ return payload
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class VersionInfo:
76
+ api_version: str
77
+ version: str
78
+ commit: str
79
+ build_date: str
80
+
81
+ @classmethod
82
+ def from_dict(cls, value: JSON) -> VersionInfo:
83
+ _require_fields(value, {"api_version", "version", "commit", "build_date"})
84
+ return cls(
85
+ api_version=_string(value, "api_version"),
86
+ version=_string(value, "version"),
87
+ commit=_string(value, "commit"),
88
+ build_date=_string(value, "build_date"),
89
+ )
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class ReadyStatus:
94
+ status: str
95
+
96
+ @classmethod
97
+ def from_dict(cls, value: JSON) -> ReadyStatus:
98
+ _require_fields(value, {"status"})
99
+ return cls(status=_string(value, "status"))
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class BuildJob:
104
+ id: str
105
+ status: JobStatus
106
+ created_at: datetime
107
+ requester: str
108
+ error: str | None = None
109
+ failure_code: BuildFailureCode | None = None
110
+ started_at: datetime | None = None
111
+ finished_at: datetime | None = None
112
+ expires_at: datetime | None = None
113
+ source_digest: str | None = None
114
+ source_uri: str | None = None
115
+ idempotency_key: str | None = None
116
+ cancellation_requested: bool = False
117
+ requested_concurrency: int | None = None
118
+ allocated_concurrency: int | None = None
119
+
120
+ @classmethod
121
+ def from_dict(cls, value: JSON) -> BuildJob:
122
+ _require_fields(value, {"id", "status", "created_at", "requester"}, _BUILD_JOB_FIELDS)
123
+ failure = value.get("failure_code")
124
+ return cls(
125
+ id=_string(value, "id"),
126
+ status=JobStatus(_string(value, "status")),
127
+ created_at=_datetime(value, "created_at"),
128
+ requester=_string(value, "requester"),
129
+ error=_optional_string(value, "error"),
130
+ failure_code=BuildFailureCode(failure) if failure is not None else None,
131
+ started_at=_optional_datetime(value, "started_at"),
132
+ finished_at=_optional_datetime(value, "finished_at"),
133
+ expires_at=_optional_datetime(value, "expires_at"),
134
+ source_digest=_optional_digest(value, "source_digest"),
135
+ source_uri=_optional_string(value, "source_uri"),
136
+ idempotency_key=_optional_string(value, "idempotency_key"),
137
+ cancellation_requested=_optional_bool(value, "cancellation_requested") or False,
138
+ requested_concurrency=_optional_int(value, "requested_concurrency"),
139
+ allocated_concurrency=_optional_int(value, "allocated_concurrency"),
140
+ )
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class BuildOutput:
145
+ format: OutputFormat
146
+ image: str
147
+ manifest_digest: str
148
+ immutable_ref: str
149
+
150
+ @classmethod
151
+ def from_dict(cls, value: JSON) -> BuildOutput:
152
+ _require_fields(value, {"format", "image", "manifest_digest", "immutable_ref"})
153
+ manifest_digest = _digest(value, "manifest_digest")
154
+ immutable_ref = _string(value, "immutable_ref")
155
+ if not _IMMUTABLE_REFERENCE.fullmatch(immutable_ref):
156
+ raise ValueError("immutable_ref must be a manifest-pinned OCI reference")
157
+ if not immutable_ref.endswith("@" + manifest_digest):
158
+ raise ValueError("immutable_ref digest must match manifest_digest")
159
+ return cls(
160
+ format=OutputFormat(_string(value, "format")),
161
+ image=_string(value, "image"),
162
+ manifest_digest=manifest_digest,
163
+ immutable_ref=immutable_ref,
164
+ )
165
+
166
+
167
+ @dataclass(frozen=True, slots=True)
168
+ class BuildResults:
169
+ id: str
170
+ source_uri: str
171
+ source_digest: str
172
+ outputs: tuple[BuildOutput, ...]
173
+ idempotency_key: str | None = None
174
+
175
+ @classmethod
176
+ def from_dict(cls, value: JSON) -> BuildResults:
177
+ _require_fields(
178
+ value,
179
+ {"id", "source_uri", "source_digest", "outputs"},
180
+ {"id", "source_uri", "source_digest", "outputs", "idempotency_key"},
181
+ )
182
+ outputs = value["outputs"]
183
+ if not isinstance(outputs, list):
184
+ raise TypeError("outputs must be an array")
185
+ if len(outputs) > 200:
186
+ raise ValueError("outputs must contain at most 200 items")
187
+ return cls(
188
+ id=_string(value, "id"),
189
+ source_uri=_string(value, "source_uri"),
190
+ source_digest=_digest(value, "source_digest"),
191
+ outputs=tuple(BuildOutput.from_dict(_object(item)) for item in outputs),
192
+ idempotency_key=_optional_string(value, "idempotency_key"),
193
+ )
194
+
195
+
196
+ @dataclass(frozen=True, slots=True)
197
+ class JobList:
198
+ jobs: tuple[BuildJob, ...]
199
+ continue_token: str | None = None
200
+
201
+ @classmethod
202
+ def from_dict(cls, value: JSON) -> JobList:
203
+ _require_fields(value, {"jobs"}, {"jobs", "continue"})
204
+ jobs = value["jobs"]
205
+ if not isinstance(jobs, list):
206
+ raise TypeError("jobs must be an array")
207
+ if len(jobs) > 500:
208
+ raise ValueError("jobs must contain at most 500 items")
209
+ return cls(
210
+ jobs=tuple(BuildJob.from_dict(_object(item)) for item in jobs),
211
+ continue_token=_optional_string(value, "continue"),
212
+ )
213
+
214
+
215
+ _BUILD_JOB_FIELDS = {
216
+ "id",
217
+ "status",
218
+ "error",
219
+ "failure_code",
220
+ "created_at",
221
+ "started_at",
222
+ "finished_at",
223
+ "expires_at",
224
+ "source_digest",
225
+ "source_uri",
226
+ "idempotency_key",
227
+ "requester",
228
+ "cancellation_requested",
229
+ "requested_concurrency",
230
+ "allocated_concurrency",
231
+ }
232
+ _DIGEST = re.compile(r"^sha256:[a-f0-9]{64}$")
233
+ _IMMUTABLE_REFERENCE = re.compile(r"^.+@sha256:[a-f0-9]{64}$")
234
+
235
+
236
+ def _enum_value(value: str | Enum) -> str:
237
+ return str(value.value) if isinstance(value, Enum) else value
238
+
239
+
240
+ def _put_if_not_none(payload: JSON, key: str, value: Any) -> None:
241
+ if value is not None:
242
+ payload[key] = value
243
+
244
+
245
+ def _object(value: Any) -> JSON:
246
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
247
+ raise TypeError("response must be a JSON object")
248
+ return value
249
+
250
+
251
+ def _require_fields(value: JSON, required: set[str], allowed: set[str] | None = None) -> None:
252
+ missing = required - value.keys()
253
+ if missing:
254
+ raise TypeError(f"response is missing fields: {', '.join(sorted(missing))}")
255
+ unexpected = value.keys() - (allowed or required)
256
+ if unexpected:
257
+ raise TypeError(f"response has unexpected fields: {', '.join(sorted(unexpected))}")
258
+
259
+
260
+ def _string(value: JSON, key: str) -> str:
261
+ result = value[key]
262
+ if not isinstance(result, str):
263
+ raise TypeError(f"{key} must be a string")
264
+ return result
265
+
266
+
267
+ def _optional_string(value: JSON, key: str) -> str | None:
268
+ if key not in value:
269
+ return None
270
+ return _string(value, key)
271
+
272
+
273
+ def _optional_int(value: JSON, key: str) -> int | None:
274
+ if key not in value:
275
+ return None
276
+ result = value[key]
277
+ if not isinstance(result, int) or isinstance(result, bool):
278
+ raise TypeError(f"{key} must be an integer")
279
+ return result
280
+
281
+
282
+ def _optional_bool(value: JSON, key: str) -> bool | None:
283
+ if key not in value:
284
+ return None
285
+ result = value[key]
286
+ if not isinstance(result, bool):
287
+ raise TypeError(f"{key} must be a boolean")
288
+ return result
289
+
290
+
291
+ def _digest(value: JSON, key: str) -> str:
292
+ result = _string(value, key)
293
+ if not _DIGEST.fullmatch(result):
294
+ raise ValueError(f"{key} must be a SHA-256 digest")
295
+ return result
296
+
297
+
298
+ def _optional_digest(value: JSON, key: str) -> str | None:
299
+ if key not in value:
300
+ return None
301
+ return _digest(value, key)
302
+
303
+
304
+ def _datetime(value: JSON, key: str) -> datetime:
305
+ raw = _string(value, key)
306
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
307
+ if parsed.tzinfo is None:
308
+ raise ValueError(f"{key} must include a timezone")
309
+ return parsed
310
+
311
+
312
+ def _optional_datetime(value: JSON, key: str) -> datetime | None:
313
+ if key not in value:
314
+ return None
315
+ return _datetime(value, key)
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterable, Mapping
5
+ from datetime import datetime, timedelta, timezone
6
+ from email.utils import parsedate_to_datetime
7
+ from http import HTTPStatus
8
+ from typing import Any
9
+
10
+ from ._errors import KovaAPIError, KovaProtocolError
11
+
12
+ ERROR_BODY_LIMIT = 1 << 20
13
+
14
+
15
+ def decode_json(raw: bytes) -> dict[str, Any]:
16
+ try:
17
+ value = json.loads(raw)
18
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
19
+ raise KovaProtocolError("Kova Service returned invalid JSON") from error
20
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
21
+ raise KovaProtocolError("Kova Service response must be a JSON object")
22
+ return value
23
+
24
+
25
+ def decode_text(raw: bytes) -> str:
26
+ try:
27
+ return raw.decode("utf-8")
28
+ except UnicodeDecodeError as error:
29
+ raise KovaProtocolError("Kova Service returned non-UTF-8 logs") from error
30
+
31
+
32
+ def read_bounded(chunks: Iterable[bytes], max_bytes: int) -> bytes:
33
+ content = bytearray()
34
+ for chunk in chunks:
35
+ if len(content) + len(chunk) > max_bytes:
36
+ raise KovaProtocolError(f"Kova Service response exceeds {max_bytes} bytes")
37
+ content.extend(chunk)
38
+ return bytes(content)
39
+
40
+
41
+ def api_error(status_code: int, headers: Mapping[str, str], raw: bytes) -> KovaAPIError:
42
+ code = _default_error_code(status_code)
43
+ message = _status_text(status_code)
44
+ retryable = status_code == 429 or status_code >= 500
45
+ try:
46
+ payload = json.loads(raw)
47
+ except (UnicodeDecodeError, json.JSONDecodeError):
48
+ payload = None
49
+ if isinstance(payload, dict):
50
+ if isinstance(payload.get("code"), str) and payload["code"]:
51
+ code = payload["code"]
52
+ if isinstance(payload.get("message"), str) and payload["message"].strip():
53
+ message = payload["message"].strip()
54
+ if isinstance(payload.get("retryable"), bool):
55
+ retryable = payload["retryable"]
56
+ return KovaAPIError(
57
+ status_code=status_code,
58
+ code=code,
59
+ message=message,
60
+ retryable=retryable,
61
+ retry_after=parse_retry_after(headers.get("Retry-After")),
62
+ )
63
+
64
+
65
+ def parse_retry_after(value: str | None, *, now: datetime | None = None) -> timedelta | None:
66
+ if not value:
67
+ return None
68
+ normalized = value.strip()
69
+ try:
70
+ seconds = int(normalized)
71
+ except ValueError:
72
+ seconds = -1
73
+ if seconds >= 0:
74
+ return timedelta(seconds=seconds) if seconds > 0 else None
75
+ try:
76
+ when = parsedate_to_datetime(normalized)
77
+ except (TypeError, ValueError, OverflowError):
78
+ return None
79
+ if when.tzinfo is None:
80
+ when = when.replace(tzinfo=timezone.utc)
81
+ current = now or datetime.now(timezone.utc)
82
+ delay = when - current
83
+ return delay if delay.total_seconds() > 0 else None
84
+
85
+
86
+ def _default_error_code(status_code: int) -> str:
87
+ return {
88
+ 400: "invalid_request",
89
+ 405: "invalid_request",
90
+ 401: "unauthenticated",
91
+ 403: "forbidden",
92
+ 404: "not_found",
93
+ 409: "conflict",
94
+ 429: "queue_capacity_exceeded",
95
+ 410: "logs_unavailable",
96
+ }.get(status_code, "internal")
97
+
98
+
99
+ def _status_text(status_code: int) -> str:
100
+ try:
101
+ return HTTPStatus(status_code).phrase
102
+ except ValueError:
103
+ return "request failed"
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0rc7'
22
+ __version_tuple__ = version_tuple = (0, 1, 0, 'rc7')
23
+
24
+ __commit_id__ = commit_id = None