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 CHANGED
@@ -1,5 +1,5 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
  __author__ = "Wherobots"
5
5
  __email__ = "support@wherobots.com"
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: Any) -> None:
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 if subdir else "dependencies",
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
- botocore_session._credentials = creds
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: Any) -> None:
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: Any) -> None:
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: Any) -> None:
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 if val else None
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
@@ -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.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", # local file — auto-uploaded via presigned URL
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_...", # or set WHEROBOTS_API_KEY env var
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", # local script auto-upload still works
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, # already-uploaded 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
- python -m venv .venv
632
- source .venv/bin/activate
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 # mypy (strict)
643
- make check # lint + typecheck + tests + build
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,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,,