plexus-python 0.11.0__tar.gz → 0.11.2__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 (62) hide show
  1. {plexus_python-0.11.0 → plexus_python-0.11.2}/CHANGELOG.md +43 -0
  2. {plexus_python-0.11.0 → plexus_python-0.11.2}/PKG-INFO +1 -1
  3. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/__init__.py +1 -1
  4. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/client.py +27 -6
  5. {plexus_python-0.11.0 → plexus_python-0.11.2}/pyproject.toml +1 -1
  6. {plexus_python-0.11.0 → plexus_python-0.11.2}/skills/plexus/SKILL.md +48 -3
  7. {plexus_python-0.11.0 → plexus_python-0.11.2}/skills/plexus-firmware/SKILL.md +39 -2
  8. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_runs.py +42 -0
  9. {plexus_python-0.11.0 → plexus_python-0.11.2}/uv.lock +1 -1
  10. {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  11. {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  12. {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  13. {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/workflows/ci.yml +0 -0
  14. {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/workflows/publish.yml +0 -0
  15. {plexus_python-0.11.0 → plexus_python-0.11.2}/.gitignore +0 -0
  16. {plexus_python-0.11.0 → plexus_python-0.11.2}/AGENTS.md +0 -0
  17. {plexus_python-0.11.0 → plexus_python-0.11.2}/API.md +0 -0
  18. {plexus_python-0.11.0 → plexus_python-0.11.2}/CODE_OF_CONDUCT.md +0 -0
  19. {plexus_python-0.11.0 → plexus_python-0.11.2}/CONTRIBUTING.md +0 -0
  20. {plexus_python-0.11.0 → plexus_python-0.11.2}/LICENSE +0 -0
  21. {plexus_python-0.11.0 → plexus_python-0.11.2}/README.md +0 -0
  22. {plexus_python-0.11.0 → plexus_python-0.11.2}/SECURITY.md +0 -0
  23. {plexus_python-0.11.0 → plexus_python-0.11.2}/TODO.md +0 -0
  24. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/.python-version +0 -0
  25. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/README.md +0 -0
  26. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/basic.py +0 -0
  27. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/can.py +0 -0
  28. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/i2c_bme280.py +0 -0
  29. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/mac_metrics.py +0 -0
  30. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/mavlink.py +0 -0
  31. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/mqtt.py +0 -0
  32. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/pyproject.toml +0 -0
  33. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/thermal_camera.py +0 -0
  34. {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/uv.lock +0 -0
  35. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/_log.py +0 -0
  36. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/batching.py +0 -0
  37. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/buffer.py +0 -0
  38. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/cameras/__init__.py +0 -0
  39. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/cameras/thermal.py +0 -0
  40. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/cli.py +0 -0
  41. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/config.py +0 -0
  42. {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/ws.py +0 -0
  43. {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/plexus.service +0 -0
  44. {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/release.sh +0 -0
  45. {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/scan_buses.py +0 -0
  46. {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/setup.sh +0 -0
  47. {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/verify_skills.py +0 -0
  48. {plexus_python-0.11.0 → plexus_python-0.11.2}/skills/README.md +0 -0
  49. {plexus_python-0.11.0 → plexus_python-0.11.2}/skills/plexus-dashboard/SKILL.md +0 -0
  50. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/conftest.py +0 -0
  51. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_basic.py +0 -0
  52. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_batching.py +0 -0
  53. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_batching_wire.py +0 -0
  54. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_buffer.py +0 -0
  55. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_config.py +0 -0
  56. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_isolation.py +0 -0
  57. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_rate_limit.py +0 -0
  58. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_retry.py +0 -0
  59. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_skills.py +0 -0
  60. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_thermal.py +0 -0
  61. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_video.py +0 -0
  62. {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_ws.py +0 -0
@@ -2,6 +2,49 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.2] - 2026-09-02 - Stamp run boundaries on the server's clock
6
+
7
+ ### Fixed
8
+
9
+ - **A run's window was measured by a different clock than the points inside
10
+ it.** `_normalize_ts_ms` corrects generated timestamps by the offset the
11
+ gateway reports on connect; `start_run` / `end_run` read the local clock
12
+ instead. Every point taken in the final `offset` milliseconds of a run
13
+ therefore landed *after* `ended_at` and fell outside the window its own run
14
+ was evaluated over.
15
+
16
+ Measured on production: a device running 58ms behind the server sent 300
17
+ points and its run was judged on 284 of them. The shortfall reads exactly
18
+ like ingest lag, which is what it was mistaken for — a pass criterion can
19
+ come back green because the excursion that would have failed it was outside
20
+ the window by 58 milliseconds. Run boundaries now carry the same correction
21
+ as the telemetry. An explicitly passed `started_at` / `ended_at` is still
22
+ left alone.
23
+
24
+ ## [0.11.1] - 2026-09-02 - Correct what the agent skills teach
25
+
26
+ ### Fixed
27
+
28
+ - **`plexus-firmware` claimed the SDK batches. It does not.** Of CPython
29
+ targets — naming Jetson and embedded Linux specifically — the skill said
30
+ "stop and use the SDK instead ... It already does batching". `send()` is one
31
+ message per call, the gateway's ceiling counts messages, and the overflow is
32
+ discarded asynchronously after the send returned True. The skills are how a
33
+ coding agent learns this API instead of inventing it, so that line aimed
34
+ agents at silent data loss on exactly the hardware most likely to hit bench
35
+ rates. Both skills now carry the real ceilings and point at `px.batch()`.
36
+
37
+ - **Runs were documented in no skill**, so an agent had no way to know a test
38
+ bench should name its window. The `plexus` skill now carries the contract:
39
+ the app host rather than the gateway, offset-bearing timestamps, the
40
+ operators, and the two semantics that surprise people — every sample must
41
+ satisfy a criterion, and a metric with no data in the window fails rather
42
+ than passes.
43
+
44
+ - **The documented slug rule was wrong** (`^[a-z0-9][a-z0-9_-]{1,62}$`). The
45
+ gateway accepts dots, single characters and 256 bytes, so an agent applying
46
+ the documented rule would reject slugs the platform takes.
47
+
5
48
  ## [0.11.0] - 2026-09-01 - Batching, runs, and an end to silent drops
6
49
 
7
50
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plexus-python
3
- Version: 0.11.0
3
+ Version: 0.11.2
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
5
  Project-URL: Homepage, https://plexus.company
6
6
  Project-URL: Documentation, https://docs.plexus.company
@@ -23,7 +23,7 @@ from plexus.client import (
23
23
  )
24
24
  from plexus.config import RetryConfig
25
25
 
26
- __version__ = "0.11.0"
26
+ __version__ = "0.11.2"
27
27
  __all__ = [
28
28
  "AuthenticationError",
29
29
  "BatchSender",
@@ -60,10 +60,12 @@ if TYPE_CHECKING: # pragma: no cover - typing only
60
60
  logger = logging.getLogger(__name__)
61
61
 
62
62
 
63
- def _utc_now_iso() -> str:
64
- """Now as ISO-8601 with a Z offset — the shape /api/runs validates."""
65
- return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
66
- "+00:00", "Z"
63
+ def _iso_from_ms(ms: int) -> str:
64
+ """Epoch milliseconds as ISO-8601 with a Z offset — /api/runs validates the shape."""
65
+ return (
66
+ datetime.fromtimestamp(ms / 1000, timezone.utc)
67
+ .isoformat(timespec="milliseconds")
68
+ .replace("+00:00", "Z")
67
69
  )
68
70
 
69
71
 
@@ -350,6 +352,22 @@ class Plexus:
350
352
  return int(timestamp * 1000)
351
353
  return int(timestamp)
352
354
 
355
+ def _now_iso(self) -> str:
356
+ """Now on the SERVER's clock, as ISO-8601.
357
+
358
+ Run boundaries have to be stamped on the same clock as the telemetry
359
+ they bound, and `_normalize_ts_ms` corrects generated timestamps by the
360
+ offset the gateway reports on connect. Reading the local clock here
361
+ instead — which this did — leaves `ended_at` behind every point taken
362
+ in the final `offset` milliseconds of the run, and those points then
363
+ fall outside the window their own run is evaluated over.
364
+
365
+ The failure is quiet and looks like something else entirely: on a
366
+ device running 58ms behind the server, a 300-point run was evaluated
367
+ over 284 of them, and the shortfall reads exactly like ingest lag.
368
+ """
369
+ return _iso_from_ms(int(time.time() * 1000) + self._clock_offset_ms)
370
+
353
371
  @staticmethod
354
372
  def _infer_class(value: FlexValue) -> str:
355
373
  """Numbers are metrics; everything else (str/bool/dict/list) is an event.
@@ -584,7 +602,10 @@ class Plexus:
584
602
  Returns:
585
603
  The created run dict, including its "id".
586
604
  """
587
- body: dict[str, Any] = {"name": name, "started_at": started_at or _utc_now_iso()}
605
+ body: dict[str, Any] = {
606
+ "name": name,
607
+ "started_at": started_at or self._now_iso(),
608
+ }
588
609
  if source_id is not _USE_CLIENT_SOURCE:
589
610
  body["source_id"] = source_id
590
611
  else:
@@ -664,7 +685,7 @@ class Plexus:
664
685
  return self._api(
665
686
  "PATCH",
666
687
  f"/api/runs/{run_id}",
667
- {"status": status, "ended_at": ended_at or _utc_now_iso()},
688
+ {"status": status, "ended_at": ended_at or self._now_iso()},
668
689
  )["run"]
669
690
 
670
691
  @contextmanager
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plexus-python"
7
- version = "0.11.0"
7
+ version = "0.11.2"
8
8
  description = "Thin Python SDK for Plexus — send telemetry in one line"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -156,12 +156,54 @@ When asked to "set up Plexus" in a project, do this:
156
156
  5. **Add a single usage example** in the project — one obvious place, not five — so they can verify it works.
157
157
  6. **Update README** with a short section: "Set `PLEXUS_API_KEY`, then run X."
158
158
 
159
+ ## Runs
160
+
161
+ A **run** is a named time window on a source — a hot-fire, a bench sequence, a
162
+ flight. Runs are recalled on `/runs`, compared against each other aligned at
163
+ T+0, and evaluated against declared pass criteria on close. Test-bench software
164
+ should open one when a test starts and close it when the test ends, rather than
165
+ expecting somebody to drag a time picker afterwards.
166
+
167
+ These live on the **app** host (`https://app.plexus.company`), not the gateway,
168
+ and take the same `x-api-key`.
169
+
170
+ ```
171
+ POST /api/runs {name, source_id, started_at, pass_criteria?, tags?}
172
+ PATCH /api/runs/{id} {status: "completed"|"aborted", ended_at}
173
+ GET /api/runs ?source_id=&status=
174
+ ```
175
+
176
+ `started_at` / `ended_at` are ISO-8601 **with an offset** (`...Z` is fine); a
177
+ naive local timestamp is a 400.
178
+
179
+ In plexus-python (>= 0.11.0) this is `px.start_run()` / `px.end_run()`, or:
180
+
181
+ ```python
182
+ with px.run("hotfire-03", pass_criteria=[
183
+ {"metric": "motor.temp_c", "operator": "<", "value": 85},
184
+ ]):
185
+ bench.execute()
186
+ ```
187
+
188
+ An exception leaving the block closes the run `aborted`; a clean exit closes it
189
+ `completed`.
190
+
191
+ **Pass criteria** are `{metric, operator, value, label?}` with operator one of
192
+ `> >= < <= = !=`. On close, every sample of that metric inside the window is
193
+ checked. A criterion passes only if *all* of them satisfy it — one excursion
194
+ fails it — and a criterion whose metric has no data in the window **fails**
195
+ rather than passing. The verdict lands on the run's `test_result`.
196
+
197
+ Criteria are evaluated against stored telemetry, which trails ingest by a few
198
+ seconds. A run closed the instant its last point is sent may read "no data";
199
+ let the pipeline settle first.
200
+
159
201
  ## Idioms
160
202
 
161
203
  - **Polling cadences for dashboards**: latest values 5s, charts 10s, fleet health 10s, source list 30s. Use SWR or TanStack Query with `refreshInterval`.
162
204
  - **Time ranges**: prefer `last=1h` (relative) over `start`/`end` (absolute) — easier to reason about and less timezone footgun. `start`/`end` are ISO date-times, not epoch ms.
163
- - **Batching ingest**: buffer up to 64 points or 5 seconds, whichever first. On 429 / 5xx, exponential backoff with max 3 attempts.
164
- - **Source IDs are slugs**: `drone-001`, `sat-alpha-3`. Must match `^[a-z0-9][a-z0-9_-]{1,62}$`. Stable, lowercase, hyphenated. Don't use UUIDs in user-facing surfaces.
205
+ - **Batching ingest**: the gateway meters **messages, not points** — 500/s per WebSocket connection, 2000/s per source, up to 10,000 points in one message. Buffer up to 64 points or 5 seconds, whichever first. One send per reading at bench rates exceeds the limit and the overflow is *discarded*, reported asynchronously as `RATE_LIMITED` after the send returned. In plexus-python use `px.batch(interval_ms=50)`; `px.send()` alone is one message per call. On 429 / 5xx, exponential backoff with max 3 attempts.
206
+ - **Source IDs are slugs**: `drone-001`, `sat-alpha-3`, `bench.rig-2`. Must match `^[a-z0-9][a-z0-9._-]*$`, up to 256 characters — dots are legal and a single character is legal. A uuid-shaped slug is rejected: every resolver reads uuid-shaped refs as internal ids, so such a source would be unreachable. Stable, lowercase.
165
207
  - **Source IDs are not deduplicated.** The gateway writes whatever `source_id` you declare. Two devices declaring the same name merge into one source.
166
208
  - **Metric names are opaque and may be long.** Anything that round-trips a name must use the identical string on both sides or the series and its metadata will not join.
167
209
 
@@ -180,4 +222,7 @@ When asked to "set up Plexus" in a project, do this:
180
222
 
181
223
  Fetch `https://plexus-data-api.fly.dev/openapi.json` for the authoritative HTTP schema (it will not show WebSocket routes), and check a real response before writing parsing code. `scripts/verify_skills.py` in this repo checks these docs against the live spec — run it if something here looks stale.
182
224
 
183
- This cheat sheet has drifted before. Corrected 2026-08-27 against Data API 0.1.0 (ingest array name, `sources`/`devices` paths, removed per-source health, columnar query) and again 2026-08-28 against gateway + API source (the live-stream host/path/auth/frame shape, the required `class` field, numeric-only timestamps, the redirect chain, and the now-fixed 1970 timestamp behavior).
225
+ This cheat sheet has drifted before. Corrected 2026-09-01 against the shipped
226
+ gateway and app (runs API — which an earlier revision of API.md wrongly said did
227
+ not exist; message-not-point metering and `px.batch()`; the real slug rule,
228
+ which permits dots, single characters and 256 bytes). Corrected 2026-08-27 against Data API 0.1.0 (ingest array name, `sources`/`devices` paths, removed per-source health, columnar query) and again 2026-08-28 against gateway + API source (the live-stream host/path/auth/frame shape, the required `class` field, numeric-only timestamps, the redirect chain, and the now-fixed 1970 timestamp behavior).
@@ -14,11 +14,29 @@ Adds a small, well-behaved Plexus ingest client to device-side code. Optimized f
14
14
  - Targets include: ESP32 / Arduino / RP2040, Raspberry Pi, NVIDIA Jetson, embedded Linux gateways, drone autopilot companion computers, satellite OBC software
15
15
  - Languages: C / C++, MicroPython, CPython, Rust, Go (for edge gateways)
16
16
 
17
- **If the target runs CPython (Pi, Jetson, any embedded Linux with room), stop and use the SDK instead** — `pip install plexus-python`, then `Plexus(api_key=..., source_id=...).send("temp", 72.5)`. It already does batching, backoff, and buffering. Hand-rolling is for targets the SDK can't reach.
17
+ **If the target runs CPython (Pi, Jetson, any embedded Linux with room), stop and use the SDK instead** — `pip install plexus-python`, then `Plexus(api_key=..., source_id=...)`. It handles backoff, store-and-forward buffering and the WebSocket transport. Hand-rolling is for targets the SDK can't reach.
18
+
19
+ **`send()` does NOT batch.** Every call is its own message on the wire, and the ceiling below counts messages. Above a few readings a second, use `px.batch()`:
20
+
21
+ ```python
22
+ with px.batch(interval_ms=50) as b: # one message per interval
23
+ while running:
24
+ b.send("imu.accel_x", imu.x) # same signature as px.send()
25
+ b.send("imu.accel_y", imu.y)
26
+ ```
27
+
28
+ Leaving the block flushes what is queued, and readings keep the timestamp they were taken at. Requires plexus-python >= 0.11.0.
18
29
 
19
30
  For **server-side ingestion** (a backend collecting metrics and forwarding), the generic `plexus` skill is fine.
20
31
  For **dashboards / UI**, use `plexus-dashboard`.
21
32
 
33
+ **On a test bench, open a run.** Software driving a test should name its window
34
+ rather than leave someone to find it on a chart later: `POST /api/runs` when the
35
+ test starts, `PATCH /api/runs/{id}` with `status` and `ended_at` when it ends
36
+ (app host, same `x-api-key`), or `with px.run(...)` from plexus-python. Declared
37
+ `pass_criteria` are evaluated against every sample in the window on close. See
38
+ the `plexus` skill for the contract.
39
+
22
40
  ## The ingest contract
23
41
 
24
42
  ```
@@ -53,7 +71,26 @@ Every firmware integration must do these. Skip any of them and you'll lose data
53
71
 
54
72
  ### 1. Batch
55
73
 
56
- Never POST one point at a time. Buffer until **64 points OR 5 seconds**, whichever first, then flush. Constants worth exposing:
74
+ **The gateway meters messages, not points.** One request carrying 200 points costs
75
+ exactly what one carrying 1 costs, so the shape of your sends — not their volume —
76
+ decides whether you hit a limit:
77
+
78
+ | Limit | Value |
79
+ | --- | --- |
80
+ | Telemetry messages per WebSocket connection | 500/s sustained, 2000 burst |
81
+ | Hard ceiling per source (WS and HTTP) | 2000 messages/s |
82
+ | Points per message | 10,000 |
83
+ | Body size | 1 MB (WS) / 5 MB (HTTP) |
84
+
85
+ Eight channels at 100 Hz sent one at a time is 800 messages/s — over the limit,
86
+ and **the overflow is discarded**. Batched every 50 ms it is 20 messages/s.
87
+
88
+ Over the limit the gateway drops the whole message and answers `RATE_LIMITED`,
89
+ which arrives *after* the send returned. Those points cannot be recovered, so
90
+ this is a batching problem, not a retry one.
91
+
92
+ Never POST one point at a time. Buffer until **64 points OR 5 seconds**, whichever
93
+ first, then flush. Constants worth exposing:
57
94
 
58
95
  ```c
59
96
  #define PLEXUS_BATCH_SIZE 64
@@ -10,6 +10,7 @@ The HTTP layer is stubbed at `_Session.request`, so no socket is opened.
10
10
  """
11
11
 
12
12
  import json
13
+ import time
13
14
 
14
15
  import pytest
15
16
 
@@ -72,6 +73,47 @@ def test_started_at_matches_the_shape_the_route_validates():
72
73
  datetime.fromisoformat(started.replace("Z", "+00:00")) # parses or raises
73
74
 
74
75
 
76
+ def test_run_boundaries_are_stamped_on_the_server_clock():
77
+ """The window must be measured by the same clock as the points inside it.
78
+
79
+ `_normalize_ts_ms` corrects generated timestamps by the offset the gateway
80
+ reports on connect. Stamping run boundaries from the local clock instead
81
+ leaves `ended_at` behind every point taken in the final `offset`
82
+ milliseconds — and those points then fall outside the window their own run
83
+ is evaluated over. Measured on production before the fix: a 300-point run
84
+ on a device 58ms behind the server was judged on 284 of them, and the
85
+ shortfall reads exactly like ingest lag.
86
+ """
87
+ from datetime import datetime
88
+
89
+ session = _StubSession()
90
+ px = _client(session)
91
+ px._clock_offset_ms = 5_000 # device 5s behind the server
92
+
93
+ px.start_run("run")
94
+ px.end_run({"id": "run_1"})
95
+
96
+ def as_ms(iso: str) -> int:
97
+ return int(datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000)
98
+
99
+ local_ms = int(time.time() * 1000)
100
+ started = as_ms(session.calls[0][2]["started_at"])
101
+ ended = as_ms(session.calls[1][2]["ended_at"])
102
+
103
+ # Both carry the offset, so they bound points that also carry it.
104
+ assert started - local_ms > 4_000
105
+ assert ended - local_ms > 4_000
106
+
107
+
108
+ def test_an_explicit_boundary_is_left_alone():
109
+ """A caller passing its own timestamp owns it — we do not shift it."""
110
+ session = _StubSession()
111
+ px = _client(session)
112
+ px._clock_offset_ms = 5_000
113
+ px.start_run("run", started_at="2026-09-01T10:00:00.000Z")
114
+ assert session.calls[0][2]["started_at"] == "2026-09-01T10:00:00.000Z"
115
+
116
+
75
117
  def test_run_defaults_to_the_clients_own_source():
76
118
  session = _StubSession()
77
119
  px = _client(session)
@@ -425,7 +425,7 @@ wheels = [
425
425
 
426
426
  [[package]]
427
427
  name = "plexus-python"
428
- version = "0.11.0"
428
+ version = "0.11.1"
429
429
  source = { editable = "." }
430
430
  dependencies = [
431
431
  { name = "websocket-client" },
File without changes
File without changes
File without changes
File without changes
File without changes