wherobots-python-sdk 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/PKG-INFO +57 -18
  2. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/README.md +56 -10
  3. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/pyproject.toml +41 -30
  4. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_api.py +6 -6
  5. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_base_client.py +118 -0
  6. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_client.py +32 -0
  7. wherobots_python_sdk-0.4.0/tests/test_client_header.py +216 -0
  8. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_config.py +14 -0
  9. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_exceptions.py +2 -3
  10. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_files_api.py +2 -2
  11. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_integration.py +1 -1
  12. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_models.py +17 -0
  13. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_security.py +4 -4
  14. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/__version__.py +1 -1
  15. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/api/base.py +17 -10
  16. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/api/files.py +11 -10
  17. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/api/runs.py +8 -8
  18. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/client.py +34 -22
  19. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/config.py +15 -4
  20. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/exceptions.py +3 -3
  21. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/models.py +22 -20
  22. wherobots_python_sdk-0.4.0/wherobots/utils/client_header.py +205 -0
  23. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/utils/validation.py +1 -1
  24. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots_python_sdk.egg-info/PKG-INFO +57 -18
  25. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots_python_sdk.egg-info/SOURCES.txt +2 -0
  26. wherobots_python_sdk-0.4.0/wherobots_python_sdk.egg-info/requires.txt +2 -0
  27. wherobots_python_sdk-0.3.0/wherobots_python_sdk.egg-info/requires.txt +0 -10
  28. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/LICENSE +0 -0
  29. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/setup.cfg +0 -0
  30. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_enums.py +0 -0
  31. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_init_exports.py +0 -0
  32. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_logger.py +0 -0
  33. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_regressions.py +0 -0
  34. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/tests/test_utils.py +0 -0
  35. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/__init__.py +0 -0
  36. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/api/__init__.py +0 -0
  37. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/enums.py +0 -0
  38. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/py.typed +0 -0
  39. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/utils/__init__.py +0 -0
  40. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots/utils/logger.py +0 -0
  41. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots_python_sdk.egg-info/dependency_links.txt +0 -0
  42. {wherobots_python_sdk-0.3.0 → wherobots_python_sdk-0.4.0}/wherobots_python_sdk.egg-info/top_level.txt +0 -0
@@ -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
@@ -33,11 +33,11 @@ The simplest way to run a local script — only an API key is needed:
33
33
  from wherobots import WherobotsJob
34
34
 
35
35
  job = WherobotsJob(
36
- script="my_analysis.py", # local file — auto-uploaded via presigned URL
36
+ script="my_analysis.py", # local file — auto-uploaded via presigned URL
37
37
  name="analysis-job-001",
38
38
  runtime="tiny",
39
39
  region="aws-us-west-2",
40
- api_key="wbk_...", # or set WHEROBOTS_API_KEY env var
40
+ api_key="wbk_...", # or set WHEROBOTS_API_KEY env var
41
41
  )
42
42
 
43
43
  run_id = job.submit()
@@ -101,6 +101,7 @@ The API key can also be set via the `WHEROBOTS_API_KEY` environment variable.
101
101
  | `WHEROBOTS_API_BASE_URL` | API base URL | `https://api.cloud.wherobots.com` |
102
102
  | `WHEROBOTS_VERSION` | Wherobots version | `latest` |
103
103
  | `WHEROBOTS_REQUEST_TIMEOUT_SECONDS`| HTTP request timeout (seconds) | `30` |
104
+ | `WHEROBOTS_CLIENT_CHAIN` | Upstream client-attribution chain (see below) | *(none)* |
104
105
 
105
106
  ### WherobotsConfig
106
107
 
@@ -130,6 +131,35 @@ config = WherobotsConfig(
130
131
  | `base_url` | `str` | `"https://api.cloud.wherobots.com"` |
131
132
  | `version` | `str` | `"latest"` |
132
133
  | `request_timeout_seconds` | `int` | `30` |
134
+ | `client_chain` | `str \| None` | `None` |
135
+
136
+ ### Client attribution
137
+
138
+ Every request the SDK sends carries an advisory `X-Wherobots-Client` header so
139
+ Wherobots can attribute workloads to the client that started them:
140
+
141
+ ```
142
+ X-Wherobots-Client: client=python-sdk;ver=0.3.0;plat=darwin
143
+ ```
144
+
145
+ The header is an ordered, comma-separated chain of hops — leftmost is the
146
+ origin client, and each component appends its own hop on the right. If your
147
+ tool embeds this SDK, pass your own hop as `client_chain` and the SDK appends
148
+ itself after it:
149
+
150
+ ```python
151
+ job = WherobotsJob(
152
+ script="job.py",
153
+ name="my-job-name",
154
+ client_chain="client=airflow;ver=1.2.0",
155
+ )
156
+ # X-Wherobots-Client: client=airflow;ver=1.2.0, client=python-sdk;ver=0.3.0;plat=linux
157
+ ```
158
+
159
+ `client_chain` also works on `WherobotsJob.from_run_id(...)` and
160
+ `WherobotsConfig`, or can be set once via `WHEROBOTS_CLIENT_CHAIN`. The header
161
+ is advisory only: it is used for attribution and analytics and never affects
162
+ authentication or authorization.
133
163
 
134
164
  ## SDK Reference
135
165
 
@@ -158,6 +188,7 @@ WherobotsJob(
158
188
  base_url: str | None = None, # Override API URL
159
189
  request_timeout_seconds: int | None = None, # HTTP timeout
160
190
  config: WherobotsConfig | None = None, # Full config object
191
+ client_chain: str | None = None, # Upstream X-Wherobots-Client chain
161
192
  )
162
193
  ```
163
194
 
@@ -284,7 +315,7 @@ environment.
284
315
 
285
316
  ```python
286
317
  job = WherobotsJob(
287
- script="my_job.py", # local script auto-upload still works
318
+ script="my_job.py", # local script auto-upload still works
288
319
  name="job-with-local-wheel",
289
320
  dependencies=[
290
321
  # Local wheel — uploaded to managed storage at submit() time
@@ -334,7 +365,7 @@ with FilesAPI.from_config(WherobotsConfig.from_env()) as files:
334
365
  )
335
366
 
336
367
  job = WherobotsJob(
337
- script=script_uri, # already-uploaded URI
368
+ script=script_uri, # already-uploaded URI
338
369
  name="job-in-integration",
339
370
  dependencies=[WherobotsJob.add_file_dependency(wheel_uri)],
340
371
  )
@@ -594,10 +625,8 @@ Base URL: `https://api.cloud.wherobots.com`
594
625
  ```bash
595
626
  git clone https://github.com/wherobots/wherobots-python-sdk.git
596
627
  cd wherobots-python-sdk
597
- python -m venv .venv
598
- source .venv/bin/activate
599
- pip install -e ".[dev]"
600
- pre-commit install
628
+ uv sync --group dev
629
+ uv run pre-commit install
601
630
  ```
602
631
 
603
632
  ### Quality checks
@@ -605,10 +634,27 @@ pre-commit install
605
634
  ```bash
606
635
  make lint # ruff check + ruff format --check
607
636
  make format # auto-fix lint issues and reformat
608
- make typecheck # mypy (strict)
609
- make check # lint + typecheck + tests + build
637
+ make typecheck # pyrefly (strict config)
638
+ make rails # import-linter + no-suppression + ast-grep
639
+ make check # lint + typecheck + rails + tests + build
610
640
  ```
611
641
 
642
+ ### Suppression baseline
643
+
644
+ Pre-existing debt is grandfathered in `.slop-suppressions.json` (CODEOWNED): the inline
645
+ suppressions already in the tree, plus the `# noqa: CODE` / `# pyrefly: ignore` comments that
646
+ `ruff check --add-noqa` and `pyrefly check --suppress-errors` wrote for pre-existing violations.
647
+ The suppressions are functional at the tool level; the keystone
648
+ (`.slop-repellant/check_no_suppression.py`) fails any net-new one.
649
+
650
+ - **Prune** after fixing baselined code (drops stale entries):
651
+ - ruff's `RUF100` auto-removes stale noqas via the pre-commit hook's `--fix`
652
+ - `uv run pyrefly suppress --remove-unused`
653
+ - then `uv run python .slop-repellant/check_no_suppression.py --update`
654
+ - **Reset** (re-record from scratch): delete `.slop-suppressions.json`, then run the same `--update`.
655
+ - Shrinking the baseline is always welcome; **growing it requires CODEOWNER sign-off** — fix the
656
+ code instead of widening the baseline.
657
+
612
658
  ### Releasing
613
659
 
614
660
  See [`RELEASING.md`](RELEASING.md) for the release flow (version
@@ -35,13 +35,19 @@ Repository = "https://github.com/wherobots/wherobots-python-sdk"
35
35
  Issues = "https://github.com/wherobots/wherobots-python-sdk/issues"
36
36
  Documentation = "https://docs.wherobots.com"
37
37
 
38
- [project.optional-dependencies]
38
+ # PEP 735 dev dependencies — installed with `uv sync --group dev`.
39
+ [dependency-groups]
39
40
  dev = [
40
- "pytest>=7.4.0",
41
+ "pytest>=8.0",
41
42
  "pytest-cov>=4.1.0",
42
43
  "pytest-mock>=3.11.1",
43
- "ruff>=0.6.0",
44
- "mypy>=1.5.0",
44
+ "ruff>=0.15,<0.17",
45
+ "pyrefly>=0.16",
46
+ "import-linter>=2.1",
47
+ "pre-commit>=3.8",
48
+ "hypothesis>=6.100",
49
+ "cosmic-ray>=8.4",
50
+ "ast-grep-cli>=0.39",
45
51
  "types-requests>=2.31",
46
52
  ]
47
53
 
@@ -54,44 +60,49 @@ wherobots = ["py.typed"]
54
60
  [tool.setuptools.dynamic]
55
61
  version = {attr = "wherobots.__version__.__version__"}
56
62
 
63
+ # SLOP REPELLANT ruff rail: line-length 100, py310 target, select = ["ALL"]
64
+ # plus the typing.Any/typing.cast ban all come from the base config.
65
+ # NOTE: `lint.select`/`lint.ignore` would REPLACE the base's — tune only via
66
+ # the extend-* keys.
57
67
  [tool.ruff]
58
- line-length = 100
59
- target-version = "py310"
60
- extend-exclude = ["env", ".venv", "venv", "build", "dist"]
68
+ extend = ".slop-repellant/ruff-base.toml"
69
+ # .slop-repellant/ is vendored, kit-MANAGED tooling (re-rendered on
70
+ # slop-repellant-update) — linting it locally would fork the managed files.
71
+ extend-exclude = ["env", ".venv", "venv", "build", "dist", ".slop-repellant"]
61
72
 
62
73
  [tool.ruff.lint]
63
- select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
64
74
  # SIM117 merges nested with-statements — often hurts readability when
65
75
  # the context managers are logically distinct. Disable.
66
- ignore = ["SIM117"]
76
+ # EM101/EM102/TRY003 (exception-message-as-literal trio): style position,
77
+ # not debt — we keep messages inline at the raise site. Disable globally.
78
+ # PLR0917 (too-many-positional-arguments): newly enabled in ruff 0.16's ALL
79
+ # set. It is the positional-only variant of PLR0913 (too-many-arguments),
80
+ # which the few wide signatures here already carry an accepted `# noqa:
81
+ # PLR0913` for — so PLR0917 is redundant with a complaint the codebase has
82
+ # already signed off on. Ignored globally rather than making those args
83
+ # keyword-only, which would be a semver-breaking public-API change.
84
+ extend-ignore = ["SIM117", "EM101", "EM102", "TRY003", "PLR0917"]
67
85
 
68
- [tool.ruff.lint.per-file-ignores]
86
+ [tool.ruff.lint.extend-per-file-ignores]
69
87
  # Tests may assert freely, re-raise without chaining, shadow builtins
70
88
  # for fixtures, and use mock call patterns that look like unused
71
- # locals.
72
- "tests/**/*.py" = ["B011", "B904", "F841", "RUF059", "RUF013"]
89
+ # locals. N803/N806 fire on @patch-injected MockSession-style names and
90
+ # PLC0415 on deliberate deferred imports — idiomatic in tests, not debt.
91
+ # (Extends the base's tests/** ignore set.)
92
+ "tests/**/*.py" = ["B011", "B904", "F841", "RUF059", "RUF013", "N803", "N806", "PLC0415"]
93
+ # e2e/ holds standalone CLI scripts: printing is their job (T201), and
94
+ # they are intentionally not a package (INP001) with exec bits (EXE001).
95
+ "e2e/**/*.py" = ["T201", "INP001", "EXE001"]
73
96
  # Package __init__ groups exports by category with comment headers;
74
97
  # don't let ruff re-sort them alphabetically.
75
98
  "wherobots/__init__.py" = ["RUF022"]
76
99
 
77
- [tool.mypy]
78
- python_version = "3.10"
79
- strict = true
80
- files = ["wherobots"]
81
- warn_unused_ignores = true
82
- show_error_codes = true
83
-
84
- [[tool.mypy.overrides]]
85
- # Test code is not held to strict typing — productionize incrementally.
86
- module = "tests.*"
87
- ignore_errors = true
88
-
89
- [[tool.mypy.overrides]]
90
- # boto3 / botocore ship runtime types in some versions but the
91
- # pre-commit mypy env doesn't install them. Declare the policy once
92
- # here rather than scattering per-import ``# type: ignore`` comments.
93
- module = ["boto3", "boto3.*", "botocore", "botocore.*"]
94
- ignore_missing_imports = true
100
+ [tool.pyrefly]
101
+ project-includes = ["wherobots"]
102
+ check-unannotated-defs = true
103
+ strict-callable-subtyping = true
104
+ permissive-ignores = false
105
+ ignore-errors-in-generated-code = false
95
106
 
96
107
  [tool.pytest.ini_options]
97
108
  testpaths = ["tests"]
@@ -49,7 +49,7 @@ def _make_response(json_data, status_code=200):
49
49
 
50
50
 
51
51
  # =========================================================================== #
52
- # list()
52
+ # list() # noqa: ERA001
53
53
  # =========================================================================== #
54
54
 
55
55
 
@@ -120,7 +120,7 @@ class TestList:
120
120
 
121
121
 
122
122
  # =========================================================================== #
123
- # create()
123
+ # create() # noqa: ERA001
124
124
  # =========================================================================== #
125
125
 
126
126
 
@@ -177,7 +177,7 @@ class TestCreate:
177
177
 
178
178
 
179
179
  # =========================================================================== #
180
- # get()
180
+ # get() # noqa: ERA001
181
181
  # =========================================================================== #
182
182
 
183
183
 
@@ -197,7 +197,7 @@ class TestGet:
197
197
 
198
198
 
199
199
  # =========================================================================== #
200
- # cancel()
200
+ # cancel() # noqa: ERA001
201
201
  # =========================================================================== #
202
202
 
203
203
 
@@ -213,7 +213,7 @@ class TestCancel:
213
213
 
214
214
 
215
215
  # =========================================================================== #
216
- # get_logs()
216
+ # get_logs() # noqa: ERA001
217
217
  # =========================================================================== #
218
218
 
219
219
 
@@ -241,7 +241,7 @@ class TestGetLogs:
241
241
 
242
242
 
243
243
  # =========================================================================== #
244
- # get_metrics()
244
+ # get_metrics() # noqa: ERA001
245
245
  # =========================================================================== #
246
246
 
247
247
 
@@ -8,6 +8,12 @@ import requests
8
8
  from wherobots.api.base import BaseClient
9
9
  from wherobots.config import WherobotsConfig
10
10
  from wherobots.exceptions import WherobotsAPIError, WherobotsConfigError
11
+ from wherobots.utils.client_header import (
12
+ CLIENT_HEADER,
13
+ build_hop,
14
+ resolve_platform,
15
+ resolve_version,
16
+ )
11
17
 
12
18
  # ---------------------------------------------------------------------------
13
19
  # Fixtures
@@ -34,6 +40,118 @@ class TestAuthHeaders:
34
40
  assert "Authorization" not in headers
35
41
 
36
42
 
43
+ # ---------------------------------------------------------------------------
44
+ # Client attribution (X-Wherobots-Client)
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ class TestClientAttributionHeader:
49
+ @patch("wherobots.api.base.requests.Session")
50
+ def test_header_set_on_the_session(self, MockSession, config):
51
+ session = MockSession.return_value
52
+ session.headers = {}
53
+ session.mount = MagicMock()
54
+
55
+ BaseClient(config)
56
+
57
+ assert session.headers[CLIENT_HEADER] == build_hop()
58
+ assert session.headers[CLIENT_HEADER] == (
59
+ f"client=python-sdk;ver={resolve_version()};plat={resolve_platform()}"
60
+ )
61
+
62
+ @patch("wherobots.api.base.requests.Session")
63
+ def test_header_rides_along_with_every_request(self, MockSession, config):
64
+ # The header lives on the session rather than on individual calls,
65
+ # so it is attached to whatever requests the client later makes.
66
+ session = MockSession.return_value
67
+ session.headers = {}
68
+ session.mount = MagicMock()
69
+
70
+ mock_response = MagicMock()
71
+ mock_response.raise_for_status.return_value = None
72
+ session.request.return_value = mock_response
73
+
74
+ client = BaseClient(config)
75
+ client.get("/runs")
76
+ client.post("/runs", json_body={"name": "test"})
77
+
78
+ assert session.request.call_count == 2
79
+ assert session.headers[CLIENT_HEADER] == build_hop()
80
+
81
+ @patch("wherobots.api.base.requests.Session")
82
+ def test_upstream_chain_is_appended_to_not_replaced(self, MockSession):
83
+ cfg = WherobotsConfig(
84
+ api_key="test-key-123",
85
+ base_url="https://api.test.wherobots.com",
86
+ client_chain="client=airflow;ver=1.2.0",
87
+ )
88
+ session = MockSession.return_value
89
+ session.headers = {}
90
+ session.mount = MagicMock()
91
+
92
+ BaseClient(cfg)
93
+
94
+ assert session.headers[CLIENT_HEADER] == f"client=airflow;ver=1.2.0, {build_hop()}"
95
+
96
+ @patch("wherobots.api.base.requests.Session")
97
+ def test_attribution_never_displaces_auth(self, MockSession):
98
+ # A CRLF is the only thing in a chain that could ever *become* another
99
+ # header, so that is what this has to carry: a plain comma-separated
100
+ # "X-API-Key: nope" is just more text inside one header value and could
101
+ # not displace auth no matter how the chain were handled.
102
+ cfg = WherobotsConfig(
103
+ api_key="test-key-123",
104
+ base_url="https://api.test.wherobots.com",
105
+ client_chain="client=spoofed\r\nX-API-Key: nope",
106
+ )
107
+ session = MockSession.return_value
108
+ session.headers = {}
109
+ session.mount = MagicMock()
110
+
111
+ BaseClient(cfg)
112
+
113
+ assert session.headers["X-API-Key"] == "test-key-123"
114
+ assert "\r" not in session.headers[CLIENT_HEADER]
115
+ assert "\n" not in session.headers[CLIENT_HEADER]
116
+
117
+ def test_a_hostile_upstream_chain_still_yields_a_sendable_request(self):
118
+ """The chain is caller-supplied, so it must not be able to break a call.
119
+
120
+ `requests` refuses to send a header value containing a control
121
+ character, raising `InvalidHeader` — a `requests` exception, not a
122
+ `WherobotsJobError`. Before the chain was scrubbed, an upstream that
123
+ forwarded a CR or LF broke every request this SDK made and leaked a
124
+ foreign exception type doing it.
125
+
126
+ Non-ASCII text gets past `requests` entirely and then fails one layer
127
+ lower, where `http.client.putheader` latin-1 encodes, so both are
128
+ exercised here and the latin-1 encode is asserted explicitly.
129
+
130
+ This asserts against real header preparation rather than a mocked
131
+ session, because the mock is exactly what hid the defect.
132
+ """
133
+ cfg = WherobotsConfig(
134
+ api_key="test-key-123",
135
+ base_url="https://api.test.wherobots.com",
136
+ client_chain="client=evil\r\nX-Injected: yes, client=日本語",
137
+ )
138
+
139
+ with patch("wherobots.api.base.requests.Session") as MockSession:
140
+ session = MockSession.return_value
141
+ session.headers = {}
142
+ session.mount = MagicMock()
143
+ BaseClient(cfg)
144
+
145
+ prepared = requests.Request(
146
+ "GET", "https://api.test.wherobots.com/runs", headers=session.headers
147
+ ).prepare()
148
+
149
+ assert "X-Injected" not in prepared.headers
150
+ assert prepared.headers[CLIENT_HEADER].startswith("client=evil")
151
+ # What http.client actually does with it.
152
+ prepared.headers[CLIENT_HEADER].encode("latin-1")
153
+
154
+
37
155
  # ---------------------------------------------------------------------------
38
156
  # Config validation
39
157
  # ---------------------------------------------------------------------------
@@ -821,3 +821,35 @@ class TestScriptAutoUpload:
821
821
  assert uri == "s3://managed/scripts/job.py"
822
822
  mock_files.upload_managed_file.assert_called_once_with("job.py", subdir="scripts")
823
823
  mock_files.upload_dependency.assert_not_called()
824
+
825
+
826
+ # ── Client attribution chain plumbing ──────────────────────────────────
827
+
828
+
829
+ class TestClientChainPlumbing:
830
+ """``client_chain`` reaches the config every construction path uses."""
831
+
832
+ def test_constructor_threads_client_chain_into_config(self, mock_env):
833
+ job = WherobotsJob(
834
+ script="s3://bucket/main.py",
835
+ name="chain-job-name",
836
+ runtime="tiny",
837
+ client_chain="client=airflow;ver=1.2.0",
838
+ )
839
+
840
+ assert job._config.client_chain == "client=airflow;ver=1.2.0"
841
+
842
+ def test_constructor_defaults_to_no_upstream_chain(self, mock_env, monkeypatch):
843
+ monkeypatch.delenv("WHEROBOTS_CLIENT_CHAIN", raising=False)
844
+
845
+ job = WherobotsJob(script="s3://bucket/main.py", name="chain-job-name", runtime="tiny")
846
+
847
+ assert job._config.client_chain is None
848
+
849
+ def test_from_run_id_threads_client_chain_into_config(self, mock_env, mock_run_view):
850
+ with patch("wherobots.client.RunsAPI") as runs_cls:
851
+ runs_cls.from_config.return_value = _make_mock_api(run_view=mock_run_view)
852
+
853
+ job = WherobotsJob.from_run_id("run-123", client_chain="client=airflow;ver=1.2.0")
854
+
855
+ assert job._config.client_chain == "client=airflow;ver=1.2.0"