splime 0.4.4__tar.gz → 0.4.5__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 (112) hide show
  1. {splime-0.4.4/src/splime.egg-info → splime-0.4.5}/PKG-INFO +36 -5
  2. {splime-0.4.4 → splime-0.4.5}/README.md +35 -4
  3. {splime-0.4.4 → splime-0.4.5}/pyproject.toml +1 -1
  4. {splime-0.4.4 → splime-0.4.5}/src/spl/_client.py +34 -2
  5. splime-0.4.5/src/spl/_http.py +289 -0
  6. splime-0.4.5/src/spl/_process.py +128 -0
  7. splime-0.4.5/src/spl/_timeout.py +49 -0
  8. {splime-0.4.4 → splime-0.4.5}/src/spl/core/_common.py +749 -116
  9. splime-0.4.5/src/spl/core/_graph.py +36 -0
  10. {splime-0.4.4 → splime-0.4.5}/src/spl/core/adapter_compat.py +313 -30
  11. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/adapter.py +158 -2
  12. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/function.py +2 -2
  13. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/node_remote.py +39 -7
  14. splime-0.4.5/src/spl/core/entities/pipeline.py +1038 -0
  15. splime-0.4.5/src/spl/core/entities/scalar.py +86 -0
  16. {splime-0.4.4 → splime-0.4.5}/src/spl/core/fingerprint.py +7 -16
  17. splime-0.4.5/src/spl/core/json_contract.py +280 -0
  18. {splime-0.4.4 → splime-0.4.5}/src/spl/core/manifest.py +71 -42
  19. {splime-0.4.4 → splime-0.4.5}/src/spl/core/node_runtime.py +100 -29
  20. splime-0.4.5/src/spl/core/redaction.py +303 -0
  21. {splime-0.4.4 → splime-0.4.5}/src/spl/core/resume.py +108 -0
  22. splime-0.4.5/src/spl/daemon/artifact_access.py +249 -0
  23. splime-0.4.5/src/spl/daemon/callback_capability.py +276 -0
  24. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/canonical.py +20 -10
  25. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/cli.py +40 -3
  26. splime-0.4.5/src/spl/daemon/docker_pool.py +1331 -0
  27. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/environment.py +2 -5
  28. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/environment_base.py +21 -11
  29. splime-0.4.5/src/spl/daemon/home_lock.py +520 -0
  30. splime-0.4.5/src/spl/daemon/metadata.py +1297 -0
  31. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/remote_client.py +163 -60
  32. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/env.py +2 -5
  33. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/library.py +75 -34
  34. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/object.py +81 -4
  35. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/run.py +421 -15
  36. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/sync_event.py +222 -5
  37. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/_helpers.py +28 -3
  38. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/artifacts.py +16 -5
  39. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/diagnostics.py +2 -0
  40. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/remote.py +20 -6
  41. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/runs.py +13 -5
  42. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/runtime_backend.py +38 -3
  43. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/runtime_config.py +8 -0
  44. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/runtime_dependencies.py +52 -3
  45. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/secret_store.py +3 -1
  46. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/server.py +1336 -158
  47. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/services/sync.py +11 -1
  48. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/spl_free_runner.py +85 -6
  49. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/storage_base.py +278 -3
  50. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/store.py +72 -8
  51. splime-0.4.5/src/spl/daemon/telemetry.py +916 -0
  52. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/worker.py +127 -51
  53. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/worker_runtime_marker.py +1 -0
  54. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon_client.py +31 -9
  55. {splime-0.4.4 → splime-0.4.5}/src/spl/pipeline_widget.py +7 -10
  56. {splime-0.4.4 → splime-0.4.5}/src/spl/server_client.py +19 -9
  57. {splime-0.4.4 → splime-0.4.5/src/splime.egg-info}/PKG-INFO +36 -5
  58. {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/SOURCES.txt +9 -0
  59. splime-0.4.4/src/spl/_http.py +0 -123
  60. splime-0.4.4/src/spl/core/entities/pipeline.py +0 -526
  61. splime-0.4.4/src/spl/core/entities/scalar.py +0 -48
  62. splime-0.4.4/src/spl/daemon/docker_pool.py +0 -569
  63. splime-0.4.4/src/spl/daemon/metadata.py +0 -401
  64. {splime-0.4.4 → splime-0.4.5}/LICENSE +0 -0
  65. {splime-0.4.4 → splime-0.4.5}/NOTICE +0 -0
  66. {splime-0.4.4 → splime-0.4.5}/setup.cfg +0 -0
  67. {splime-0.4.4 → splime-0.4.5}/src/spl/__init__.py +0 -0
  68. {splime-0.4.4 → splime-0.4.5}/src/spl/_deprecate.py +0 -0
  69. {splime-0.4.4 → splime-0.4.5}/src/spl/_owner_ref.py +0 -0
  70. {splime-0.4.4 → splime-0.4.5}/src/spl/_views.py +0 -0
  71. {splime-0.4.4 → splime-0.4.5}/src/spl/client.py +0 -0
  72. {splime-0.4.4 → splime-0.4.5}/src/spl/core/__init__.py +0 -0
  73. {splime-0.4.4 → splime-0.4.5}/src/spl/core/common.py +0 -0
  74. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/__init__.py +0 -0
  75. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/artifact.py +0 -0
  76. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/control.py +0 -0
  77. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/distribution.py +0 -0
  78. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/local_function.py +0 -0
  79. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/misc.py +0 -0
  80. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/module.py +0 -0
  81. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/node.py +0 -0
  82. {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/node_function.py +0 -0
  83. {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/__init__.py +0 -0
  84. {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/common.py +0 -0
  85. {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/parse.py +0 -0
  86. {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/unparse.py +0 -0
  87. {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/utils.py +0 -0
  88. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/__init__.py +0 -0
  89. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/__main__.py +0 -0
  90. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/client.py +0 -0
  91. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/docker_environment.py +0 -0
  92. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/doctor.py +0 -0
  93. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/heartbeat_service.py +0 -0
  94. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/interpreter_visibility.py +0 -0
  95. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/__init__.py +0 -0
  96. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/server_connection.py +0 -0
  97. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/__init__.py +0 -0
  98. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/envs.py +0 -0
  99. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/libraries.py +0 -0
  100. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/objects.py +0 -0
  101. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/server_connections.py +0 -0
  102. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/run_lifecycle.py +0 -0
  103. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/run_progress.py +0 -0
  104. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/server_connection.py +0 -0
  105. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/services/__init__.py +0 -0
  106. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/signature.py +0 -0
  107. {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/spl_free_generator.py +0 -0
  108. {splime-0.4.4 → splime-0.4.5}/src/spl/py.typed +0 -0
  109. {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/dependency_links.txt +0 -0
  110. {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/entry_points.txt +0 -0
  111. {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/requires.txt +0 -0
  112. {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splime
3
- Version: 0.4.4
3
+ Version: 0.4.5
4
4
  Summary: Reuse Python functions across projects without rewriting or redeploying them.
5
5
  Author-email: Yastrebov Kirill <yastrebovks@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -76,11 +76,14 @@ redeploying.
76
76
  ## Requirements
77
77
 
78
78
  - Python **3.13+**
79
+ - POSIX for local daemon environment builds and timeout-safe worker execution
80
+ in 0.4.5 (Windows Job Object support is not yet implemented; Windows
81
+ client-only use is unaffected)
79
82
 
80
83
  ## Install
81
84
 
82
85
  ```bash
83
- pip install splime
86
+ python3.13 -m pip install "splime==0.4.5"
84
87
  ```
85
88
 
86
89
  The distribution is named `splime`; the Python import package is `spl`.
@@ -119,7 +122,7 @@ it by name and get back the value (plus logs and any artifacts).
119
122
 
120
123
  The same `call` becomes a remote run when you point it at a library, an owner, or a
121
124
  target machine. This requires a connected splime server and a private worker; the local
122
- daemon builds an isolated environment on the worker before executing.
125
+ daemon builds the declared dependency environment on the worker before executing.
123
126
 
124
127
  ```python
125
128
  client = SPLClient(user_token="…", machine_token="…") # connect the daemon to your server
@@ -157,10 +160,38 @@ splime runs code that you publish on purpose, on machines you control. It is bui
157
160
 
158
161
  - **explicit ownership** of every published object,
159
162
  - **scoped access** grants per library (read metadata, read objects, execute),
160
- - **private worker boundaries** — the server coordinates, your own workers execute,
161
- - **isolated environments** built by the daemon before a run,
163
+ - **private worker topology** — the server coordinates, your own workers execute,
164
+ - **dependency environments** built by the daemon before a run,
165
+ - **metadata-only central telemetry by default** — local inputs, results,
166
+ error details, streams, and artifact bodies stay on the machine; diagnostic
167
+ adds only redacted/truncated error and stdout/stderr text, while full also
168
+ opts in to redacted inputs, results, and supported text artifacts,
162
169
  - an **auditable run history**.
163
170
 
171
+ Credentialed central-server traffic requires a direct HTTPS endpoint and never
172
+ follows redirects. Loopback HTTP remains available for local development; the
173
+ only other plaintext path is the exact Docker callback carrying only a scoped
174
+ run capability.
175
+
176
+ Native and `venv-subprocess` runtimes execute trusted code under the
177
+ conductor's OS identity—the daemon user for daemon-managed runs. A virtual
178
+ environment separates dependencies and a subprocess separates execution, but
179
+ neither is an OS sandbox. Docker or a deliberately separate OS identity is
180
+ required when code must not read same-UID daemon files. Docker provides the
181
+ configured process/filesystem boundary, subject to its mounts and network
182
+ options and to trust in the Docker daemon and host.
183
+
184
+ Scoped callback capabilities limit the authority intentionally passed over the
185
+ worker protocol. They do not protect against arbitrary same-UID file reads and
186
+ do not turn a native or virtual-environment worker into a sandbox.
187
+
188
+ Docker object runs use one container per run by default and mount only that
189
+ run's writable directory. Warm pooling is explicitly opt-in with
190
+ `spl-daemon serve --docker-pool-enabled --docker-pool-size N`; pooled
191
+ containers share all runs for that daemon and are suitable only for
192
+ single-tenant, mutually trusting workloads. One process also exclusively locks
193
+ each daemon home before opening its database or publishing its endpoint.
194
+
164
195
  ## How it fits together
165
196
 
166
197
  | Piece | What it does |
@@ -21,11 +21,14 @@ redeploying.
21
21
  ## Requirements
22
22
 
23
23
  - Python **3.13+**
24
+ - POSIX for local daemon environment builds and timeout-safe worker execution
25
+ in 0.4.5 (Windows Job Object support is not yet implemented; Windows
26
+ client-only use is unaffected)
24
27
 
25
28
  ## Install
26
29
 
27
30
  ```bash
28
- pip install splime
31
+ python3.13 -m pip install "splime==0.4.5"
29
32
  ```
30
33
 
31
34
  The distribution is named `splime`; the Python import package is `spl`.
@@ -64,7 +67,7 @@ it by name and get back the value (plus logs and any artifacts).
64
67
 
65
68
  The same `call` becomes a remote run when you point it at a library, an owner, or a
66
69
  target machine. This requires a connected splime server and a private worker; the local
67
- daemon builds an isolated environment on the worker before executing.
70
+ daemon builds the declared dependency environment on the worker before executing.
68
71
 
69
72
  ```python
70
73
  client = SPLClient(user_token="…", machine_token="…") # connect the daemon to your server
@@ -102,10 +105,38 @@ splime runs code that you publish on purpose, on machines you control. It is bui
102
105
 
103
106
  - **explicit ownership** of every published object,
104
107
  - **scoped access** grants per library (read metadata, read objects, execute),
105
- - **private worker boundaries** — the server coordinates, your own workers execute,
106
- - **isolated environments** built by the daemon before a run,
108
+ - **private worker topology** — the server coordinates, your own workers execute,
109
+ - **dependency environments** built by the daemon before a run,
110
+ - **metadata-only central telemetry by default** — local inputs, results,
111
+ error details, streams, and artifact bodies stay on the machine; diagnostic
112
+ adds only redacted/truncated error and stdout/stderr text, while full also
113
+ opts in to redacted inputs, results, and supported text artifacts,
107
114
  - an **auditable run history**.
108
115
 
116
+ Credentialed central-server traffic requires a direct HTTPS endpoint and never
117
+ follows redirects. Loopback HTTP remains available for local development; the
118
+ only other plaintext path is the exact Docker callback carrying only a scoped
119
+ run capability.
120
+
121
+ Native and `venv-subprocess` runtimes execute trusted code under the
122
+ conductor's OS identity—the daemon user for daemon-managed runs. A virtual
123
+ environment separates dependencies and a subprocess separates execution, but
124
+ neither is an OS sandbox. Docker or a deliberately separate OS identity is
125
+ required when code must not read same-UID daemon files. Docker provides the
126
+ configured process/filesystem boundary, subject to its mounts and network
127
+ options and to trust in the Docker daemon and host.
128
+
129
+ Scoped callback capabilities limit the authority intentionally passed over the
130
+ worker protocol. They do not protect against arbitrary same-UID file reads and
131
+ do not turn a native or virtual-environment worker into a sandbox.
132
+
133
+ Docker object runs use one container per run by default and mount only that
134
+ run's writable directory. Warm pooling is explicitly opt-in with
135
+ `spl-daemon serve --docker-pool-enabled --docker-pool-size N`; pooled
136
+ containers share all runs for that daemon and are suitable only for
137
+ single-tenant, mutually trusting workloads. One process also exclusively locks
138
+ each daemon home before opening its database or publishing its endpoint.
139
+
109
140
  ## How it fits together
110
141
 
111
142
  | Piece | What it does |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splime"
7
- version = "0.4.4"
7
+ version = "0.4.5"
8
8
 
9
9
  description = "Reuse Python functions across projects without rewriting or redeploying them."
10
10
  readme = "README.md"
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import builtins
22
22
  import re
23
+ import warnings
23
24
  from collections.abc import Mapping
24
25
  from dataclasses import dataclass, field, replace
25
26
  from html import escape
@@ -735,6 +736,30 @@ class RemoteRun:
735
736
  )
736
737
  return downloaded
737
738
 
739
+ def _acknowledge_transient_delivery(self) -> None:
740
+ """Best-effort additive handshake after terminal data is consumed."""
741
+
742
+ if self.server_side:
743
+ return
744
+ try:
745
+ self._client._daemon.acknowledge_run_delivery(self.id)
746
+ except ClientError as exc:
747
+ if exc.status_code in {404, 405}:
748
+ # A 0.4.5 client can still collect from a 0.4.4 daemon. That
749
+ # daemon does not enforce transient cleanup, so no data is at risk.
750
+ return
751
+ warnings.warn(
752
+ f"run {self.id!r} was delivered, but its retention acknowledgment failed: {exc}",
753
+ RuntimeWarning,
754
+ stacklevel=2,
755
+ )
756
+ except Exception as exc:
757
+ warnings.warn(
758
+ f"run {self.id!r} was delivered, but its retention acknowledgment failed: {exc}",
759
+ RuntimeWarning,
760
+ stacklevel=2,
761
+ )
762
+
738
763
  def collect(
739
764
  self,
740
765
  *,
@@ -753,18 +778,25 @@ class RemoteRun:
753
778
  timeout_seconds=timeout_seconds,
754
779
  progress=progress,
755
780
  )
756
- if final_state["status"] != "succeeded":
781
+ status = str(final_state["status"])
782
+ transient = m_manifest.retention_disposition(final_state.get("keep", True), status) == "remove"
783
+ if status != "succeeded":
757
784
  error = final_state.get("error") or "run returned no error message"
785
+ if transient:
786
+ self._acknowledge_transient_delivery()
758
787
  raise RuntimeError(f"{self.mode} run {self.id!r} ended as {final_state.get('status')!r}: {error}")
759
788
 
760
789
  payload = self.result()
761
790
  downloaded = self.download_artifacts(artifacts_dir) if artifacts_dir is not None else {}
762
- return RemoteResult(
791
+ result = RemoteResult(
763
792
  run=final_state,
764
793
  payload=payload,
765
794
  mode=self.mode,
766
795
  downloaded_artifacts=downloaded,
767
796
  )
797
+ if transient:
798
+ self._acknowledge_transient_delivery()
799
+ return result
768
800
 
769
801
 
770
802
  class _LibraryAdmin:
@@ -0,0 +1,289 @@
1
+ """Safe HTTP helpers shared by local-daemon and central-server clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import logging
7
+ import ssl
8
+ from functools import lru_cache, partial
9
+ from typing import Any
10
+ from urllib.error import HTTPError
11
+ from urllib.parse import urlsplit
12
+ from urllib.request import (
13
+ HTTPHandler,
14
+ HTTPRedirectHandler,
15
+ HTTPSHandler,
16
+ ProxyHandler,
17
+ Request,
18
+ build_opener,
19
+ )
20
+
21
+ DEFAULT_CONNECT_TIMEOUT_SECONDS = 10.0
22
+ DEFAULT_HTTP_TIMEOUT_SECONDS = 60.0
23
+ DEFAULT_FILE_TRANSFER_TIMEOUT_SECONDS = 300.0
24
+
25
+ _LOOPBACK_HTTP_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
26
+ _DOCKER_CALLBACK_HTTP_HOST = "host.docker.internal"
27
+ _DOCKER_CALLBACK_PATH = "/remote-nodes/run"
28
+ _CREDENTIAL_HEADER_NAMES = frozenset(
29
+ {
30
+ "authorization",
31
+ "cookie",
32
+ "proxy-authorization",
33
+ "x-spl-claim",
34
+ "x-spl-user-token",
35
+ }
36
+ )
37
+ _LOGGER = logging.getLogger(__name__)
38
+
39
+
40
+ class InsecureCredentialTransportError(ValueError):
41
+ """Reject a credentialed request that would use unsafe plaintext HTTP."""
42
+
43
+
44
+ class _NoRedirectHandler(HTTPRedirectHandler):
45
+ """Surface redirects without replaying a request or any of its headers."""
46
+
47
+ def redirect_request(
48
+ self,
49
+ request: Request,
50
+ file_pointer: Any,
51
+ code: int,
52
+ message: str,
53
+ headers: Any,
54
+ new_url: str,
55
+ ) -> Request | None:
56
+ del message, new_url
57
+ raise HTTPError(
58
+ request.full_url,
59
+ code,
60
+ (
61
+ f"HTTP {code} redirect refused: SPL HTTP requests do not follow redirects; "
62
+ "configure the client with the final HTTPS endpoint instead"
63
+ ),
64
+ headers,
65
+ file_pointer,
66
+ )
67
+
68
+
69
+ @lru_cache(maxsize=1)
70
+ def _warn_loopback_http_once() -> None:
71
+ _LOGGER.warning(
72
+ "SPL is sending credentials over plaintext HTTP to an explicit loopback development target; "
73
+ "use HTTPS outside local development"
74
+ )
75
+
76
+
77
+ @lru_cache(maxsize=1)
78
+ def _warn_docker_callback_http_once() -> None:
79
+ _LOGGER.warning(
80
+ "SPL is sending a run-scoped callback capability over plaintext HTTP to the local Docker host bridge"
81
+ )
82
+
83
+
84
+ def _credential_header_names(request: Request) -> set[str]:
85
+ return {
86
+ name.casefold()
87
+ for name, value in request.header_items()
88
+ if value and name.casefold() in _CREDENTIAL_HEADER_NAMES
89
+ }
90
+
91
+
92
+ def _authorization_bearer_token(request: Request) -> str | None:
93
+ """Return one syntactically valid bearer token without logging it."""
94
+
95
+ authorization = next(
96
+ (value for name, value in request.header_items() if name.casefold() == "authorization"),
97
+ "",
98
+ )
99
+ scheme, separator, token = authorization.partition(" ")
100
+ if scheme.casefold() != "bearer" or not separator or not token:
101
+ return None
102
+ return token
103
+
104
+
105
+ def _validate_transport(
106
+ request: Request,
107
+ *,
108
+ allow_docker_callback_http: bool,
109
+ contains_credentials: bool,
110
+ ) -> bool:
111
+ """Validate one request and return whether environment proxies must be bypassed."""
112
+
113
+ parsed = urlsplit(request.full_url)
114
+ scheme = parsed.scheme.casefold()
115
+ host = (parsed.hostname or "").casefold()
116
+ credential_headers = _credential_header_names(request)
117
+ url_contains_credentials = parsed.username is not None or parsed.password is not None
118
+ is_credentialed = contains_credentials or bool(credential_headers) or url_contains_credentials
119
+
120
+ if scheme not in {"http", "https"} or not host:
121
+ raise ValueError("SPL HTTP request URLs must use an absolute http:// or https:// URL with a host")
122
+
123
+ if allow_docker_callback_http:
124
+ # Import lazily to keep the generic transport module out of the
125
+ # daemon package's compatibility-import cycle while sharing the exact
126
+ # token namespace with the authority that mints it.
127
+ from spl.daemon.callback_capability import CALLBACK_CAPABILITY_PREFIX
128
+
129
+ bearer_token = _authorization_bearer_token(request)
130
+ docker_callback_is_valid = (
131
+ scheme == "http"
132
+ and host == _DOCKER_CALLBACK_HTTP_HOST
133
+ and parsed.path == _DOCKER_CALLBACK_PATH
134
+ and not parsed.query
135
+ and not parsed.fragment
136
+ and request.get_method().upper() == "POST"
137
+ and bearer_token is not None
138
+ and bearer_token.startswith(CALLBACK_CAPABILITY_PREFIX)
139
+ and len(bearer_token) > len(CALLBACK_CAPABILITY_PREFIX)
140
+ and credential_headers == {"authorization"}
141
+ and not contains_credentials
142
+ )
143
+ if not docker_callback_is_valid:
144
+ raise InsecureCredentialTransportError(
145
+ "the Docker plaintext callback exception is limited to a POST authenticated with a "
146
+ "Bearer splcb_... run callback capability to "
147
+ "http://host.docker.internal/remote-nodes/run without a query or any other credential"
148
+ )
149
+ _warn_docker_callback_http_once()
150
+ return True
151
+
152
+ is_loopback = host in _LOOPBACK_HTTP_HOSTS
153
+ if scheme == "http" and is_credentialed:
154
+ if not is_loopback:
155
+ raise InsecureCredentialTransportError(
156
+ "credentialed SPL HTTP requests require HTTPS; plaintext is allowed only for the explicit "
157
+ "loopback development hosts 127.0.0.1, ::1, and localhost"
158
+ )
159
+ _warn_loopback_http_once()
160
+
161
+ return is_loopback
162
+
163
+
164
+ class ConnectionPhaseError(OSError):
165
+ """Wrap a failure proven to occur while establishing the connection."""
166
+
167
+ def __init__(self, cause: OSError):
168
+ self.cause = cause
169
+ super().__init__(str(cause))
170
+
171
+
172
+ class _PhaseAwareHTTPConnection(http.client.HTTPConnection):
173
+ def __init__(
174
+ self,
175
+ host: str,
176
+ *,
177
+ read_timeout: float | None,
178
+ **kwargs: Any,
179
+ ) -> None:
180
+ self._spl_read_timeout = read_timeout
181
+ super().__init__(host, **kwargs)
182
+
183
+ def connect(self) -> None:
184
+ try:
185
+ super().connect()
186
+ except OSError as exc:
187
+ raise ConnectionPhaseError(exc) from exc
188
+ if self.sock is not None:
189
+ self.sock.settimeout(self._spl_read_timeout)
190
+
191
+
192
+ class _PhaseAwareHTTPSConnection(http.client.HTTPSConnection):
193
+ def __init__(
194
+ self,
195
+ host: str,
196
+ *,
197
+ read_timeout: float | None,
198
+ **kwargs: Any,
199
+ ) -> None:
200
+ self._spl_read_timeout = read_timeout
201
+ super().__init__(host, **kwargs)
202
+
203
+ def connect(self) -> None:
204
+ try:
205
+ # HTTPSConnection.connect includes TCP connect, any proxy tunnel,
206
+ # and the TLS handshake. No application request has been sent yet.
207
+ super().connect()
208
+ except OSError as exc:
209
+ raise ConnectionPhaseError(exc) from exc
210
+ if self.sock is not None:
211
+ self.sock.settimeout(self._spl_read_timeout)
212
+
213
+
214
+ class _SplitTimeoutHTTPHandler(HTTPHandler):
215
+ def __init__(self, *, read_timeout: float | None) -> None:
216
+ super().__init__()
217
+ self._spl_read_timeout = read_timeout
218
+
219
+ def http_open(self, request: Request) -> Any:
220
+ connection = partial(
221
+ _PhaseAwareHTTPConnection,
222
+ read_timeout=self._spl_read_timeout,
223
+ )
224
+ return self.do_open(connection, request)
225
+
226
+
227
+ class _SplitTimeoutHTTPSHandler(HTTPSHandler):
228
+ def __init__(
229
+ self,
230
+ *,
231
+ context: ssl.SSLContext,
232
+ read_timeout: float | None,
233
+ ) -> None:
234
+ super().__init__(context=context)
235
+ self._spl_context = context
236
+ self._spl_read_timeout = read_timeout
237
+
238
+ def https_open(self, request: Request) -> Any:
239
+ connection = partial(
240
+ _PhaseAwareHTTPSConnection,
241
+ read_timeout=self._spl_read_timeout,
242
+ )
243
+ return self.do_open(connection, request, context=self._spl_context)
244
+
245
+
246
+ @lru_cache(maxsize=1)
247
+ def verified_https_context() -> ssl.SSLContext:
248
+ """Return a verifying context with environment/system and certifi roots."""
249
+
250
+ import certifi
251
+
252
+ context = ssl.create_default_context()
253
+ context.load_verify_locations(cafile=certifi.where())
254
+ return context
255
+
256
+
257
+ def urlopen_verified(
258
+ request: Request,
259
+ *,
260
+ timeout: float | None = DEFAULT_HTTP_TIMEOUT_SECONDS,
261
+ connect_timeout: float | None = DEFAULT_CONNECT_TIMEOUT_SECONDS,
262
+ allow_docker_callback_http: bool = False,
263
+ contains_credentials: bool = False,
264
+ ) -> Any:
265
+ """Open a policy-checked request with verified TLS and split timeout budgets.
266
+
267
+ ``contains_credentials`` marks authentication material carried in a body
268
+ instead of a recognized header. ``allow_docker_callback_http`` is a narrow
269
+ exception for the run-scoped worker callback route only.
270
+ """
271
+
272
+ bypass_environment_proxies = _validate_transport(
273
+ request,
274
+ allow_docker_callback_http=allow_docker_callback_http,
275
+ contains_credentials=contains_credentials,
276
+ )
277
+
278
+ handlers: list[Any] = [
279
+ _NoRedirectHandler(),
280
+ _SplitTimeoutHTTPHandler(read_timeout=timeout),
281
+ _SplitTimeoutHTTPSHandler(
282
+ context=verified_https_context(),
283
+ read_timeout=timeout,
284
+ ),
285
+ ]
286
+ if bypass_environment_proxies:
287
+ handlers.insert(0, ProxyHandler({}))
288
+ opener = build_opener(*handlers)
289
+ return opener.open(request, timeout=connect_timeout)
@@ -0,0 +1,128 @@
1
+ """Bounded subprocess execution with POSIX process-tree termination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ from collections.abc import Mapping, Sequence
10
+ from pathlib import Path
11
+ from typing import IO
12
+
13
+ from spl._timeout import TimeoutDomain, validate_timeout_seconds
14
+
15
+ DEFAULT_TERMINATION_GRACE_SECONDS = 0.5
16
+ WINDOWS_TIMEOUT_UNSUPPORTED = (
17
+ "finite subprocess timeouts require Windows Job Object support; "
18
+ "timed worker execution is currently supported only on POSIX"
19
+ )
20
+
21
+
22
+ def run_process_tree(
23
+ command: Sequence[str],
24
+ *,
25
+ cwd: Path | None = None,
26
+ env: Mapping[str, str] | None = None,
27
+ timeout: float | None = None,
28
+ termination_grace_seconds: float = DEFAULT_TERMINATION_GRACE_SECONDS,
29
+ stdout_target: int | IO[str] | None = subprocess.PIPE,
30
+ stderr_target: int | IO[str] | None = subprocess.PIPE,
31
+ ) -> subprocess.CompletedProcess[str]:
32
+ """Run ``command`` and terminate its complete POSIX process group on timeout.
33
+
34
+ A finite timeout fails closed on non-POSIX systems. Windows process groups
35
+ do not contain arbitrary descendants; equivalent support requires assigning
36
+ the child to a Job Object at creation time.
37
+ """
38
+
39
+ validate_timeout_seconds(
40
+ timeout,
41
+ name="process timeout",
42
+ domain=TimeoutDomain.FINITE,
43
+ allow_none=True,
44
+ )
45
+ validate_timeout_seconds(
46
+ termination_grace_seconds,
47
+ name="termination_grace_seconds",
48
+ domain=TimeoutDomain.NON_NEGATIVE,
49
+ allow_none=False,
50
+ )
51
+ process_groups_supported = _posix_process_groups_supported()
52
+ if timeout is not None and not process_groups_supported:
53
+ raise RuntimeError(WINDOWS_TIMEOUT_UNSUPPORTED)
54
+
55
+ process = subprocess.Popen(
56
+ list(command),
57
+ cwd=cwd,
58
+ env=env,
59
+ text=True,
60
+ stdout=stdout_target,
61
+ stderr=stderr_target,
62
+ start_new_session=process_groups_supported,
63
+ )
64
+ try:
65
+ stdout_text, stderr_text = process.communicate(timeout=timeout)
66
+ except subprocess.TimeoutExpired as exc:
67
+ stdout_text, stderr_text = _terminate_timed_out_process(
68
+ process,
69
+ grace_seconds=termination_grace_seconds,
70
+ )
71
+ assert timeout is not None
72
+ raise subprocess.TimeoutExpired(
73
+ command,
74
+ timeout,
75
+ output=stdout_text,
76
+ stderr=stderr_text,
77
+ ) from exc
78
+
79
+ return subprocess.CompletedProcess(
80
+ list(command),
81
+ process.returncode,
82
+ stdout=stdout_text,
83
+ stderr=stderr_text,
84
+ )
85
+
86
+
87
+ def _terminate_timed_out_process(
88
+ process: subprocess.Popen[str],
89
+ *,
90
+ grace_seconds: float,
91
+ ) -> tuple[str, str]:
92
+ """Terminate, force-kill, and reap one timed-out POSIX process group."""
93
+
94
+ _signal_process_group(process.pid, signal.SIGTERM)
95
+ deadline = time.monotonic() + grace_seconds
96
+ while time.monotonic() < deadline:
97
+ if not _process_group_exists(process.pid):
98
+ break
99
+ time.sleep(min(0.01, max(0.0, deadline - time.monotonic())))
100
+ _signal_process_group(process.pid, signal.SIGKILL)
101
+ stdout, stderr = process.communicate()
102
+ return stdout or "", stderr or ""
103
+
104
+
105
+ def _posix_process_groups_supported() -> bool:
106
+ return os.name == "posix"
107
+
108
+
109
+ def _process_group_exists(process_group_id: int) -> bool:
110
+ try:
111
+ os.killpg(process_group_id, 0)
112
+ except ProcessLookupError:
113
+ return False
114
+ except PermissionError:
115
+ return True
116
+ return True
117
+
118
+
119
+ def _signal_process_group(process_group_id: int, sig: signal.Signals) -> None:
120
+ try:
121
+ os.killpg(process_group_id, sig)
122
+ except ProcessLookupError:
123
+ return
124
+ except PermissionError:
125
+ # macOS can report EPERM for a process group containing only a zombie
126
+ # leader between termination and the final wait. The leader is still
127
+ # reaped by ``communicate`` below; a live same-user group is killable.
128
+ return
@@ -0,0 +1,49 @@
1
+ """Shared validation for timeout values that must be safe for runtime APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from enum import StrEnum
7
+
8
+
9
+ class TimeoutDomain(StrEnum):
10
+ """Supported numeric domains for timeout settings."""
11
+
12
+ FINITE = "finite"
13
+ NON_NEGATIVE = "non-negative"
14
+ POSITIVE = "positive"
15
+
16
+
17
+ def validate_timeout_seconds(
18
+ value: object,
19
+ *,
20
+ name: str,
21
+ domain: TimeoutDomain,
22
+ allow_none: bool,
23
+ ) -> float | None:
24
+ """Return ``value`` as a finite float when it satisfies ``domain``."""
25
+
26
+ requirement = {
27
+ TimeoutDomain.FINITE: "a finite number",
28
+ TimeoutDomain.NON_NEGATIVE: "a finite non-negative number",
29
+ TimeoutDomain.POSITIVE: "positive and finite",
30
+ }[domain]
31
+ suffix = " or None" if allow_none else ""
32
+ if value is None:
33
+ if allow_none:
34
+ return None
35
+ raise ValueError(f"{name} must be {requirement}")
36
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
37
+ raise ValueError(f"{name} must be {requirement}{suffix}")
38
+ try:
39
+ normalized = float(value)
40
+ except (OverflowError, ValueError) as exc:
41
+ raise ValueError(f"{name} must be {requirement}{suffix}") from exc
42
+ invalid = (
43
+ not math.isfinite(normalized)
44
+ or (domain is TimeoutDomain.NON_NEGATIVE and normalized < 0)
45
+ or (domain is TimeoutDomain.POSITIVE and normalized <= 0)
46
+ )
47
+ if invalid:
48
+ raise ValueError(f"{name} must be {requirement}{suffix}")
49
+ return normalized