wherobots-python-sdk 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- wherobots/__version__.py +1 -1
- wherobots/api/base.py +17 -10
- wherobots/api/files.py +11 -10
- wherobots/api/runs.py +8 -8
- wherobots/client.py +34 -22
- wherobots/config.py +15 -4
- wherobots/exceptions.py +3 -3
- wherobots/models.py +22 -20
- wherobots/utils/client_header.py +205 -0
- wherobots/utils/validation.py +1 -1
- {wherobots_python_sdk-0.3.0.dist-info → wherobots_python_sdk-0.4.0.dist-info}/METADATA +57 -18
- wherobots_python_sdk-0.4.0.dist-info/RECORD +21 -0
- {wherobots_python_sdk-0.3.0.dist-info → wherobots_python_sdk-0.4.0.dist-info}/WHEEL +1 -1
- wherobots_python_sdk-0.3.0.dist-info/RECORD +0 -20
- {wherobots_python_sdk-0.3.0.dist-info → wherobots_python_sdk-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {wherobots_python_sdk-0.3.0.dist-info → wherobots_python_sdk-0.4.0.dist-info}/top_level.txt +0 -0
wherobots/__version__.py
CHANGED
wherobots/api/base.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
``BaseClient`` wraps a ``requests.Session`` with:
|
|
4
4
|
* Auth header construction (``X-API-Key``).
|
|
5
|
+
* Client attribution via the advisory ``X-Wherobots-Client`` header,
|
|
6
|
+
set once on the session so every outgoing request carries it.
|
|
5
7
|
* Automatic retry with exponential back-off for transient errors
|
|
6
8
|
(429 Too Many Requests, 503 Service Unavailable, connection errors).
|
|
7
9
|
* Unified error handling that maps HTTP failures into
|
|
@@ -11,15 +13,16 @@
|
|
|
11
13
|
from __future__ import annotations
|
|
12
14
|
|
|
13
15
|
import warnings
|
|
14
|
-
from typing import Any
|
|
16
|
+
from typing import Any # noqa: TID251
|
|
15
17
|
|
|
16
18
|
import requests
|
|
17
19
|
from requests.adapters import HTTPAdapter
|
|
18
20
|
from urllib3.util.retry import Retry
|
|
19
21
|
|
|
20
22
|
from wherobots.__version__ import __version__
|
|
21
|
-
from wherobots.config import WherobotsConfig
|
|
23
|
+
from wherobots.config import WherobotsConfig # noqa: TC001
|
|
22
24
|
from wherobots.exceptions import WherobotsAPIError, WherobotsConfigError
|
|
25
|
+
from wherobots.utils.client_header import CLIENT_HEADER, client_header_value
|
|
23
26
|
from wherobots.utils.logger import get_logger
|
|
24
27
|
|
|
25
28
|
logger = get_logger("api")
|
|
@@ -45,7 +48,7 @@ class BaseClient:
|
|
|
45
48
|
retries.
|
|
46
49
|
"""
|
|
47
50
|
|
|
48
|
-
def __init__(
|
|
51
|
+
def __init__( # noqa: D107
|
|
49
52
|
self,
|
|
50
53
|
config: WherobotsConfig,
|
|
51
54
|
max_retries: int = _DEFAULT_RETRIES,
|
|
@@ -94,11 +97,15 @@ class BaseClient:
|
|
|
94
97
|
# Intentionally not mounting on http:// — API keys must never
|
|
95
98
|
# be transmitted over plaintext HTTP.
|
|
96
99
|
|
|
97
|
-
# Common headers
|
|
100
|
+
# Common headers. ``X-Wherobots-Client`` is set here, on the
|
|
101
|
+
# session, so it rides along with every request this client makes
|
|
102
|
+
# instead of having to be threaded through each call site. Auth
|
|
103
|
+
# headers are merged last so nothing can displace them.
|
|
98
104
|
self._session.headers.update(
|
|
99
105
|
{
|
|
100
106
|
"Content-Type": "application/json",
|
|
101
107
|
"User-Agent": f"wherobots-python-sdk/{__version__}",
|
|
108
|
+
CLIENT_HEADER: client_header_value(config.client_chain),
|
|
102
109
|
**self._auth_headers(config.api_key),
|
|
103
110
|
}
|
|
104
111
|
)
|
|
@@ -154,7 +161,7 @@ class BaseClient:
|
|
|
154
161
|
timeout=self._timeout,
|
|
155
162
|
)
|
|
156
163
|
response.raise_for_status()
|
|
157
|
-
return response
|
|
164
|
+
return response # noqa: TRY300
|
|
158
165
|
except requests.HTTPError as exc:
|
|
159
166
|
response = exc.response
|
|
160
167
|
status_code = response.status_code if response is not None else None
|
|
@@ -224,15 +231,15 @@ class BaseClient:
|
|
|
224
231
|
self._session.close()
|
|
225
232
|
self._closed = True
|
|
226
233
|
|
|
227
|
-
def __enter__(self) -> BaseClient:
|
|
234
|
+
def __enter__(self) -> BaseClient: # noqa: PYI034
|
|
228
235
|
"""Enter the runtime context (context-manager protocol)."""
|
|
229
236
|
return self
|
|
230
237
|
|
|
231
|
-
def __exit__(self, *exc:
|
|
238
|
+
def __exit__(self, *exc: object) -> None:
|
|
232
239
|
"""Exit the runtime context, closing the session."""
|
|
233
240
|
self.close()
|
|
234
241
|
|
|
235
|
-
def __del__(self) -> None:
|
|
242
|
+
def __del__(self) -> None: # noqa: D105
|
|
236
243
|
# Mirror ``open()``/``socket``: warn callers who let the client
|
|
237
244
|
# get GC'd without closing it, so connection pools don't leak
|
|
238
245
|
# silently. Guarded against partial __init__ failure and
|
|
@@ -242,7 +249,7 @@ class BaseClient:
|
|
|
242
249
|
return
|
|
243
250
|
if getattr(self, "_session", None) is None:
|
|
244
251
|
return
|
|
245
|
-
except Exception:
|
|
252
|
+
except Exception: # noqa: BLE001
|
|
246
253
|
return
|
|
247
254
|
try:
|
|
248
255
|
warnings.warn(
|
|
@@ -252,5 +259,5 @@ class BaseClient:
|
|
|
252
259
|
stacklevel=2,
|
|
253
260
|
)
|
|
254
261
|
self._session.close()
|
|
255
|
-
except Exception:
|
|
262
|
+
except Exception: # noqa: BLE001, S110
|
|
256
263
|
pass
|
wherobots/api/files.py
CHANGED
|
@@ -19,7 +19,7 @@ import os
|
|
|
19
19
|
import tempfile
|
|
20
20
|
import uuid
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import Any, Literal
|
|
22
|
+
from typing import Any, Literal # noqa: TID251
|
|
23
23
|
from urllib.parse import urlparse
|
|
24
24
|
|
|
25
25
|
import boto3
|
|
@@ -28,7 +28,7 @@ from botocore.credentials import RefreshableCredentials
|
|
|
28
28
|
from botocore.session import Session as BotocoreSession
|
|
29
29
|
|
|
30
30
|
from wherobots.api.base import BaseClient
|
|
31
|
-
from wherobots.config import WherobotsConfig
|
|
31
|
+
from wherobots.config import WherobotsConfig # noqa: TC001
|
|
32
32
|
from wherobots.exceptions import WherobotsAPIError, WherobotsValidationError
|
|
33
33
|
from wherobots.models import StorageIntegration, TemporaryAwsCredentials
|
|
34
34
|
from wherobots.utils.logger import get_logger
|
|
@@ -49,7 +49,7 @@ class FilesAPI:
|
|
|
49
49
|
client: A configured ``BaseClient`` instance.
|
|
50
50
|
"""
|
|
51
51
|
|
|
52
|
-
def __init__(self, client: BaseClient) -> None:
|
|
52
|
+
def __init__(self, client: BaseClient) -> None: # noqa: D107
|
|
53
53
|
self._client = client
|
|
54
54
|
self._integrations_cache: list[StorageIntegration] | None = None
|
|
55
55
|
|
|
@@ -58,7 +58,7 @@ class FilesAPI:
|
|
|
58
58
|
# ------------------------------------------------------------------ #
|
|
59
59
|
|
|
60
60
|
@classmethod
|
|
61
|
-
def from_config(cls, config: WherobotsConfig, **kwargs: Any) -> FilesAPI:
|
|
61
|
+
def from_config(cls, config: WherobotsConfig, **kwargs: Any) -> FilesAPI: # noqa: ANN401
|
|
62
62
|
"""Build a ``FilesAPI`` from a ``WherobotsConfig``.
|
|
63
63
|
|
|
64
64
|
Args:
|
|
@@ -345,7 +345,7 @@ class FilesAPI:
|
|
|
345
345
|
return self.upload_managed_file(tmp_path, "scripts", dest_filename=filename)
|
|
346
346
|
finally:
|
|
347
347
|
with contextlib.suppress(OSError):
|
|
348
|
-
os.unlink(tmp_path)
|
|
348
|
+
os.unlink(tmp_path) # noqa: PTH108
|
|
349
349
|
|
|
350
350
|
def upload_dependency(
|
|
351
351
|
self,
|
|
@@ -399,7 +399,7 @@ class FilesAPI:
|
|
|
399
399
|
if storage_integration is None:
|
|
400
400
|
return self.upload_managed_file(
|
|
401
401
|
local_path,
|
|
402
|
-
subdir
|
|
402
|
+
subdir or "dependencies",
|
|
403
403
|
dest_filename=filename,
|
|
404
404
|
)
|
|
405
405
|
|
|
@@ -513,7 +513,8 @@ class FilesAPI:
|
|
|
513
513
|
method="wherobots-storage-credentials",
|
|
514
514
|
)
|
|
515
515
|
botocore_session = BotocoreSession()
|
|
516
|
-
|
|
516
|
+
# pyrefly: ignore [missing-attribute]
|
|
517
|
+
botocore_session._credentials = creds # noqa: SLF001
|
|
517
518
|
client = boto3.Session(botocore_session=botocore_session).client(
|
|
518
519
|
"s3", region_name=initial.region
|
|
519
520
|
)
|
|
@@ -527,11 +528,11 @@ class FilesAPI:
|
|
|
527
528
|
"""Close the underlying HTTP client and release resources."""
|
|
528
529
|
self._client.close()
|
|
529
530
|
|
|
530
|
-
def __enter__(self) -> FilesAPI:
|
|
531
|
+
def __enter__(self) -> FilesAPI: # noqa: PYI034
|
|
531
532
|
"""Enter the runtime context (context-manager protocol)."""
|
|
532
533
|
return self
|
|
533
534
|
|
|
534
|
-
def __exit__(self, *exc:
|
|
535
|
+
def __exit__(self, *exc: object) -> None:
|
|
535
536
|
"""Exit the runtime context, closing the client."""
|
|
536
537
|
self.close()
|
|
537
538
|
|
|
@@ -570,7 +571,7 @@ def _normalize_subdir(subdir: str | None) -> str:
|
|
|
570
571
|
Each segment is run through :func:`_validate_filename` so traversal
|
|
571
572
|
(``..``, ``\\``) is rejected. Leading/trailing slashes and empty
|
|
572
573
|
segments are stripped.
|
|
573
|
-
"""
|
|
574
|
+
""" # noqa: D301
|
|
574
575
|
if not subdir:
|
|
575
576
|
return ""
|
|
576
577
|
segments = [seg for seg in subdir.replace("\\", "/").split("/") if seg]
|
wherobots/api/runs.py
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import builtins
|
|
6
|
-
from typing import Any
|
|
5
|
+
import builtins # noqa: TC003
|
|
6
|
+
from typing import Any # noqa: TID251
|
|
7
7
|
from urllib.parse import quote as _url_quote
|
|
8
8
|
|
|
9
9
|
import requests
|
|
10
10
|
|
|
11
11
|
from wherobots.api.base import BaseClient
|
|
12
|
-
from wherobots.config import WherobotsConfig
|
|
12
|
+
from wherobots.config import WherobotsConfig # noqa: TC001
|
|
13
13
|
from wherobots.enums import JobStatus
|
|
14
14
|
from wherobots.exceptions import WherobotsAPIError
|
|
15
15
|
from wherobots.models import (
|
|
@@ -34,7 +34,7 @@ class RunsAPI:
|
|
|
34
34
|
client: A configured ``BaseClient`` instance.
|
|
35
35
|
"""
|
|
36
36
|
|
|
37
|
-
def __init__(self, client: BaseClient) -> None:
|
|
37
|
+
def __init__(self, client: BaseClient) -> None: # noqa: D107
|
|
38
38
|
self._client = client
|
|
39
39
|
|
|
40
40
|
# ------------------------------------------------------------------ #
|
|
@@ -80,7 +80,7 @@ class RunsAPI:
|
|
|
80
80
|
# ------------------------------------------------------------------ #
|
|
81
81
|
|
|
82
82
|
@classmethod
|
|
83
|
-
def from_config(cls, config: WherobotsConfig, **kwargs: Any) -> RunsAPI:
|
|
83
|
+
def from_config(cls, config: WherobotsConfig, **kwargs: Any) -> RunsAPI: # noqa: ANN401
|
|
84
84
|
"""Build a ``RunsAPI`` from a ``WherobotsConfig``.
|
|
85
85
|
|
|
86
86
|
Args:
|
|
@@ -197,7 +197,7 @@ class RunsAPI:
|
|
|
197
197
|
response = self._client.get(f"/runs/{safe_id}/metrics")
|
|
198
198
|
return RunMetricsResponse.from_dict(self._parse_json(response))
|
|
199
199
|
|
|
200
|
-
def list(
|
|
200
|
+
def list( # noqa: PLR0913
|
|
201
201
|
self,
|
|
202
202
|
region: str | None = None,
|
|
203
203
|
name_pattern: str | None = None,
|
|
@@ -248,10 +248,10 @@ class RunsAPI:
|
|
|
248
248
|
"""Close the underlying HTTP client and release resources."""
|
|
249
249
|
self._client.close()
|
|
250
250
|
|
|
251
|
-
def __enter__(self) -> RunsAPI:
|
|
251
|
+
def __enter__(self) -> RunsAPI: # noqa: PYI034
|
|
252
252
|
"""Enter the runtime context (context-manager protocol)."""
|
|
253
253
|
return self
|
|
254
254
|
|
|
255
|
-
def __exit__(self, *exc:
|
|
255
|
+
def __exit__(self, *exc: object) -> None:
|
|
256
256
|
"""Exit the runtime context, closing the client."""
|
|
257
257
|
self.close()
|
wherobots/client.py
CHANGED
|
@@ -8,8 +8,8 @@ delegating HTTP work to the ``RunsAPI`` layer.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import time
|
|
11
|
-
from collections.abc import Callable, Iterator
|
|
12
|
-
from typing import Any
|
|
11
|
+
from collections.abc import Callable, Iterator # noqa: TC003
|
|
12
|
+
from typing import Any # noqa: TID251
|
|
13
13
|
|
|
14
14
|
from wherobots.api.runs import RunsAPI
|
|
15
15
|
from wherobots.config import WherobotsConfig
|
|
@@ -41,7 +41,8 @@ logger = get_logger("client")
|
|
|
41
41
|
def _status_str(status: JobStatus | str | None) -> str:
|
|
42
42
|
"""Human-readable status string for logging, handling the
|
|
43
43
|
forward-compat case where *status* is a raw string the enum
|
|
44
|
-
doesn't know about.
|
|
44
|
+
doesn't know about.
|
|
45
|
+
""" # noqa: D205
|
|
45
46
|
if status is None:
|
|
46
47
|
return "UNKNOWN"
|
|
47
48
|
if isinstance(status, JobStatus):
|
|
@@ -103,7 +104,7 @@ class WherobotsJob:
|
|
|
103
104
|
_config: WherobotsConfig
|
|
104
105
|
_api: RunsAPI
|
|
105
106
|
|
|
106
|
-
def __init__(
|
|
107
|
+
def __init__( # noqa: ANN204, PLR0913
|
|
107
108
|
self,
|
|
108
109
|
script: str,
|
|
109
110
|
name: str,
|
|
@@ -120,10 +121,11 @@ class WherobotsJob:
|
|
|
120
121
|
s3_bucket: str | None = None,
|
|
121
122
|
s3_prefix: str | None = None,
|
|
122
123
|
jar_main_class: str | None = None,
|
|
123
|
-
auto_upload: bool = True,
|
|
124
|
+
auto_upload: bool = True, # noqa: FBT001, FBT002
|
|
124
125
|
base_url: str | None = None,
|
|
125
126
|
request_timeout_seconds: int | None = None,
|
|
126
127
|
config: WherobotsConfig | None = None,
|
|
128
|
+
client_chain: str | None = None,
|
|
127
129
|
):
|
|
128
130
|
"""Initialize a Wherobots Job.
|
|
129
131
|
|
|
@@ -162,6 +164,10 @@ class WherobotsJob:
|
|
|
162
164
|
base_url: Override the API base URL
|
|
163
165
|
request_timeout_seconds: HTTP request timeout in seconds
|
|
164
166
|
config: Optional WherobotsConfig to override defaults
|
|
167
|
+
client_chain: Upstream ``X-Wherobots-Client`` chain for tools
|
|
168
|
+
that embed this SDK. The SDK appends its own hop to the
|
|
169
|
+
right of it so the originating client stays leftmost.
|
|
170
|
+
Advisory only — it never affects authentication.
|
|
165
171
|
|
|
166
172
|
Raises:
|
|
167
173
|
WherobotsValidationError: If script is None/empty, disk sizes
|
|
@@ -195,6 +201,7 @@ class WherobotsJob:
|
|
|
195
201
|
base_url=base_url,
|
|
196
202
|
version=version,
|
|
197
203
|
request_timeout_seconds=request_timeout_seconds,
|
|
204
|
+
client_chain=client_chain,
|
|
198
205
|
)
|
|
199
206
|
|
|
200
207
|
self.script = script
|
|
@@ -278,7 +285,7 @@ class WherobotsJob:
|
|
|
278
285
|
|
|
279
286
|
# Only reachable from submit(), which raises in attached mode
|
|
280
287
|
# before getting here. The asserts narrow ``str | None`` -> ``str``.
|
|
281
|
-
assert self.script is not None, "script must be set in submit-mode"
|
|
288
|
+
assert self.script is not None, "script must be set in submit-mode" # noqa: S101
|
|
282
289
|
|
|
283
290
|
if self._is_s3_uri(self.script):
|
|
284
291
|
self._script_uri = self.script
|
|
@@ -290,7 +297,7 @@ class WherobotsJob:
|
|
|
290
297
|
"Set auto_upload=True or provide an S3 URI."
|
|
291
298
|
)
|
|
292
299
|
|
|
293
|
-
assert self._script_uri is not None # all branches above set or raise
|
|
300
|
+
assert self._script_uri is not None # all branches above set or raise # noqa: S101
|
|
294
301
|
return self._script_uri
|
|
295
302
|
|
|
296
303
|
def _upload_local_script(self, local_path: str) -> str:
|
|
@@ -310,7 +317,7 @@ class WherobotsJob:
|
|
|
310
317
|
Raises:
|
|
311
318
|
WherobotsAPIError: If the credential fetch or upload fails.
|
|
312
319
|
"""
|
|
313
|
-
from wherobots.api.files import FilesAPI
|
|
320
|
+
from wherobots.api.files import FilesAPI # noqa: PLC0415
|
|
314
321
|
|
|
315
322
|
files_api = FilesAPI.from_config(self._config)
|
|
316
323
|
try:
|
|
@@ -339,7 +346,7 @@ class WherobotsJob:
|
|
|
339
346
|
"Local dependency files require auto_upload=True. "
|
|
340
347
|
"Either set auto_upload=True or pre-upload and pass an s3:// URI."
|
|
341
348
|
)
|
|
342
|
-
from wherobots.api.files import FilesAPI
|
|
349
|
+
from wherobots.api.files import FilesAPI # noqa: PLC0415
|
|
343
350
|
|
|
344
351
|
resolved: list[dict[str, Any]] = []
|
|
345
352
|
files_api = FilesAPI.from_config(self._config)
|
|
@@ -367,7 +374,7 @@ class WherobotsJob:
|
|
|
367
374
|
def _build_payload(self) -> CreateRunPayload:
|
|
368
375
|
# Only reachable from submit(); attached instances raise earlier.
|
|
369
376
|
# Runtime is optional — the API applies the org default when unset.
|
|
370
|
-
assert self.name is not None, "name must be set in submit-mode"
|
|
377
|
+
assert self.name is not None, "name must be set in submit-mode" # noqa: S101
|
|
371
378
|
|
|
372
379
|
self._prepare_dependencies()
|
|
373
380
|
script_uri = self._prepare_script_uri()
|
|
@@ -413,7 +420,7 @@ class WherobotsJob:
|
|
|
413
420
|
# ------------------------------------------------------------------ #
|
|
414
421
|
|
|
415
422
|
@classmethod
|
|
416
|
-
def from_run_id(
|
|
423
|
+
def from_run_id( # noqa: PLR0913
|
|
417
424
|
cls,
|
|
418
425
|
run_id: str,
|
|
419
426
|
*,
|
|
@@ -422,6 +429,7 @@ class WherobotsJob:
|
|
|
422
429
|
base_url: str | None = None,
|
|
423
430
|
region: str | None = None,
|
|
424
431
|
request_timeout_seconds: int | None = None,
|
|
432
|
+
client_chain: str | None = None,
|
|
425
433
|
) -> WherobotsJob:
|
|
426
434
|
"""Attach to an existing run for read-only log/metric access.
|
|
427
435
|
|
|
@@ -441,6 +449,9 @@ class WherobotsJob:
|
|
|
441
449
|
base_url: Override the API base URL.
|
|
442
450
|
region: AWS region override.
|
|
443
451
|
request_timeout_seconds: HTTP request timeout in seconds.
|
|
452
|
+
client_chain: Upstream ``X-Wherobots-Client`` chain for tools
|
|
453
|
+
that embed this SDK. The SDK appends its own hop to the
|
|
454
|
+
right of it so the originating client stays leftmost.
|
|
444
455
|
|
|
445
456
|
Returns:
|
|
446
457
|
A ``WherobotsJob`` bound to *run_id* with descriptive
|
|
@@ -461,6 +472,7 @@ class WherobotsJob:
|
|
|
461
472
|
region=region,
|
|
462
473
|
base_url=base_url,
|
|
463
474
|
request_timeout_seconds=request_timeout_seconds,
|
|
475
|
+
client_chain=client_chain,
|
|
464
476
|
)
|
|
465
477
|
self._api = RunsAPI.from_config(self._config)
|
|
466
478
|
|
|
@@ -513,10 +525,10 @@ class WherobotsJob:
|
|
|
513
525
|
"""
|
|
514
526
|
self._api.close()
|
|
515
527
|
|
|
516
|
-
def __enter__(self) -> WherobotsJob:
|
|
528
|
+
def __enter__(self) -> WherobotsJob: # noqa: D105, PYI034
|
|
517
529
|
return self
|
|
518
530
|
|
|
519
|
-
def __exit__(self, *exc:
|
|
531
|
+
def __exit__(self, *exc: object) -> None: # noqa: D105
|
|
520
532
|
self.close()
|
|
521
533
|
|
|
522
534
|
# ------------------------------------------------------------------ #
|
|
@@ -671,9 +683,9 @@ class WherobotsJob:
|
|
|
671
683
|
f"iter_logs exceeded max_pages={max_pages} without reaching end of log stream"
|
|
672
684
|
)
|
|
673
685
|
|
|
674
|
-
def poll_for_logs(
|
|
686
|
+
def poll_for_logs( # noqa: C901, PLR0912
|
|
675
687
|
self,
|
|
676
|
-
follow: bool = True,
|
|
688
|
+
follow: bool = True, # noqa: FBT001, FBT002
|
|
677
689
|
interval: float = 2.0,
|
|
678
690
|
log_handler: Callable[[dict[str, Any]], None] | None = None,
|
|
679
691
|
max_errors: int = 10,
|
|
@@ -701,7 +713,7 @@ class WherobotsJob:
|
|
|
701
713
|
raise WherobotsJobError("Job not submitted. Call submit() first.")
|
|
702
714
|
|
|
703
715
|
def default_handler(item: dict[str, Any]) -> None:
|
|
704
|
-
print(item.get("raw", ""), flush=True)
|
|
716
|
+
print(item.get("raw", ""), flush=True) # noqa: T201
|
|
705
717
|
|
|
706
718
|
handler = log_handler or default_handler
|
|
707
719
|
consecutive_errors = 0
|
|
@@ -749,16 +761,16 @@ class WherobotsJob:
|
|
|
749
761
|
break
|
|
750
762
|
except WherobotsAPIError as exc:
|
|
751
763
|
# Non-transient HTTP errors should not be retried
|
|
752
|
-
if exc.status_code and 400 <= exc.status_code < 500 and exc.status_code != 429:
|
|
764
|
+
if exc.status_code and 400 <= exc.status_code < 500 and exc.status_code != 429: # noqa: PLR2004
|
|
753
765
|
raise
|
|
754
766
|
consecutive_errors += 1
|
|
755
|
-
logger.error("Error polling logs (%d/%d): %s", consecutive_errors, max_errors, exc)
|
|
767
|
+
logger.error("Error polling logs (%d/%d): %s", consecutive_errors, max_errors, exc) # noqa: TRY400
|
|
756
768
|
if consecutive_errors >= max_errors:
|
|
757
769
|
raise
|
|
758
770
|
time.sleep(interval)
|
|
759
771
|
except Exception as exc:
|
|
760
772
|
consecutive_errors += 1
|
|
761
|
-
logger.error("Error polling logs (%d/%d): %s", consecutive_errors, max_errors, exc)
|
|
773
|
+
logger.error("Error polling logs (%d/%d): %s", consecutive_errors, max_errors, exc) # noqa: TRY400
|
|
762
774
|
if consecutive_errors >= max_errors:
|
|
763
775
|
raise
|
|
764
776
|
time.sleep(interval)
|
|
@@ -779,10 +791,10 @@ class WherobotsJob:
|
|
|
779
791
|
|
|
780
792
|
return True
|
|
781
793
|
|
|
782
|
-
def wait_for_completion(
|
|
794
|
+
def wait_for_completion( # noqa: C901
|
|
783
795
|
self,
|
|
784
796
|
poll_interval: float = 5.0,
|
|
785
|
-
stream_logs: bool = True,
|
|
797
|
+
stream_logs: bool = True, # noqa: FBT001, FBT002
|
|
786
798
|
log_interval: float = 2.0,
|
|
787
799
|
max_wait_seconds: float | None = None,
|
|
788
800
|
) -> JobStatus | str:
|
|
@@ -846,7 +858,7 @@ class WherobotsJob:
|
|
|
846
858
|
# ------------------------------------------------------------------ #
|
|
847
859
|
|
|
848
860
|
@classmethod
|
|
849
|
-
def list_runs(
|
|
861
|
+
def list_runs( # noqa: PLR0913
|
|
850
862
|
cls,
|
|
851
863
|
api_key: str | None = None,
|
|
852
864
|
region: str | None = None,
|
wherobots/config.py
CHANGED
|
@@ -21,6 +21,10 @@ class WherobotsConfig:
|
|
|
21
21
|
base_url: Wherobots API base URL (must use HTTPS).
|
|
22
22
|
version: API version string.
|
|
23
23
|
request_timeout_seconds: HTTP request timeout in seconds.
|
|
24
|
+
client_chain: Upstream ``X-Wherobots-Client`` chain for callers that
|
|
25
|
+
embed this SDK (e.g. the Airflow provider). The SDK appends its
|
|
26
|
+
own hop to the right of it, so the origin client stays leftmost.
|
|
27
|
+
Advisory only — it never affects authentication.
|
|
24
28
|
"""
|
|
25
29
|
|
|
26
30
|
api_key: str | None = None
|
|
@@ -30,6 +34,7 @@ class WherobotsConfig:
|
|
|
30
34
|
base_url: str = "https://api.cloud.wherobots.com"
|
|
31
35
|
version: str = "latest"
|
|
32
36
|
request_timeout_seconds: int = 30
|
|
37
|
+
client_chain: str | None = None
|
|
33
38
|
|
|
34
39
|
def __repr__(self) -> str:
|
|
35
40
|
"""Return a string representation with the API key masked."""
|
|
@@ -39,7 +44,7 @@ class WherobotsConfig:
|
|
|
39
44
|
# enough that those 4 characters don't meaningfully leak
|
|
40
45
|
# the secret. For keys of 8 characters or fewer, mask the
|
|
41
46
|
# entire value.
|
|
42
|
-
masked_key = f"***{self.api_key[-4:]}" if len(self.api_key) > 8 else "***"
|
|
47
|
+
masked_key = f"***{self.api_key[-4:]}" if len(self.api_key) > 8 else "***" # noqa: PLR2004
|
|
43
48
|
return (
|
|
44
49
|
f"WherobotsConfig("
|
|
45
50
|
f"api_key={masked_key!r}, "
|
|
@@ -48,7 +53,8 @@ class WherobotsConfig:
|
|
|
48
53
|
f"s3_prefix={self.s3_prefix!r}, "
|
|
49
54
|
f"base_url={self.base_url!r}, "
|
|
50
55
|
f"version={self.version!r}, "
|
|
51
|
-
f"request_timeout_seconds={self.request_timeout_seconds!r}
|
|
56
|
+
f"request_timeout_seconds={self.request_timeout_seconds!r}, "
|
|
57
|
+
f"client_chain={self.client_chain!r})"
|
|
52
58
|
)
|
|
53
59
|
|
|
54
60
|
def __post_init__(self) -> None:
|
|
@@ -74,7 +80,7 @@ class WherobotsConfig:
|
|
|
74
80
|
raise WherobotsConfigError(f"base_url must be an https:// URL. Got: {self.base_url}")
|
|
75
81
|
|
|
76
82
|
@classmethod
|
|
77
|
-
def from_env(
|
|
83
|
+
def from_env( # noqa: PLR0913
|
|
78
84
|
cls,
|
|
79
85
|
api_key: str | None = None,
|
|
80
86
|
region: str | None = None,
|
|
@@ -83,6 +89,7 @@ class WherobotsConfig:
|
|
|
83
89
|
base_url: str | None = None,
|
|
84
90
|
version: str | None = None,
|
|
85
91
|
request_timeout_seconds: int | None = None,
|
|
92
|
+
client_chain: str | None = None,
|
|
86
93
|
) -> WherobotsConfig:
|
|
87
94
|
"""Build configuration from environment variables with optional overrides.
|
|
88
95
|
|
|
@@ -98,6 +105,9 @@ class WherobotsConfig:
|
|
|
98
105
|
version: Override for ``WHEROBOTS_VERSION``.
|
|
99
106
|
request_timeout_seconds: Override for
|
|
100
107
|
``WHEROBOTS_REQUEST_TIMEOUT_SECONDS``.
|
|
108
|
+
client_chain: Override for ``WHEROBOTS_CLIENT_CHAIN`` — the
|
|
109
|
+
upstream ``X-Wherobots-Client`` chain this SDK appends its
|
|
110
|
+
own hop to.
|
|
101
111
|
|
|
102
112
|
Returns:
|
|
103
113
|
A validated ``WherobotsConfig`` instance.
|
|
@@ -110,7 +120,7 @@ class WherobotsConfig:
|
|
|
110
120
|
def _env(var: str) -> str | None:
|
|
111
121
|
"""Return env var value, treating empty strings as None."""
|
|
112
122
|
val = os.environ.get(var)
|
|
113
|
-
return val
|
|
123
|
+
return val or None
|
|
114
124
|
|
|
115
125
|
# Parse timeout with error handling for malformed env var
|
|
116
126
|
if request_timeout_seconds is not None:
|
|
@@ -162,4 +172,5 @@ class WherobotsConfig:
|
|
|
162
172
|
or "https://api.cloud.wherobots.com",
|
|
163
173
|
version=version or _env("WHEROBOTS_VERSION") or "latest",
|
|
164
174
|
request_timeout_seconds=timeout,
|
|
175
|
+
client_chain=client_chain or _env("WHEROBOTS_CLIENT_CHAIN"),
|
|
165
176
|
)
|
wherobots/exceptions.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any # noqa: TID251
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class WherobotsJobError(Exception):
|
|
@@ -12,7 +12,7 @@ class WherobotsJobError(Exception):
|
|
|
12
12
|
class WherobotsAPIError(WherobotsJobError):
|
|
13
13
|
"""Raised when the Wherobots API returns an error response."""
|
|
14
14
|
|
|
15
|
-
def __init__(
|
|
15
|
+
def __init__( # noqa: ANN204, D107
|
|
16
16
|
self,
|
|
17
17
|
message: str,
|
|
18
18
|
status_code: int | None = None,
|
|
@@ -24,7 +24,7 @@ class WherobotsAPIError(WherobotsJobError):
|
|
|
24
24
|
self.request_id = request_id
|
|
25
25
|
super().__init__(message)
|
|
26
26
|
|
|
27
|
-
def __str__(self) -> str:
|
|
27
|
+
def __str__(self) -> str: # noqa: D105
|
|
28
28
|
parts = [super().__str__()]
|
|
29
29
|
if self.request_id:
|
|
30
30
|
parts.append(f"[request_id={self.request_id}]")
|
wherobots/models.py
CHANGED
|
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from dataclasses import dataclass, field
|
|
10
10
|
from datetime import datetime, timedelta, timezone
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any # noqa: TID251
|
|
12
12
|
|
|
13
13
|
from wherobots.enums import (
|
|
14
14
|
AppStatus,
|
|
@@ -362,13 +362,13 @@ class RunEventMeta:
|
|
|
362
362
|
end_timestamp: int | None = None
|
|
363
363
|
|
|
364
364
|
@classmethod
|
|
365
|
-
def from_dict(cls, data: dict[str, Any]) -> RunEventMeta:
|
|
365
|
+
def from_dict(cls, data: dict[str, Any]) -> RunEventMeta: # noqa: D102
|
|
366
366
|
return cls(
|
|
367
367
|
start_timestamp=data.get("start_timestamp"),
|
|
368
368
|
end_timestamp=data.get("end_timestamp"),
|
|
369
369
|
)
|
|
370
370
|
|
|
371
|
-
def to_dict(self) -> dict[str, Any]:
|
|
371
|
+
def to_dict(self) -> dict[str, Any]: # noqa: D102
|
|
372
372
|
d: dict[str, Any] = {}
|
|
373
373
|
if self.start_timestamp is not None:
|
|
374
374
|
d["start_timestamp"] = self.start_timestamp
|
|
@@ -389,7 +389,7 @@ class KubeAppEvent:
|
|
|
389
389
|
event_meta: RunEventMeta | None = None
|
|
390
390
|
|
|
391
391
|
@classmethod
|
|
392
|
-
def from_dict(cls, data: dict[str, Any]) -> KubeAppEvent:
|
|
392
|
+
def from_dict(cls, data: dict[str, Any]) -> KubeAppEvent: # noqa: D102
|
|
393
393
|
event_meta = None
|
|
394
394
|
if data.get("eventMeta") is not None:
|
|
395
395
|
event_meta = RunEventMeta.from_dict(data["eventMeta"])
|
|
@@ -402,7 +402,7 @@ class KubeAppEvent:
|
|
|
402
402
|
event_meta=event_meta,
|
|
403
403
|
)
|
|
404
404
|
|
|
405
|
-
def to_dict(self) -> dict[str, Any]:
|
|
405
|
+
def to_dict(self) -> dict[str, Any]: # noqa: D102
|
|
406
406
|
d: dict[str, Any] = {
|
|
407
407
|
"id": self.id,
|
|
408
408
|
"createTime": self.create_time,
|
|
@@ -440,7 +440,7 @@ class RunKubeApp:
|
|
|
440
440
|
message: str | None = None
|
|
441
441
|
|
|
442
442
|
@classmethod
|
|
443
|
-
def from_dict(cls, data: dict[str, Any]) -> RunKubeApp:
|
|
443
|
+
def from_dict(cls, data: dict[str, Any]) -> RunKubeApp: # noqa: D102
|
|
444
444
|
app_type: AppType | str | None = None
|
|
445
445
|
raw_app_type = data.get("appType")
|
|
446
446
|
if raw_app_type:
|
|
@@ -479,7 +479,7 @@ class RunKubeApp:
|
|
|
479
479
|
message=data.get("message"),
|
|
480
480
|
)
|
|
481
481
|
|
|
482
|
-
def to_dict(self) -> dict[str, Any]:
|
|
482
|
+
def to_dict(self) -> dict[str, Any]: # noqa: D102
|
|
483
483
|
d: dict[str, Any] = {
|
|
484
484
|
"id": self.id,
|
|
485
485
|
"createTime": self.create_time,
|
|
@@ -525,7 +525,7 @@ class OrganizationCustomer:
|
|
|
525
525
|
roles: list[str] = field(default_factory=list)
|
|
526
526
|
|
|
527
527
|
@classmethod
|
|
528
|
-
def from_dict(cls, data: dict[str, Any]) -> OrganizationCustomer:
|
|
528
|
+
def from_dict(cls, data: dict[str, Any]) -> OrganizationCustomer: # noqa: D102
|
|
529
529
|
return cls(
|
|
530
530
|
id=data.get("id") or "",
|
|
531
531
|
create_time=data.get("createTime") or "",
|
|
@@ -535,7 +535,7 @@ class OrganizationCustomer:
|
|
|
535
535
|
roles=list(data.get("roles") or []),
|
|
536
536
|
)
|
|
537
537
|
|
|
538
|
-
def to_dict(self) -> dict[str, Any]:
|
|
538
|
+
def to_dict(self) -> dict[str, Any]: # noqa: D102
|
|
539
539
|
d: dict[str, Any] = {
|
|
540
540
|
"id": self.id,
|
|
541
541
|
"createTime": self.create_time,
|
|
@@ -610,7 +610,7 @@ class RunView:
|
|
|
610
610
|
return self.complete_time
|
|
611
611
|
|
|
612
612
|
@classmethod
|
|
613
|
-
def from_dict(cls, data: dict[str, Any]) -> RunView:
|
|
613
|
+
def from_dict(cls, data: dict[str, Any]) -> RunView: # noqa: C901
|
|
614
614
|
"""Deserialize from API JSON, flattening the nested ``payload``.
|
|
615
615
|
|
|
616
616
|
Args:
|
|
@@ -634,7 +634,7 @@ class RunView:
|
|
|
634
634
|
status = raw_status
|
|
635
635
|
|
|
636
636
|
# Helper: look in payload first, then top-level (for flexibility).
|
|
637
|
-
def _get(key: str, default: Any = None) -> Any:
|
|
637
|
+
def _get(key: str, default: Any = None) -> Any: # noqa: ANN401
|
|
638
638
|
if key in payload:
|
|
639
639
|
return payload[key]
|
|
640
640
|
return data.get(key, default)
|
|
@@ -721,7 +721,7 @@ class RunView:
|
|
|
721
721
|
raw=dict(data),
|
|
722
722
|
)
|
|
723
723
|
|
|
724
|
-
def to_dict(self) -> dict[str, Any]:
|
|
724
|
+
def to_dict(self) -> dict[str, Any]: # noqa: C901, PLR0912
|
|
725
725
|
"""Serialize to the Wherobots API JSON format.
|
|
726
726
|
|
|
727
727
|
Returns:
|
|
@@ -1026,27 +1026,29 @@ class UtilizationStats:
|
|
|
1026
1026
|
return []
|
|
1027
1027
|
|
|
1028
1028
|
@staticmethod
|
|
1029
|
-
def _coerce_point(item: Any) -> tuple[float, float] | None:
|
|
1029
|
+
def _coerce_point(item: Any) -> tuple[float, float] | None: # noqa: ANN401
|
|
1030
1030
|
"""Coerce a single series entry to ``(timestamp, value)``.
|
|
1031
1031
|
|
|
1032
1032
|
Accepts ``[ts, value]`` / ``(ts, value)`` tuples and dicts
|
|
1033
1033
|
with ``timestamp``/``value`` (or ``t``/``v``) keys. Returns
|
|
1034
1034
|
``None`` for shapes that can't be coerced — silently skipped.
|
|
1035
1035
|
"""
|
|
1036
|
-
if isinstance(item, (list, tuple)) and len(item) == 2:
|
|
1036
|
+
if isinstance(item, (list, tuple)) and len(item) == 2: # noqa: PLR2004
|
|
1037
1037
|
ts, value = item
|
|
1038
1038
|
elif isinstance(item, dict):
|
|
1039
1039
|
ts = item.get("timestamp", item.get("t"))
|
|
1040
1040
|
value = item.get("value", item.get("v"))
|
|
1041
1041
|
else:
|
|
1042
1042
|
return None
|
|
1043
|
+
if ts is None or value is None:
|
|
1044
|
+
return None
|
|
1043
1045
|
try:
|
|
1044
1046
|
return float(ts), float(value)
|
|
1045
1047
|
except (TypeError, ValueError):
|
|
1046
1048
|
return None
|
|
1047
1049
|
|
|
1048
1050
|
|
|
1049
|
-
def _unwrap_metric_envelope(value: Any) -> Any:
|
|
1051
|
+
def _unwrap_metric_envelope(value: Any) -> Any: # noqa: ANN401
|
|
1050
1052
|
"""Strip the server's ``{display_name, metric: {data, format}}`` wrapper.
|
|
1051
1053
|
|
|
1052
1054
|
The Wherobots metrics endpoint wraps each metric as
|
|
@@ -1268,8 +1270,8 @@ class TemporaryAwsCredentials:
|
|
|
1268
1270
|
expiration: datetime
|
|
1269
1271
|
region: str
|
|
1270
1272
|
|
|
1271
|
-
def __repr__(self) -> str:
|
|
1272
|
-
masked = f"***{self.access_key_id[-4:]}" if len(self.access_key_id) > 4 else "***"
|
|
1273
|
+
def __repr__(self) -> str: # noqa: D105
|
|
1274
|
+
masked = f"***{self.access_key_id[-4:]}" if len(self.access_key_id) > 4 else "***" # noqa: PLR2004
|
|
1273
1275
|
return (
|
|
1274
1276
|
f"TemporaryAwsCredentials(access_key_id={masked!r}, "
|
|
1275
1277
|
f"expiration={self.expiration.isoformat()!r}, region={self.region!r})"
|
|
@@ -1295,19 +1297,19 @@ class TemporaryAwsCredentials:
|
|
|
1295
1297
|
object.__setattr__(self, "session_token", "")
|
|
1296
1298
|
object.__setattr__(self, "expiration", datetime.now(timezone.utc) - timedelta(seconds=1))
|
|
1297
1299
|
|
|
1298
|
-
def __reduce__(self) -> Any:
|
|
1300
|
+
def __reduce__(self) -> Any: # noqa: ANN401, D105
|
|
1299
1301
|
raise TypeError(
|
|
1300
1302
|
"TemporaryAwsCredentials cannot be pickled. "
|
|
1301
1303
|
"Re-fetch credentials in the consumer process."
|
|
1302
1304
|
)
|
|
1303
1305
|
|
|
1304
|
-
def __copy__(self) -> Any:
|
|
1306
|
+
def __copy__(self) -> Any: # noqa: ANN401, D105
|
|
1305
1307
|
raise TypeError(
|
|
1306
1308
|
"TemporaryAwsCredentials cannot be copied. "
|
|
1307
1309
|
"Fetch a fresh set via FilesAPI.get_storage_credentials()."
|
|
1308
1310
|
)
|
|
1309
1311
|
|
|
1310
|
-
def __deepcopy__(self, _memo: dict[int, Any]) -> Any:
|
|
1312
|
+
def __deepcopy__(self, _memo: dict[int, Any]) -> Any: # noqa: ANN401, D105
|
|
1311
1313
|
raise TypeError(
|
|
1312
1314
|
"TemporaryAwsCredentials cannot be deep-copied. "
|
|
1313
1315
|
"Fetch a fresh set via FilesAPI.get_storage_credentials()."
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Build the advisory ``X-Wherobots-Client`` request header.
|
|
2
|
+
|
|
3
|
+
``X-Wherobots-Client`` is the shared, cross-client attribution header. It
|
|
4
|
+
carries an ordered, comma-separated chain of hops modelled on
|
|
5
|
+
``X-Forwarded-For``: the **leftmost** hop is the origin client and every
|
|
6
|
+
component that forwards the request **appends its own hop on the right**::
|
|
7
|
+
|
|
8
|
+
client=airflow;ver=1.2.0, client=python-sdk;ver=0.3.0;plat=linux
|
|
9
|
+
|
|
10
|
+
Each hop is ``client=<token>`` plus optional ``;key=value`` parameters. The
|
|
11
|
+
convention defines ``ver``, ``plat`` and ``cmd``; this SDK emits ``ver`` and
|
|
12
|
+
``plat`` — there is no subcommand for it to name. Commas and semicolons are
|
|
13
|
+
the delimiters, so they never appear inside a value, and neither do control
|
|
14
|
+
characters, which an HTTP header field-value cannot carry at all.
|
|
15
|
+
|
|
16
|
+
The header is **advisory only**: it is client-asserted, used for attribution
|
|
17
|
+
and analytics, and must never influence authentication or authorization.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import platform
|
|
23
|
+
from typing import Final
|
|
24
|
+
|
|
25
|
+
from wherobots.__version__ import __version__
|
|
26
|
+
|
|
27
|
+
#: Canonical name of the shared, cross-service client-chain header.
|
|
28
|
+
CLIENT_HEADER: Final[str] = "X-Wherobots-Client"
|
|
29
|
+
|
|
30
|
+
#: This SDK's stable client token for the ``client=`` segment. Renaming it
|
|
31
|
+
#: splits its analytics history, so it never changes.
|
|
32
|
+
CLIENT_NAME: Final[str] = "python-sdk"
|
|
33
|
+
|
|
34
|
+
# The server treats a header value longer than this (in bytes) as malformed
|
|
35
|
+
# and records ``unknown`` for the whole chain, so we never emit more:
|
|
36
|
+
# oversized upstream chains are trimmed from the left instead. Every value is
|
|
37
|
+
# scrubbed to ASCII, so bytes and characters count the same here.
|
|
38
|
+
MAX_HEADER_BYTES: Final[int] = 512
|
|
39
|
+
|
|
40
|
+
# Individual values are kept well under the 64-character bound the header
|
|
41
|
+
# convention places on a client token.
|
|
42
|
+
MAX_VALUE_CHARS: Final[int] = 63
|
|
43
|
+
|
|
44
|
+
_HOP_SEPARATOR: Final[str] = ", "
|
|
45
|
+
_DELIMITERS: Final[tuple[str, ...]] = (",", ";")
|
|
46
|
+
_DELIMITER_REPLACEMENT: Final[str] = "_"
|
|
47
|
+
|
|
48
|
+
# An HTTP header field-value cannot carry a control character. `requests`
|
|
49
|
+
# refuses to send one at all (`InvalidHeader`), so a stray CR or LF anywhere in
|
|
50
|
+
# the chain would turn advisory attribution into a hard failure on every
|
|
51
|
+
# request -- and against a server that did accept it, a CRLF is the classic
|
|
52
|
+
# header-injection primitive. The whole C0 set plus DEL is replaced, not just
|
|
53
|
+
# the two that bite, since none of it is meaningful inside a hop.
|
|
54
|
+
_CONTROL_CHARACTERS: Final[dict[int, str]] = dict.fromkeys(
|
|
55
|
+
(*range(0x20), 0x7F), _DELIMITER_REPLACEMENT
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _scrub(value: str) -> str:
|
|
60
|
+
"""Reduce *value* to something a header can actually carry.
|
|
61
|
+
|
|
62
|
+
Everything outside ASCII is replaced, for two independent reasons:
|
|
63
|
+
|
|
64
|
+
- ``http.client.putheader`` encodes a header value as **latin-1**, so a
|
|
65
|
+
non-latin-1 character raises ``UnicodeEncodeError`` at send time. That
|
|
66
|
+
is past ``requests``' own validation, which accepts the value, so it
|
|
67
|
+
surfaces as a raw ``UnicodeEncodeError`` from deep in the stack.
|
|
68
|
+
- A lone surrogate — what ``os.environ`` yields on Linux for a non-UTF-8
|
|
69
|
+
byte in ``WHEROBOTS_CLIENT_CHAIN`` — is not encodable at all, and raises
|
|
70
|
+
from the byte-length bound in ``client_header_value`` before the value
|
|
71
|
+
even reaches ``requests``.
|
|
72
|
+
|
|
73
|
+
ASCII rather than latin-1 because the hop grammar is ASCII by
|
|
74
|
+
construction, so nothing legitimate is lost, and it makes
|
|
75
|
+
``MAX_HEADER_BYTES`` unambiguous: one character is one byte in every
|
|
76
|
+
encoding involved.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
value: Raw text destined for the header.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The text with surrounding whitespace removed, every non-ASCII
|
|
83
|
+
character replaced by ``?``, and every control character by ``_``.
|
|
84
|
+
"""
|
|
85
|
+
ascii_only = value.encode("ascii", errors="replace").decode("ascii")
|
|
86
|
+
return ascii_only.strip().translate(_CONTROL_CHARACTERS)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _sanitize(value: str) -> str:
|
|
90
|
+
"""Make *value* safe to embed in a hop.
|
|
91
|
+
|
|
92
|
+
Strips surrounding whitespace, neutralizes control characters, replaces the
|
|
93
|
+
hop delimiters (``,`` and ``;``) so they cannot break the grammar, and
|
|
94
|
+
truncates to ``MAX_VALUE_CHARS``.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
value: Raw value to embed.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The sanitized value, possibly empty.
|
|
101
|
+
"""
|
|
102
|
+
cleaned = _scrub(value)
|
|
103
|
+
for delimiter in _DELIMITERS:
|
|
104
|
+
cleaned = cleaned.replace(delimiter, _DELIMITER_REPLACEMENT)
|
|
105
|
+
return cleaned[:MAX_VALUE_CHARS]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_version() -> str:
|
|
109
|
+
"""Return this SDK's version string, or ``""`` if it is unavailable.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The package version, or an empty string when the version cannot be
|
|
113
|
+
determined (in which case the ``ver`` parameter is omitted).
|
|
114
|
+
"""
|
|
115
|
+
return __version__.strip()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_platform() -> str:
|
|
119
|
+
"""Return the host platform token (``linux``, ``darwin``, ``windows``).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The lowercased platform name, or an empty string when the platform
|
|
123
|
+
cannot be determined (in which case ``plat`` is omitted).
|
|
124
|
+
"""
|
|
125
|
+
return platform.system().strip().lower()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_hop(
|
|
129
|
+
client: str = CLIENT_NAME,
|
|
130
|
+
version: str | None = None,
|
|
131
|
+
plat: str | None = None,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""Render a single hop of the form ``client=<token>;ver=<v>;plat=<p>``.
|
|
134
|
+
|
|
135
|
+
Parameters whose value is missing or empty are omitted rather than
|
|
136
|
+
emitted as a placeholder, so an unresolvable version simply means the
|
|
137
|
+
hop carries no ``ver``.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
client: Client token for this hop.
|
|
141
|
+
version: Client version, or ``None`` to resolve this SDK's version.
|
|
142
|
+
plat: Platform name, or ``None`` to resolve the host platform.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The rendered hop.
|
|
146
|
+
"""
|
|
147
|
+
resolved_version = resolve_version() if version is None else version
|
|
148
|
+
resolved_platform = resolve_platform() if plat is None else plat
|
|
149
|
+
|
|
150
|
+
segments = [f"client={_sanitize(client) or CLIENT_NAME}"]
|
|
151
|
+
sanitized_version = _sanitize(resolved_version)
|
|
152
|
+
if sanitized_version:
|
|
153
|
+
segments.append(f"ver={sanitized_version}")
|
|
154
|
+
sanitized_platform = _sanitize(resolved_platform)
|
|
155
|
+
if sanitized_platform:
|
|
156
|
+
segments.append(f"plat={sanitized_platform}")
|
|
157
|
+
return ";".join(segments)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _split_chain(chain: str | None) -> list[str]:
|
|
161
|
+
"""Split an inbound chain into its individual hops.
|
|
162
|
+
|
|
163
|
+
Hop *parameters* are preserved as-is — the chain is a record of what
|
|
164
|
+
upstream asserted, not something to re-render — but the text is scrubbed,
|
|
165
|
+
because an upstream chain is arbitrary caller-supplied input and this is
|
|
166
|
+
the only sanitization it ever gets before landing in a request header.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
chain: Raw ``X-Wherobots-Client`` value, or ``None``.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The non-empty hops, in order, stripped and free of control characters.
|
|
173
|
+
"""
|
|
174
|
+
if not chain:
|
|
175
|
+
return []
|
|
176
|
+
return [scrubbed for hop in chain.split(",") if (scrubbed := _scrub(hop))]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def client_header_value(upstream_chain: str | None = None, hop: str | None = None) -> str:
|
|
180
|
+
"""Build the full header value for a request this SDK is sending.
|
|
181
|
+
|
|
182
|
+
Any *upstream_chain* the caller supplies is preserved — scrubbed of
|
|
183
|
+
control characters, but otherwise untouched — and this SDK's hop is
|
|
184
|
+
appended on its right, so the origin stays leftmost. When
|
|
185
|
+
the result would exceed ``MAX_HEADER_BYTES`` the oldest (leftmost)
|
|
186
|
+
upstream hops are dropped until it fits — losing early provenance beats
|
|
187
|
+
the server discarding the whole chain as malformed.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
upstream_chain: Inbound ``X-Wherobots-Client`` value from a caller
|
|
191
|
+
that is forwarding a request through this SDK, if any.
|
|
192
|
+
hop: This SDK's hop, or ``None`` to build the default one.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The header value to send. Never empty: with no upstream chain it is
|
|
196
|
+
this SDK's single hop.
|
|
197
|
+
"""
|
|
198
|
+
own_hop = build_hop() if hop is None else hop
|
|
199
|
+
upstream_hops = _split_chain(upstream_chain)
|
|
200
|
+
while upstream_hops:
|
|
201
|
+
value = _HOP_SEPARATOR.join([*upstream_hops, own_hop])
|
|
202
|
+
if len(value.encode("utf-8")) <= MAX_HEADER_BYTES:
|
|
203
|
+
return value
|
|
204
|
+
upstream_hops.pop(0)
|
|
205
|
+
return own_hop
|
wherobots/utils/validation.py
CHANGED
|
@@ -23,7 +23,7 @@ def validate_name(name: str) -> str:
|
|
|
23
23
|
WherobotsValidationError: If the name is too short, too long,
|
|
24
24
|
or contains invalid characters.
|
|
25
25
|
"""
|
|
26
|
-
if not 8 <= len(name) <= 255:
|
|
26
|
+
if not 8 <= len(name) <= 255: # noqa: PLR2004
|
|
27
27
|
raise WherobotsValidationError("Name must be 8-255 characters")
|
|
28
28
|
|
|
29
29
|
if not NAME_PATTERN.match(name):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wherobots-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python SDK for Wherobots (currently covers the Jobs REST API)
|
|
5
5
|
Author-email: Wherobots <support@wherobots.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -23,13 +23,6 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: requests>=2.33.0
|
|
25
25
|
Requires-Dist: boto3>=1.34.0
|
|
26
|
-
Provides-Extra: dev
|
|
27
|
-
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
28
|
-
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
29
|
-
Requires-Dist: pytest-mock>=3.11.1; extra == "dev"
|
|
30
|
-
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
31
|
-
Requires-Dist: mypy>=1.5.0; extra == "dev"
|
|
32
|
-
Requires-Dist: types-requests>=2.31; extra == "dev"
|
|
33
26
|
Dynamic: license-file
|
|
34
27
|
|
|
35
28
|
# Wherobots Python SDK
|
|
@@ -67,11 +60,11 @@ The simplest way to run a local script — only an API key is needed:
|
|
|
67
60
|
from wherobots import WherobotsJob
|
|
68
61
|
|
|
69
62
|
job = WherobotsJob(
|
|
70
|
-
script="my_analysis.py",
|
|
63
|
+
script="my_analysis.py", # local file — auto-uploaded via presigned URL
|
|
71
64
|
name="analysis-job-001",
|
|
72
65
|
runtime="tiny",
|
|
73
66
|
region="aws-us-west-2",
|
|
74
|
-
api_key="wbk_...",
|
|
67
|
+
api_key="wbk_...", # or set WHEROBOTS_API_KEY env var
|
|
75
68
|
)
|
|
76
69
|
|
|
77
70
|
run_id = job.submit()
|
|
@@ -135,6 +128,7 @@ The API key can also be set via the `WHEROBOTS_API_KEY` environment variable.
|
|
|
135
128
|
| `WHEROBOTS_API_BASE_URL` | API base URL | `https://api.cloud.wherobots.com` |
|
|
136
129
|
| `WHEROBOTS_VERSION` | Wherobots version | `latest` |
|
|
137
130
|
| `WHEROBOTS_REQUEST_TIMEOUT_SECONDS`| HTTP request timeout (seconds) | `30` |
|
|
131
|
+
| `WHEROBOTS_CLIENT_CHAIN` | Upstream client-attribution chain (see below) | *(none)* |
|
|
138
132
|
|
|
139
133
|
### WherobotsConfig
|
|
140
134
|
|
|
@@ -164,6 +158,35 @@ config = WherobotsConfig(
|
|
|
164
158
|
| `base_url` | `str` | `"https://api.cloud.wherobots.com"` |
|
|
165
159
|
| `version` | `str` | `"latest"` |
|
|
166
160
|
| `request_timeout_seconds` | `int` | `30` |
|
|
161
|
+
| `client_chain` | `str \| None` | `None` |
|
|
162
|
+
|
|
163
|
+
### Client attribution
|
|
164
|
+
|
|
165
|
+
Every request the SDK sends carries an advisory `X-Wherobots-Client` header so
|
|
166
|
+
Wherobots can attribute workloads to the client that started them:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
X-Wherobots-Client: client=python-sdk;ver=0.3.0;plat=darwin
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The header is an ordered, comma-separated chain of hops — leftmost is the
|
|
173
|
+
origin client, and each component appends its own hop on the right. If your
|
|
174
|
+
tool embeds this SDK, pass your own hop as `client_chain` and the SDK appends
|
|
175
|
+
itself after it:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
job = WherobotsJob(
|
|
179
|
+
script="job.py",
|
|
180
|
+
name="my-job-name",
|
|
181
|
+
client_chain="client=airflow;ver=1.2.0",
|
|
182
|
+
)
|
|
183
|
+
# X-Wherobots-Client: client=airflow;ver=1.2.0, client=python-sdk;ver=0.3.0;plat=linux
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`client_chain` also works on `WherobotsJob.from_run_id(...)` and
|
|
187
|
+
`WherobotsConfig`, or can be set once via `WHEROBOTS_CLIENT_CHAIN`. The header
|
|
188
|
+
is advisory only: it is used for attribution and analytics and never affects
|
|
189
|
+
authentication or authorization.
|
|
167
190
|
|
|
168
191
|
## SDK Reference
|
|
169
192
|
|
|
@@ -192,6 +215,7 @@ WherobotsJob(
|
|
|
192
215
|
base_url: str | None = None, # Override API URL
|
|
193
216
|
request_timeout_seconds: int | None = None, # HTTP timeout
|
|
194
217
|
config: WherobotsConfig | None = None, # Full config object
|
|
218
|
+
client_chain: str | None = None, # Upstream X-Wherobots-Client chain
|
|
195
219
|
)
|
|
196
220
|
```
|
|
197
221
|
|
|
@@ -318,7 +342,7 @@ environment.
|
|
|
318
342
|
|
|
319
343
|
```python
|
|
320
344
|
job = WherobotsJob(
|
|
321
|
-
script="my_job.py",
|
|
345
|
+
script="my_job.py", # local script auto-upload still works
|
|
322
346
|
name="job-with-local-wheel",
|
|
323
347
|
dependencies=[
|
|
324
348
|
# Local wheel — uploaded to managed storage at submit() time
|
|
@@ -368,7 +392,7 @@ with FilesAPI.from_config(WherobotsConfig.from_env()) as files:
|
|
|
368
392
|
)
|
|
369
393
|
|
|
370
394
|
job = WherobotsJob(
|
|
371
|
-
script=script_uri,
|
|
395
|
+
script=script_uri, # already-uploaded URI
|
|
372
396
|
name="job-in-integration",
|
|
373
397
|
dependencies=[WherobotsJob.add_file_dependency(wheel_uri)],
|
|
374
398
|
)
|
|
@@ -628,10 +652,8 @@ Base URL: `https://api.cloud.wherobots.com`
|
|
|
628
652
|
```bash
|
|
629
653
|
git clone https://github.com/wherobots/wherobots-python-sdk.git
|
|
630
654
|
cd wherobots-python-sdk
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
pip install -e ".[dev]"
|
|
634
|
-
pre-commit install
|
|
655
|
+
uv sync --group dev
|
|
656
|
+
uv run pre-commit install
|
|
635
657
|
```
|
|
636
658
|
|
|
637
659
|
### Quality checks
|
|
@@ -639,10 +661,27 @@ pre-commit install
|
|
|
639
661
|
```bash
|
|
640
662
|
make lint # ruff check + ruff format --check
|
|
641
663
|
make format # auto-fix lint issues and reformat
|
|
642
|
-
make typecheck #
|
|
643
|
-
make
|
|
664
|
+
make typecheck # pyrefly (strict config)
|
|
665
|
+
make rails # import-linter + no-suppression + ast-grep
|
|
666
|
+
make check # lint + typecheck + rails + tests + build
|
|
644
667
|
```
|
|
645
668
|
|
|
669
|
+
### Suppression baseline
|
|
670
|
+
|
|
671
|
+
Pre-existing debt is grandfathered in `.slop-suppressions.json` (CODEOWNED): the inline
|
|
672
|
+
suppressions already in the tree, plus the `# noqa: CODE` / `# pyrefly: ignore` comments that
|
|
673
|
+
`ruff check --add-noqa` and `pyrefly check --suppress-errors` wrote for pre-existing violations.
|
|
674
|
+
The suppressions are functional at the tool level; the keystone
|
|
675
|
+
(`.slop-repellant/check_no_suppression.py`) fails any net-new one.
|
|
676
|
+
|
|
677
|
+
- **Prune** after fixing baselined code (drops stale entries):
|
|
678
|
+
- ruff's `RUF100` auto-removes stale noqas via the pre-commit hook's `--fix`
|
|
679
|
+
- `uv run pyrefly suppress --remove-unused`
|
|
680
|
+
- then `uv run python .slop-repellant/check_no_suppression.py --update`
|
|
681
|
+
- **Reset** (re-record from scratch): delete `.slop-suppressions.json`, then run the same `--update`.
|
|
682
|
+
- Shrinking the baseline is always welcome; **growing it requires CODEOWNER sign-off** — fix the
|
|
683
|
+
code instead of widening the baseline.
|
|
684
|
+
|
|
646
685
|
### Releasing
|
|
647
686
|
|
|
648
687
|
See [`RELEASING.md`](RELEASING.md) for the release flow (version
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
wherobots/__init__.py,sha256=TENVmuGg882ujMs7zj4bSKYHF3SOPXOfiqwLQEPggXE,1917
|
|
2
|
+
wherobots/__version__.py,sha256=k6ZfWdXQCuI__hhJ5rdjMmRn4JkT3w2Isev99qNJH5M,111
|
|
3
|
+
wherobots/client.py,sha256=mFShTtxXfCg9GLVMykM7jC1SvuDCZ_sY9JnHIOlOwxQ,37797
|
|
4
|
+
wherobots/config.py,sha256=d1QePrl7V0ooSlv_Ut0GJ5o0ShMBDH1k9yqgQ1cnxmE,7376
|
|
5
|
+
wherobots/enums.py,sha256=YsxXN3ZcY99aoEsUtjYs4otHDevsSPizAgCp-vShds8,4154
|
|
6
|
+
wherobots/exceptions.py,sha256=pwof-CAvDwXnQpSz6YiTptjNHvtWgBe1PproBbaz0lw,1348
|
|
7
|
+
wherobots/models.py,sha256=aZmEO1Kg0LPZf-F52hW13qmMArwQ3PhEODNgVlAXam8,46573
|
|
8
|
+
wherobots/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
wherobots/api/__init__.py,sha256=jUb587jazP_FA-sH_6yCYfvGxsNiUOyIYAPb3CHOYtQ,218
|
|
10
|
+
wherobots/api/base.py,sha256=-VZ7LkrjlpMdtFfG3Y6IM3dEdg0sYkjZviza7mboInM,9438
|
|
11
|
+
wherobots/api/files.py,sha256=_v89I8tJ_ANnxaTZd4AxtxrkZfZc0BDbL2jpmPBwvWw,23294
|
|
12
|
+
wherobots/api/runs.py,sha256=y0yF_h74S4ScebgCxHjT07kBlj8aIHlfYN6iBaBktgs,8654
|
|
13
|
+
wherobots/utils/__init__.py,sha256=cTM2QNJDauQWyGBTJPE5yH4ULc4Ucg_4KFzemjM3HaA,193
|
|
14
|
+
wherobots/utils/client_header.py,sha256=-zczKIl8QfFBs1uTLbzp6cGLEzit1qGgdWbV9MLYEcM,8083
|
|
15
|
+
wherobots/utils/logger.py,sha256=eclhD9Vf2LsSm4engRzT3jE2v0wdHJH01OfeI3ml4HM,1103
|
|
16
|
+
wherobots/utils/validation.py,sha256=mjWGGhNji1crdn1AhLZoTIfknSQm38X0MmgI4ow14T8,907
|
|
17
|
+
wherobots_python_sdk-0.4.0.dist-info/licenses/LICENSE,sha256=U1EH466XmvxbqaWOY0cax49w0R87_6w6yJLyV14v4bc,10767
|
|
18
|
+
wherobots_python_sdk-0.4.0.dist-info/METADATA,sha256=u3yXE1-I_-iD9tyXok-z4c06ebgxLqv8_OJchNDxj5E,28176
|
|
19
|
+
wherobots_python_sdk-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
+
wherobots_python_sdk-0.4.0.dist-info/top_level.txt,sha256=oL-n-9GIG0rmUeukuytUqdN6yfROIsVh8bNrgZsOdxw,10
|
|
21
|
+
wherobots_python_sdk-0.4.0.dist-info/RECORD,,
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
wherobots/__init__.py,sha256=TENVmuGg882ujMs7zj4bSKYHF3SOPXOfiqwLQEPggXE,1917
|
|
2
|
-
wherobots/__version__.py,sha256=A9eC_UJ-zhu-eILoQJoc98aUpWupHFzCb0uEptfx6vo,111
|
|
3
|
-
wherobots/client.py,sha256=ygQDcJjE5H_p8HGDex-rB1UkSZOWQUpDKvF6zrJfWWE,36742
|
|
4
|
-
wherobots/config.py,sha256=Ib20Y5mGsE4aP_g99ZKEyZvV8q-aOXq7RVpYP8i0lpo,6679
|
|
5
|
-
wherobots/enums.py,sha256=YsxXN3ZcY99aoEsUtjYs4otHDevsSPizAgCp-vShds8,4154
|
|
6
|
-
wherobots/exceptions.py,sha256=ERoTU7Edf-dFCE4-ScyuEcC-2O2d5JRa8issQWc-PNA,1296
|
|
7
|
-
wherobots/models.py,sha256=0LCiu7eWE7WnDNqsMdnn0cGiexNftElQvJrdqgZM4rg,46182
|
|
8
|
-
wherobots/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
wherobots/api/__init__.py,sha256=jUb587jazP_FA-sH_6yCYfvGxsNiUOyIYAPb3CHOYtQ,218
|
|
10
|
-
wherobots/api/base.py,sha256=IiT3k17G19bT0rM2zYZs9Tpob8pgTrSLAWdKlLUVM1o,8769
|
|
11
|
-
wherobots/api/files.py,sha256=n2mNF5V8UQuPEMnSHq5b3ObThbnkL9G5_oVjjcHAJxc,23134
|
|
12
|
-
wherobots/api/runs.py,sha256=ef5ouPGUsuaIIpBW3sHjkOXxfxTuOA6ieFjkMIuNAyc,8542
|
|
13
|
-
wherobots/utils/__init__.py,sha256=cTM2QNJDauQWyGBTJPE5yH4ULc4Ucg_4KFzemjM3HaA,193
|
|
14
|
-
wherobots/utils/logger.py,sha256=eclhD9Vf2LsSm4engRzT3jE2v0wdHJH01OfeI3ml4HM,1103
|
|
15
|
-
wherobots/utils/validation.py,sha256=R0iG4OqzER4prdP8-TOQV_pQFJ3emEQyWYYuc3aCefU,890
|
|
16
|
-
wherobots_python_sdk-0.3.0.dist-info/licenses/LICENSE,sha256=U1EH466XmvxbqaWOY0cax49w0R87_6w6yJLyV14v4bc,10767
|
|
17
|
-
wherobots_python_sdk-0.3.0.dist-info/METADATA,sha256=u4AIq4XLA3DnLmHHFyAqk9zSWQpDwAVxMc_hHtGxn34,26327
|
|
18
|
-
wherobots_python_sdk-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
19
|
-
wherobots_python_sdk-0.3.0.dist-info/top_level.txt,sha256=oL-n-9GIG0rmUeukuytUqdN6yfROIsVh8bNrgZsOdxw,10
|
|
20
|
-
wherobots_python_sdk-0.3.0.dist-info/RECORD,,
|
{wherobots_python_sdk-0.3.0.dist-info → wherobots_python_sdk-0.4.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|