plexus-python 0.11.1__tar.gz → 0.11.3__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.
- {plexus_python-0.11.1 → plexus_python-0.11.3}/CHANGELOG.md +47 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/PKG-INFO +1 -1
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/__init__.py +1 -1
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/cli.py +109 -4
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/client.py +27 -6
- {plexus_python-0.11.1 → plexus_python-0.11.3}/pyproject.toml +1 -1
- plexus_python-0.11.3/tests/test_cli_diagnostics.py +94 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_runs.py +42 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/uv.lock +1 -1
- {plexus_python-0.11.1 → plexus_python-0.11.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/.github/workflows/publish.yml +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/.gitignore +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/AGENTS.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/API.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/CONTRIBUTING.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/LICENSE +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/README.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/SECURITY.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/TODO.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/.python-version +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/README.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/basic.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/can.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/mac_metrics.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/mavlink.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/mqtt.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/pyproject.toml +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/thermal_camera.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/examples/uv.lock +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/_log.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/batching.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/buffer.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/cameras/__init__.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/cameras/thermal.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/config.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/plexus/ws.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/scripts/plexus.service +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/scripts/release.sh +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/scripts/scan_buses.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/scripts/setup.sh +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/scripts/verify_skills.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/skills/README.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/skills/plexus/SKILL.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/skills/plexus-dashboard/SKILL.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/skills/plexus-firmware/SKILL.md +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/conftest.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_basic.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_batching.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_batching_wire.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_buffer.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_config.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_isolation.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_rate_limit.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_retry.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_skills.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_thermal.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_video.py +0 -0
- {plexus_python-0.11.1 → plexus_python-0.11.3}/tests/test_ws.py +0 -0
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.11.3] - 2026-09-02 - Let the CLI diagnose the CLI
|
|
6
|
+
|
|
7
|
+
All three of these came out of one evening of actually using it.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`plexus --version`.** It previously exited 2 with "the following
|
|
12
|
+
arguments are required: command" — the least useful possible reply to the
|
|
13
|
+
first thing anyone types when a CLI misbehaves.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **An unknown subcommand now names the running version and the upgrade.**
|
|
18
|
+
`invalid choice: 'skills'` sends people hunting for a typo when the real
|
|
19
|
+
cause is an old install — a pipx shim from four months earlier shadowing a
|
|
20
|
+
fresh `pip install`, in the case that prompted this. The command genuinely
|
|
21
|
+
does not exist *there*, and the fix is an upgrade, so the error says which
|
|
22
|
+
version is running and gives both the pip and pipx forms.
|
|
23
|
+
|
|
24
|
+
- **`whoami` now asks the server whether the key is actually good.** It used
|
|
25
|
+
to print the local key and exit 0 without checking anything, so a revoked
|
|
26
|
+
or expired credential produced a confident-looking summary and the 401s
|
|
27
|
+
that followed looked unrelated. It now reports the org and scopes on
|
|
28
|
+
success, distinguishes rejected (401) from access-disabled (403), and
|
|
29
|
+
treats unreachable as *unknown* rather than invalid — conflating those is
|
|
30
|
+
how a flaky connection becomes an afternoon of regenerating good
|
|
31
|
+
credentials. `--no-verify` keeps the old local-only behaviour.
|
|
32
|
+
|
|
33
|
+
## [0.11.2] - 2026-09-02 - Stamp run boundaries on the server's clock
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **A run's window was measured by a different clock than the points inside
|
|
38
|
+
it.** `_normalize_ts_ms` corrects generated timestamps by the offset the
|
|
39
|
+
gateway reports on connect; `start_run` / `end_run` read the local clock
|
|
40
|
+
instead. Every point taken in the final `offset` milliseconds of a run
|
|
41
|
+
therefore landed *after* `ended_at` and fell outside the window its own run
|
|
42
|
+
was evaluated over.
|
|
43
|
+
|
|
44
|
+
Measured on production: a device running 58ms behind the server sent 300
|
|
45
|
+
points and its run was judged on 284 of them. The shortfall reads exactly
|
|
46
|
+
like ingest lag, which is what it was mistaken for — a pass criterion can
|
|
47
|
+
come back green because the excursion that would have failed it was outside
|
|
48
|
+
the window by 58 milliseconds. Run boundaries now carry the same correction
|
|
49
|
+
as the telemetry. An explicitly passed `started_at` / `ended_at` is still
|
|
50
|
+
left alone.
|
|
51
|
+
|
|
5
52
|
## [0.11.1] - 2026-09-02 - Correct what the agent skills teach
|
|
6
53
|
|
|
7
54
|
### Fixed
|
|
@@ -31,6 +31,7 @@ import threading
|
|
|
31
31
|
import urllib.parse
|
|
32
32
|
import webbrowser
|
|
33
33
|
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
34
35
|
|
|
35
36
|
from . import config
|
|
36
37
|
|
|
@@ -428,8 +429,15 @@ def cmd_logout(_args: argparse.Namespace) -> int:
|
|
|
428
429
|
return 0
|
|
429
430
|
|
|
430
431
|
|
|
431
|
-
def cmd_whoami(
|
|
432
|
-
"""
|
|
432
|
+
def cmd_whoami(args: argparse.Namespace) -> int:
|
|
433
|
+
"""Report the local key, and whether the server still accepts it.
|
|
434
|
+
|
|
435
|
+
Printing the key alone was worse than printing nothing: a revoked or
|
|
436
|
+
expired credential produced confident-looking output and a zero exit, and
|
|
437
|
+
the 401s that followed from the SDK looked unrelated. `whoami` is what
|
|
438
|
+
someone runs precisely when they suspect their auth — it has to answer
|
|
439
|
+
that question rather than confirm a file exists.
|
|
440
|
+
"""
|
|
433
441
|
key = config.get_api_key()
|
|
434
442
|
endpoint = config.get_endpoint()
|
|
435
443
|
if not key:
|
|
@@ -438,7 +446,60 @@ def cmd_whoami(_args: argparse.Namespace) -> int:
|
|
|
438
446
|
masked = f"{key[:8]}…{key[-4:]}" if len(key) > 12 else key
|
|
439
447
|
print(f"key: {masked}")
|
|
440
448
|
print(f"endpoint: {endpoint}")
|
|
441
|
-
|
|
449
|
+
|
|
450
|
+
if args.no_verify:
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
status, body = _verify_key(endpoint, key)
|
|
454
|
+
if status == 200:
|
|
455
|
+
org = body.get("org_id") or "unknown"
|
|
456
|
+
scopes = ", ".join(body.get("scopes") or []) or "default"
|
|
457
|
+
print(f"org: {org}")
|
|
458
|
+
print(f"scopes: {scopes}")
|
|
459
|
+
print("status: valid")
|
|
460
|
+
return 0
|
|
461
|
+
if status == 401:
|
|
462
|
+
print("status: REJECTED — this key is invalid, revoked or expired.")
|
|
463
|
+
print(" Run `plexus init --force` to authorize this machine again.")
|
|
464
|
+
return 1
|
|
465
|
+
if status == 403:
|
|
466
|
+
print("status: DISABLED — the key is real but access is switched off.")
|
|
467
|
+
print(" Usually billing; check with your org admin.")
|
|
468
|
+
return 1
|
|
469
|
+
if status is None:
|
|
470
|
+
# Could not ask. Say so rather than implying either answer.
|
|
471
|
+
print(f"status: unknown — could not reach {endpoint} ({body})")
|
|
472
|
+
return 0
|
|
473
|
+
print(f"status: unexpected response ({status})")
|
|
474
|
+
return 1
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _verify_key(endpoint: str, key: str) -> tuple[int | None, Any]:
|
|
478
|
+
"""Ask the server whether a key is good. Returns (status, parsed_or_reason).
|
|
479
|
+
|
|
480
|
+
A network failure returns (None, reason): unreachable is not the same as
|
|
481
|
+
rejected, and reporting one as the other is how a flaky connection gets
|
|
482
|
+
mistaken for a credentials problem.
|
|
483
|
+
"""
|
|
484
|
+
import json as _json
|
|
485
|
+
import urllib.error
|
|
486
|
+
import urllib.request
|
|
487
|
+
|
|
488
|
+
req = urllib.request.Request(
|
|
489
|
+
f"{endpoint.rstrip('/')}/api/auth/verify-key",
|
|
490
|
+
headers={"x-api-key": key},
|
|
491
|
+
method="GET",
|
|
492
|
+
)
|
|
493
|
+
try:
|
|
494
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
495
|
+
return resp.status, _json.loads(resp.read().decode() or "{}")
|
|
496
|
+
except urllib.error.HTTPError as e:
|
|
497
|
+
try:
|
|
498
|
+
return e.code, _json.loads(e.read().decode() or "{}")
|
|
499
|
+
except Exception:
|
|
500
|
+
return e.code, {}
|
|
501
|
+
except Exception as e: # DNS, TLS, timeout, offline
|
|
502
|
+
return None, str(e)
|
|
442
503
|
|
|
443
504
|
|
|
444
505
|
def _bundled_skills_dir() -> Path | None:
|
|
@@ -507,11 +568,50 @@ def _default_skills_target(args: argparse.Namespace) -> Path:
|
|
|
507
568
|
return Path.home() / ".claude" / "skills"
|
|
508
569
|
|
|
509
570
|
|
|
571
|
+
class _VersionAwareParser(argparse.ArgumentParser):
|
|
572
|
+
"""Turns an unknown subcommand into a version diagnosis.
|
|
573
|
+
|
|
574
|
+
argparse says `invalid choice: 'skills'` and stops. When the real cause is
|
|
575
|
+
an old install — a pipx shim from months ago shadowing a fresh pip
|
|
576
|
+
install, say — that message sends people looking for a typo instead of at
|
|
577
|
+
their version. The command they were told to run genuinely does not exist
|
|
578
|
+
*here*, and the fix is an upgrade, so say which version is running and how
|
|
579
|
+
to move it.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
def error(self, message: str) -> None: # type: ignore[override]
|
|
583
|
+
if "invalid choice" in message:
|
|
584
|
+
from plexus import __version__
|
|
585
|
+
|
|
586
|
+
self.print_usage(sys.stderr)
|
|
587
|
+
print(f"\n{self.prog}: error: {message}", file=sys.stderr)
|
|
588
|
+
print(
|
|
589
|
+
f"\nYou are running plexus-python {__version__}. If you were "
|
|
590
|
+
"following a doc that\nnames this command, your install is "
|
|
591
|
+
"probably older than the doc:\n"
|
|
592
|
+
"\n pip install --upgrade plexus-python"
|
|
593
|
+
"\n pipx upgrade plexus-python # if you installed with pipx"
|
|
594
|
+
"\n\nThen check with: plexus --version",
|
|
595
|
+
file=sys.stderr,
|
|
596
|
+
)
|
|
597
|
+
self.exit(2)
|
|
598
|
+
super().error(message)
|
|
599
|
+
|
|
600
|
+
|
|
510
601
|
def build_parser() -> argparse.ArgumentParser:
|
|
511
|
-
|
|
602
|
+
from plexus import __version__
|
|
603
|
+
|
|
604
|
+
parser = _VersionAwareParser(
|
|
512
605
|
prog="plexus",
|
|
513
606
|
description="Plexus CLI — auth, send, query telemetry from your terminal.",
|
|
514
607
|
)
|
|
608
|
+
# The first thing anyone types when a CLI misbehaves, and previously an
|
|
609
|
+
# error: `plexus --version` demanded a subcommand and told you nothing.
|
|
610
|
+
parser.add_argument(
|
|
611
|
+
"--version",
|
|
612
|
+
action="version",
|
|
613
|
+
version=f"plexus-python {__version__}",
|
|
614
|
+
)
|
|
515
615
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
516
616
|
|
|
517
617
|
init = sub.add_parser(
|
|
@@ -537,6 +637,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
537
637
|
logout.set_defaults(func=cmd_logout)
|
|
538
638
|
|
|
539
639
|
whoami = sub.add_parser("whoami", help="Show the local credential summary.")
|
|
640
|
+
whoami.add_argument(
|
|
641
|
+
"--no-verify",
|
|
642
|
+
action="store_true",
|
|
643
|
+
help="Skip the server check and only print what is stored locally.",
|
|
644
|
+
)
|
|
540
645
|
whoami.set_defaults(func=cmd_whoami)
|
|
541
646
|
|
|
542
647
|
skills = sub.add_parser(
|
|
@@ -60,10 +60,12 @@ if TYPE_CHECKING: # pragma: no cover - typing only
|
|
|
60
60
|
logger = logging.getLogger(__name__)
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
def
|
|
64
|
-
"""
|
|
65
|
-
return
|
|
66
|
-
|
|
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] = {
|
|
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
|
|
688
|
+
{"status": status, "ended_at": ended_at or self._now_iso()},
|
|
668
689
|
)["run"]
|
|
669
690
|
|
|
670
691
|
@contextmanager
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The CLI has to help you debug the CLI.
|
|
2
|
+
|
|
3
|
+
Every case here came out of one evening of real use: a four-month-old pipx
|
|
4
|
+
shim shadowing a fresh install, `plexus --version` refusing to answer, and
|
|
5
|
+
`whoami` cheerfully printing a credential it had never checked.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from plexus import __version__
|
|
13
|
+
from plexus.cli import build_parser, cmd_whoami
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _args(**kw):
|
|
17
|
+
ns = mock.Mock()
|
|
18
|
+
ns.no_verify = kw.get("no_verify", False)
|
|
19
|
+
return ns
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_version_flag_answers_instead_of_demanding_a_subcommand(capsys):
|
|
23
|
+
# It used to exit 2 with "the following arguments are required: command",
|
|
24
|
+
# which is the least useful possible reply to "what version am I on".
|
|
25
|
+
with pytest.raises(SystemExit) as exc:
|
|
26
|
+
build_parser().parse_args(["--version"])
|
|
27
|
+
assert exc.value.code == 0
|
|
28
|
+
assert __version__ in capsys.readouterr().out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_unknown_subcommand_names_the_version_and_the_upgrade(capsys):
|
|
32
|
+
"""`invalid choice: 'skills'` sends people hunting for a typo when the real
|
|
33
|
+
cause is an old install. Say which version is running."""
|
|
34
|
+
with pytest.raises(SystemExit) as exc:
|
|
35
|
+
build_parser().parse_args(["skills-that-do-not-exist-yet"])
|
|
36
|
+
assert exc.value.code == 2
|
|
37
|
+
err = capsys.readouterr().err
|
|
38
|
+
assert __version__ in err
|
|
39
|
+
assert "pip install --upgrade" in err
|
|
40
|
+
assert "pipx upgrade" in err # the shadowing case that caused this
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_whoami_reports_a_valid_key_with_its_org(monkeypatch, capsys):
|
|
44
|
+
monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "a" * 32)
|
|
45
|
+
with mock.patch(
|
|
46
|
+
"plexus.cli._verify_key",
|
|
47
|
+
return_value=(200, {"org_id": "org_abc", "scopes": ["read", "write"]}),
|
|
48
|
+
):
|
|
49
|
+
assert cmd_whoami(_args()) == 0
|
|
50
|
+
out = capsys.readouterr().out
|
|
51
|
+
assert "org_abc" in out and "valid" in out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_whoami_fails_loudly_on_a_rejected_key(monkeypatch, capsys):
|
|
55
|
+
"""The whole point. A revoked key used to print a confident summary and
|
|
56
|
+
exit 0, so the 401s that followed looked unrelated."""
|
|
57
|
+
monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "b" * 32)
|
|
58
|
+
with mock.patch("plexus.cli._verify_key", return_value=(401, {})):
|
|
59
|
+
assert cmd_whoami(_args()) == 1
|
|
60
|
+
out = capsys.readouterr().out
|
|
61
|
+
assert "REJECTED" in out
|
|
62
|
+
assert "plexus init --force" in out # tell them the fix, not just the fault
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_whoami_distinguishes_disabled_from_invalid(monkeypatch, capsys):
|
|
66
|
+
monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "c" * 32)
|
|
67
|
+
with mock.patch("plexus.cli._verify_key", return_value=(403, {})):
|
|
68
|
+
assert cmd_whoami(_args()) == 1
|
|
69
|
+
assert "DISABLED" in capsys.readouterr().out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_unreachable_is_not_reported_as_invalid(monkeypatch, capsys):
|
|
73
|
+
"""Offline is not rejected. Conflating them turns a flaky connection into
|
|
74
|
+
an afternoon spent regenerating perfectly good credentials."""
|
|
75
|
+
monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "d" * 32)
|
|
76
|
+
with mock.patch("plexus.cli._verify_key", return_value=(None, "connection refused")):
|
|
77
|
+
assert cmd_whoami(_args()) == 0 # unknown is not failure
|
|
78
|
+
out = capsys.readouterr().out
|
|
79
|
+
assert "unknown" in out
|
|
80
|
+
assert "REJECTED" not in out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_no_verify_skips_the_network(monkeypatch, capsys):
|
|
84
|
+
monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "e" * 32)
|
|
85
|
+
with mock.patch("plexus.cli._verify_key") as v:
|
|
86
|
+
assert cmd_whoami(_args(no_verify=True)) == 0
|
|
87
|
+
v.assert_not_called()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_whoami_without_a_key_says_what_to_run(monkeypatch, capsys):
|
|
91
|
+
monkeypatch.delenv("PLEXUS_API_KEY", raising=False)
|
|
92
|
+
with mock.patch("plexus.config.load_config", return_value={}):
|
|
93
|
+
assert cmd_whoami(_args()) == 1
|
|
94
|
+
assert "plexus init" in capsys.readouterr().out
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|