plexus-python 0.7.1__py3-none-any.whl → 0.8.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.
plexus/__init__.py CHANGED
@@ -10,5 +10,5 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
10
10
  from plexus.client import Plexus, PlexusError, AuthenticationError, read_mjpeg_frames
11
11
  from plexus.config import RetryConfig
12
12
 
13
- __version__ = "0.7.1"
13
+ __version__ = "0.8.0"
14
14
  __all__ = ["Plexus", "PlexusError", "AuthenticationError", "RetryConfig", "read_mjpeg_frames"]
plexus/client.py CHANGED
@@ -686,6 +686,7 @@ class Plexus:
686
686
  *,
687
687
  description: Optional[str] = None,
688
688
  params: Optional[List[Dict[str, Any]]] = None,
689
+ concurrency: str = "accept",
689
690
  ) -> None:
690
691
  """Register a command handler (WebSocket transport only).
691
692
 
@@ -695,6 +696,12 @@ class Plexus:
695
696
 
696
697
  Must be called before the first send() so the command is advertised
697
698
  in the auth frame.
699
+
700
+ concurrency: "accept" (default) runs overlapping invocations of the
701
+ same command concurrently; "reject" refuses a new invocation with
702
+ an error result while a previous one is still running. Use
703
+ "reject" for handlers that drive exclusive hardware (e.g. a pump
704
+ init) so a retry or double-click can't start two at once.
698
705
  """
699
706
  ws = self._ensure_ws()
700
707
  if ws.is_authenticated:
@@ -704,7 +711,10 @@ class Plexus:
704
711
  "Call on_command() before the first send().",
705
712
  name,
706
713
  )
707
- ws.register_command(name, handler, description=description, params=params)
714
+ ws.register_command(
715
+ name, handler, description=description, params=params,
716
+ concurrency=concurrency,
717
+ )
708
718
 
709
719
  def _send_points(self, points: List[Dict[str, Any]]) -> bool:
710
720
  """Send data points to the gateway with retry and buffering.
plexus/config.py CHANGED
@@ -49,8 +49,8 @@ CONFIG_DIR = Path.home() / ".plexus"
49
49
  CONFIG_FILE = CONFIG_DIR / "config.json"
50
50
 
51
51
  PLEXUS_ENDPOINT = "https://app.plexus.company"
52
- PLEXUS_GATEWAY_URL = "https://plexus-gateway.fly.dev"
53
- PLEXUS_GATEWAY_WS_URL = "wss://plexus-gateway.fly.dev"
52
+ PLEXUS_GATEWAY_URL = "https://gateway.plexus.company"
53
+ PLEXUS_GATEWAY_WS_URL = "wss://gateway.plexus.company"
54
54
 
55
55
  DEFAULT_CONFIG = {
56
56
  "api_key": None,
plexus/ws.py CHANGED
@@ -62,6 +62,11 @@ class _RegisteredCommand:
62
62
  handler: CommandHandler
63
63
  description: Optional[str] = None
64
64
  params: List[Dict[str, Any]] = field(default_factory=list)
65
+ # "accept" (default): run overlapping invocations concurrently.
66
+ # "reject": refuse a new invocation with an error result while a
67
+ # previous one of the same command is still running.
68
+ concurrency: str = "accept"
69
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
65
70
 
66
71
  def to_manifest(self) -> Dict[str, Any]:
67
72
  m: Dict[str, Any] = {"name": self.name}
@@ -127,11 +132,24 @@ class WebSocketTransport:
127
132
  *,
128
133
  description: Optional[str] = None,
129
134
  params: Optional[List[Dict[str, Any]]] = None,
135
+ concurrency: str = "accept",
130
136
  ) -> None:
131
137
  """Register a command handler. Must be called before start() to be
132
- advertised in the auth frame."""
138
+ advertised in the auth frame.
139
+
140
+ concurrency controls what happens when a command arrives while a
141
+ previous invocation of the *same* command is still running:
142
+ "accept" (default) — run it concurrently on a new thread.
143
+ "reject" — refuse it with an error result until the
144
+ in-flight invocation finishes.
145
+ """
146
+ if concurrency not in ("accept", "reject"):
147
+ raise ValueError(
148
+ f"concurrency must be 'accept' or 'reject', got {concurrency!r}"
149
+ )
133
150
  self._commands[name] = _RegisteredCommand(
134
- name=name, handler=handler, description=description, params=params or []
151
+ name=name, handler=handler, description=description,
152
+ params=params or [], concurrency=concurrency,
135
153
  )
136
154
 
137
155
  def start(self) -> None:
@@ -371,13 +389,42 @@ class WebSocketTransport:
371
389
  })
372
390
  return
373
391
 
392
+ # concurrency="reject": if an invocation is already running, refuse
393
+ # this one immediately rather than starting a second in parallel.
394
+ holds_lock = False
395
+ if reg.concurrency == "reject":
396
+ if not reg._lock.acquire(blocking=False):
397
+ self._send_frame({
398
+ "type": "command_result",
399
+ "id": cmd_id,
400
+ "command": command,
401
+ "event": "error",
402
+ "error": f"command already in progress: {command}",
403
+ })
404
+ return
405
+ holds_lock = True
406
+
374
407
  # Run the handler off the read-loop thread so a slow handler doesn't
375
408
  # block heartbeats or other inbound frames.
376
- threading.Thread(
377
- target=self._run_handler,
378
- args=(reg, cmd_id, command, params),
379
- daemon=True,
380
- ).start()
409
+ try:
410
+ threading.Thread(
411
+ target=self._run_handler,
412
+ args=(reg, cmd_id, command, params, holds_lock),
413
+ daemon=True,
414
+ ).start()
415
+ except Exception as e:
416
+ # Thread creation failed (e.g. resource exhaustion). Release the
417
+ # concurrency lock so the command isn't wedged as "in progress",
418
+ # and report the failure rather than leaving it silently un-acked.
419
+ if holds_lock:
420
+ reg._lock.release()
421
+ self._send_frame({
422
+ "type": "command_result",
423
+ "id": cmd_id,
424
+ "command": command,
425
+ "event": "error",
426
+ "error": f"failed to start command handler: {e}",
427
+ })
381
428
 
382
429
  def _run_handler(
383
430
  self,
@@ -385,6 +432,7 @@ class WebSocketTransport:
385
432
  cmd_id: str,
386
433
  command: str,
387
434
  params: Dict[str, Any],
435
+ holds_lock: bool = False,
388
436
  ) -> None:
389
437
  try:
390
438
  result = reg.handler(command, params)
@@ -397,6 +445,11 @@ class WebSocketTransport:
397
445
  "error": str(e),
398
446
  })
399
447
  return
448
+ finally:
449
+ # Release the concurrency lock (if held) as soon as the handler
450
+ # returns, regardless of success or failure.
451
+ if holds_lock:
452
+ reg._lock.release()
400
453
  self._send_frame({
401
454
  "type": "command_result",
402
455
  "id": cmd_id,
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
- Project-URL: Homepage, https://plexus.dev
6
- Project-URL: Documentation, https://docs.plexus.dev
5
+ Project-URL: Homepage, https://plexus.company
6
+ Project-URL: Documentation, https://docs.plexus.company
7
7
  Project-URL: Repository, https://github.com/plexus-oss/plexus-python
8
8
  Project-URL: Issues, https://github.com/plexus-oss/plexus-python/issues
9
- Author-email: Plexus <hello@plexus.dev>
9
+ Author-email: Plexus <info@plexus.company>
10
10
  License-Expression: Apache-2.0
11
11
  License-File: LICENSE
12
12
  Keywords: fleet,hardware,iot,monitoring,observability,telemetry
@@ -270,8 +270,8 @@ The SDK sends an `ack` frame before invoking the handler, then a `result` frame
270
270
  | Variable | Description | Default |
271
271
  | ----------------------- | ---------------------------- | -------------------------------- |
272
272
  | `PLEXUS_API_KEY` | API key (required) | none |
273
- | `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://plexus-gateway.fly.dev` |
274
- | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://plexus-gateway.fly.dev` |
273
+ | `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://gateway.plexus.company` |
274
+ | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://gateway.plexus.company` |
275
275
 
276
276
  ## Architecture
277
277
 
@@ -0,0 +1,14 @@
1
+ plexus/__init__.py,sha256=O9GpL-CqlDKRoEfQq5ojYNsIMa7CtXuYvdw3zCrC8pg,447
2
+ plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
3
+ plexus/buffer.py,sha256=0i6PLgoj904jFNv9RCrlskvPQsPSu_KNdYWMasFOvsg,9596
4
+ plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
5
+ plexus/client.py,sha256=12qnUgmZbTEXuSFtMCAXqosh-V84odSiqagbhtfjAc8,36280
6
+ plexus/config.py,sha256=Y4rPo1zeqAOnz-pQWrDBAXZ1gjfr0UD5Q6HHTMmbKew,4450
7
+ plexus/ws.py,sha256=PSVVrS1vpGi20qGdRfTi4LWCXzt3Dx3hR09FuoT-QIs,18643
8
+ plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
9
+ plexus/cameras/thermal.py,sha256=7o33QsF1RiZLManTxZ2E36nO8lRAHppCDkS3zXBHCxs,12047
10
+ plexus_python-0.8.0.dist-info/METADATA,sha256=MfH0vB7R8uBYeJhTTY8Xbd_Qi3qCYZ3jzSIS8cPFNmg,11750
11
+ plexus_python-0.8.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ plexus_python-0.8.0.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
13
+ plexus_python-0.8.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
14
+ plexus_python-0.8.0.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- plexus/__init__.py,sha256=ZzZDGRCztNWGUtN-YF1EwwYzngxsZkU9zb_rW1Tfalg,447
2
- plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
3
- plexus/buffer.py,sha256=0i6PLgoj904jFNv9RCrlskvPQsPSu_KNdYWMasFOvsg,9596
4
- plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
5
- plexus/client.py,sha256=SZ4V9FN-bt3QKqVgp9S5QKdRXw934WXZxKlYUb7bWHw,35810
6
- plexus/config.py,sha256=RNym2Fon6JOCVi1rXPSRWjPFAdT8DSmokY5JPEljQOc,4450
7
- plexus/ws.py,sha256=9DiQchqCQU7O8r8-FuqotJh8vYAQBO7npJn4BFNzLAE,16242
8
- plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
9
- plexus/cameras/thermal.py,sha256=7o33QsF1RiZLManTxZ2E36nO8lRAHppCDkS3zXBHCxs,12047
10
- plexus_python-0.7.1.dist-info/METADATA,sha256=OUDqqIIcHUaZms1QCwL7XdaFo6j11LeN0HZ3EipZEIM,11739
11
- plexus_python-0.7.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
- plexus_python-0.7.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
13
- plexus_python-0.7.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
14
- plexus_python-0.7.1.dist-info/RECORD,,