plexus-python 0.11.1__py3-none-any.whl → 0.11.3__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.
plexus/__init__.py CHANGED
@@ -23,7 +23,7 @@ from plexus.client import (
23
23
  )
24
24
  from plexus.config import RetryConfig
25
25
 
26
- __version__ = "0.11.1"
26
+ __version__ = "0.11.3"
27
27
  __all__ = [
28
28
  "AuthenticationError",
29
29
  "BatchSender",
plexus/cli.py CHANGED
@@ -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(_args: argparse.Namespace) -> int:
432
- """Print the prefix of the locally stored key + the configured endpoint."""
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
- return 0
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
- parser = argparse.ArgumentParser(
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(
plexus/client.py CHANGED
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plexus-python
3
- Version: 0.11.1
3
+ Version: 0.11.3
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
@@ -1,9 +1,9 @@
1
- plexus/__init__.py,sha256=UyDLTsy5c8UkJpHI60i-SF4mgH1UvHjj6UJcpkQ2FuM,808
1
+ plexus/__init__.py,sha256=CQs5A5m9byvbgba4x0bu0jjqCeav-DGVTxdlygcwNdg,808
2
2
  plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
3
3
  plexus/batching.py,sha256=mPMa3m9xK-DCwRmuyL_aqaTltimIOtwoDzz4PfKWyh8,10452
4
4
  plexus/buffer.py,sha256=UNv_jEcrDwbkjJ6uhCehb7uBI2EuFEwO40waDpZn_5I,9579
5
- plexus/cli.py,sha256=TenKqhvh7Z-JhAUj4kjogrRGA2Hlrfd3TvBFo4S5tlk,17633
6
- plexus/client.py,sha256=QEG1AHWh5g3qiLOnmqXDpLPdCdEYAbb_UrT-NmE8KY8,52908
5
+ plexus/cli.py,sha256=YFkptze8LRc6mBmDgbsTeU8J6i8MMsdTWvtm3jCINiI,21773
6
+ plexus/client.py,sha256=H69DR30pj3X8PiURZsViGTDHsIsQ2Kn4muHNECsioKw,53865
7
7
  plexus/config.py,sha256=RuDh5UdVGdVQld5kQlXZO6CVXO4tS0HBalyaoAlXNvc,4416
8
8
  plexus/ws.py,sha256=2kUCTPkS-tG1XpL9RAzTiytk4Dbck5ypqkbDRSKaTF8,19811
9
9
  plexus/cameras/__init__.py,sha256=AVu1vE1xfYk9lz1cprHlTfi0ieHf84UhQh9Z92E05b8,490
@@ -12,8 +12,8 @@ plexus/_skills/README.md,sha256=9TNo9mssmBiBPkrYBiA2JJHWOIAEw6DvCWCYAvfzmjM,3243
12
12
  plexus/_skills/plexus/SKILL.md,sha256=Q1ZYe9YH-gOhMcGYuMG5H9DkHMfQOr3OazCtlxtP9f8,13617
13
13
  plexus/_skills/plexus-dashboard/SKILL.md,sha256=8xjdvI3orM31XHTLv1fSAWstCGMwt1HnEbo0SlpTxV4,9508
14
14
  plexus/_skills/plexus-firmware/SKILL.md,sha256=IkUqNbg5sM5VaZEb189P_aOUkm9_PlSqd4JLH0Nv830,12081
15
- plexus_python-0.11.1.dist-info/METADATA,sha256=iFDa0-ug5kZk2Ave_UKm4rHvy1GbLRJrhgg4ITcFLH0,13517
16
- plexus_python-0.11.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
- plexus_python-0.11.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
18
- plexus_python-0.11.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
19
- plexus_python-0.11.1.dist-info/RECORD,,
15
+ plexus_python-0.11.3.dist-info/METADATA,sha256=9HNLVMyUVehl40sr477LJ1W5_XryDW7RELpbyDysq7k,13517
16
+ plexus_python-0.11.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
+ plexus_python-0.11.3.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
18
+ plexus_python-0.11.3.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
19
+ plexus_python-0.11.3.dist-info/RECORD,,