funcd-shim 0.2.0__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 (34) hide show
  1. funcd_shim-0.2.0/.gitignore +6 -0
  2. funcd_shim-0.2.0/PKG-INFO +27 -0
  3. funcd_shim-0.2.0/README.md +13 -0
  4. funcd_shim-0.2.0/embed.go +68 -0
  5. funcd_shim-0.2.0/embed_test.go +33 -0
  6. funcd_shim-0.2.0/pyproject.toml +57 -0
  7. funcd_shim-0.2.0/src/funcd_shim/__init__.py +20 -0
  8. funcd_shim-0.2.0/src/funcd_shim/__main__.py +10 -0
  9. funcd_shim-0.2.0/src/funcd_shim/_poolworker.py +134 -0
  10. funcd_shim-0.2.0/src/funcd_shim/blob.py +117 -0
  11. funcd_shim-0.2.0/src/funcd_shim/build.py +318 -0
  12. funcd_shim-0.2.0/src/funcd_shim/contract.py +89 -0
  13. funcd_shim-0.2.0/src/funcd_shim/funclog.py +166 -0
  14. funcd_shim-0.2.0/src/funcd_shim/invcontext.py +44 -0
  15. funcd_shim-0.2.0/src/funcd_shim/invoke.py +51 -0
  16. funcd_shim-0.2.0/src/funcd_shim/kv.py +107 -0
  17. funcd_shim-0.2.0/src/funcd_shim/pool.py +182 -0
  18. funcd_shim-0.2.0/src/funcd_shim/py.typed +0 -0
  19. funcd_shim-0.2.0/src/funcd_shim/runtime.py +82 -0
  20. funcd_shim-0.2.0/src/funcd_shim/shim.py +238 -0
  21. funcd_shim-0.2.0/src/funcd_shim/tracespan.py +133 -0
  22. funcd_shim-0.2.0/src/funcd_shim/types.py +76 -0
  23. funcd_shim-0.2.0/tests/test_blob.py +104 -0
  24. funcd_shim-0.2.0/tests/test_build.py +262 -0
  25. funcd_shim-0.2.0/tests/test_coldstart.py +68 -0
  26. funcd_shim-0.2.0/tests/test_contract.py +121 -0
  27. funcd_shim-0.2.0/tests/test_funclog.py +144 -0
  28. funcd_shim-0.2.0/tests/test_kv.py +75 -0
  29. funcd_shim-0.2.0/tests/test_pool.py +297 -0
  30. funcd_shim-0.2.0/tests/test_poolworker_malformed.py +59 -0
  31. funcd_shim-0.2.0/tests/test_runtime.py +82 -0
  32. funcd_shim-0.2.0/tests/test_shim.py +300 -0
  33. funcd_shim-0.2.0/tests/test_tracespan.py +225 -0
  34. funcd_shim-0.2.0/uv.lock +420 -0
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.5
2
+ Name: funcd-shim
3
+ Version: 0.2.0
4
+ Summary: The funcd Python runtime shim, the types handlers are written against, and the contract build
5
+ Project-URL: Homepage, https://github.com/pyvvo/funcd-python
6
+ Project-URL: Repository, https://github.com/pyvvo/funcd-python
7
+ Author: The funcd Authors
8
+ License-Expression: Apache-2.0
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: fastjsonschema>=2.21.2
11
+ Provides-Extra: build
12
+ Requires-Dist: pydantic>=2.13.4; extra == 'build'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # funcd-shim
16
+
17
+ The Python side of [funcd](https://github.com/pyvvo/funcd): the runtime shim that loads a
18
+ function's handler inside a funcd worker, the types handlers are written against, and the
19
+ contract build.
20
+
21
+ ```bash
22
+ pip install "funcd-shim[build]"
23
+ ```
24
+
25
+ - `from funcd_shim import CloudEvent, FunctionContext` types a handler.
26
+ - `from funcd_shim.build import build` bakes its input and output contract. It needs the `build`
27
+ extra.
@@ -0,0 +1,13 @@
1
+ # funcd-shim
2
+
3
+ The Python side of [funcd](https://github.com/pyvvo/funcd): the runtime shim that loads a
4
+ function's handler inside a funcd worker, the types handlers are written against, and the
5
+ contract build.
6
+
7
+ ```bash
8
+ pip install "funcd-shim[build]"
9
+ ```
10
+
11
+ - `from funcd_shim import CloudEvent, FunctionContext` types a handler.
12
+ - `from funcd_shim.build import build` bakes its input and output contract. It needs the `build`
13
+ extra.
@@ -0,0 +1,68 @@
1
+ // Package python embeds the funcd Python runtime shim (ADR-0049) into the binary, so the single
2
+ // self-contained `funcd` daemon ships it with no sidecar (ADR-0036, mirroring shim/nodejs). The
3
+ // daemon extracts the package tree to its data dir on boot and points WithRuntimeShimFor("python", …)
4
+ // at the extracted entry script. The shim calls the artifact's precompiled fastjsonschema validator
5
+ // (ADR-0058 — pure-Python, subinterpreter-safe); pydantic + fastjsonschema run only at build time.
6
+ package python
7
+
8
+ import (
9
+ "embed"
10
+ "io/fs"
11
+ "os"
12
+ "path/filepath"
13
+ )
14
+
15
+ // Explicit file list (not a directory glob): includes the underscore-prefixed __init__.py /
16
+ // __main__.py that an `all:` glob would need but that would also drag in __pycache__/*.pyc. Listing
17
+ // the sources by name embeds exactly the shim, nothing machine-generated. Every module the extracted
18
+ // shim imports at runtime is listed — solo (shim.py) and pool (pool.py/_poolworker.py) both import
19
+ // funclog + tracespan (+ tracespan→invcontext) at load, and contract.py (ADR-0123) is compiled at
20
+ // worker init; kv.py backs context.kv. build.py is the push-time AST baker (build-only) — not shipped.
21
+ //
22
+ //go:embed src/funcd_shim/__init__.py src/funcd_shim/__main__.py src/funcd_shim/shim.py src/funcd_shim/invoke.py src/funcd_shim/pool.py src/funcd_shim/_poolworker.py src/funcd_shim/runtime.py src/funcd_shim/types.py src/funcd_shim/contract.py src/funcd_shim/funclog.py src/funcd_shim/tracespan.py src/funcd_shim/invcontext.py src/funcd_shim/kv.py src/funcd_shim/blob.py src/funcd_shim/py.typed
23
+ var shimFS embed.FS
24
+
25
+ // The entry scripts launch the package: Python prepends a script's own directory to sys.path, so
26
+ // placing them beside the extracted `funcd_shim/` package makes `from funcd_shim...` resolve with
27
+ // no PYTHONPATH. Each runs a main() that reads its config (FUNCD_ARTIFACT / FUNCD_POOL_MANIFEST /
28
+ // FUNCD_PORT…) from the env.
29
+ const (
30
+ shimEntryScript = "from funcd_shim.shim import main\nraise SystemExit(main())\n"
31
+ poolEntryScript = "from funcd_shim.pool import main\nraise SystemExit(main())\n" // ADR-0050, needs Python ≥3.14
32
+ )
33
+
34
+ // Extract writes the embedded Python shim package under dir and returns the entry script paths to
35
+ // launch as `python3 <entry>`: shimEntry is the solo shim (ADR-0049), poolEntry the subinterpreter
36
+ // pool host (ADR-0050, requires Python ≥3.14). Idempotent: overwrites whatever is there.
37
+ func Extract(dir string) (shimEntry, poolEntry string, err error) {
38
+ walkErr := fs.WalkDir(shimFS, "src/funcd_shim", func(path string, d fs.DirEntry, e error) error {
39
+ if e != nil {
40
+ return e
41
+ }
42
+ rel, rerr := filepath.Rel("src", path) // funcd_shim/...
43
+ if rerr != nil {
44
+ return rerr
45
+ }
46
+ dst := filepath.Join(dir, rel)
47
+ if d.IsDir() {
48
+ return os.MkdirAll(dst, 0o750)
49
+ }
50
+ data, readErr := shimFS.ReadFile(path)
51
+ if readErr != nil {
52
+ return readErr
53
+ }
54
+ return os.WriteFile(dst, data, 0o600)
55
+ })
56
+ if walkErr != nil {
57
+ return "", "", walkErr
58
+ }
59
+ shimEntry = filepath.Join(dir, "funcd_shim_entry.py")
60
+ if werr := os.WriteFile(shimEntry, []byte(shimEntryScript), 0o600); werr != nil {
61
+ return "", "", werr
62
+ }
63
+ poolEntry = filepath.Join(dir, "funcd_pool_entry.py")
64
+ if werr := os.WriteFile(poolEntry, []byte(poolEntryScript), 0o600); werr != nil {
65
+ return "", "", werr
66
+ }
67
+ return shimEntry, poolEntry, nil
68
+ }
@@ -0,0 +1,33 @@
1
+ package python
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ // TestEmbedIncludesEveryRuntimeModule guards against a new funcd_shim module (e.g. blob.py for context.blob)
11
+ // being added to src/funcd_shim/ but forgotten in embed.go's explicit go:embed list — which would ship a shim
12
+ // that ModuleNotFoundErrors the moment a handler touches the missing accessor (the exact ADR-0127 blob.py miss).
13
+ func TestEmbedIncludesEveryRuntimeModule(t *testing.T) {
14
+ // notShipped: source modules intentionally NOT embedded (build-time only, never imported at runtime).
15
+ notShipped := map[string]bool{
16
+ "build.py": true, // the push-time AST baker (pydantic/typia)
17
+ }
18
+ srcDir := filepath.Join("src", "funcd_shim")
19
+ ents, err := os.ReadDir(srcDir)
20
+ if err != nil {
21
+ t.Fatalf("read %s: %v", srcDir, err)
22
+ }
23
+ for _, e := range ents {
24
+ name := e.Name()
25
+ if e.IsDir() || !strings.HasSuffix(name, ".py") || notShipped[name] {
26
+ continue
27
+ }
28
+ if _, oerr := shimFS.Open("src/funcd_shim/" + name); oerr != nil {
29
+ t.Errorf("source module %q is not in embed.go's go:embed list — the extracted shim would be missing it "+
30
+ "(add it, or add it to notShipped if build-only): %v", name, oerr)
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "funcd-shim"
3
+ # Not bumped per release: a bump would make every uv.lock stale. The vX.Y.Z git tag is the version.
4
+ version = "0.2.0"
5
+ description = "The funcd Python runtime shim, the types handlers are written against, and the contract build"
6
+ authors = [{ name = "The funcd Authors" }]
7
+ readme = "README.md"
8
+ license = "Apache-2.0"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fastjsonschema>=2.21.2",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ # the contract build (funcd_shim.build) reads FuncInput/FuncOutput through pydantic
16
+ build = ["pydantic>=2.13.4"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/pyvvo/funcd-python"
20
+ Repository = "https://github.com/pyvvo/funcd-python"
21
+
22
+ [project.scripts]
23
+ funcd-shim = "funcd_shim.shim:main"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/funcd_shim"]
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "mypy>=1.13",
35
+ "pydantic>=2.13.4",
36
+ "pytest>=8.3",
37
+ "ruff>=0.8",
38
+ ]
39
+
40
+ [tool.mypy]
41
+ strict = true
42
+ python_version = "3.12"
43
+ files = ["src", "tests"]
44
+
45
+ [[tool.mypy.overrides]]
46
+ module = "fastjsonschema.*" # ships no type stubs; used only as a build-time validator generator
47
+ ignore_missing_imports = true
48
+
49
+ [tool.ruff]
50
+ line-length = 110
51
+ target-version = "py312"
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "I", "N", "UP", "B", "A", "S"]
55
+
56
+ [tool.ruff.lint.per-file-ignores]
57
+ "tests/*" = ["S101", "S310"] # asserts are the point of tests; S310 = loopback urllib in tests
@@ -0,0 +1,20 @@
1
+ """funcd Python runtime shim + the typed authoring contract.
2
+
3
+ Function authors import the contract types::
4
+
5
+ from funcd_shim import Handler, CloudEvent, FunctionContext
6
+
7
+ def handle(context: FunctionContext, event: CloudEvent) -> dict:
8
+ ...
9
+
10
+ The shim entrypoint is ``python -m funcd_shim`` (see :mod:`funcd_shim.shim`). It serves the
11
+ runtime-shim HTTP contract and validates ``event.data`` / the result against the optional
12
+ ``FuncInput`` / ``FuncOutput`` **pydantic models** an artifact declares (ADR-0058, supersedes the
13
+ ADR-0038 JTD ``event_schema``). Requires pydantic at runtime.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .types import CloudEvent, FunctionContext, Handler, Json
19
+
20
+ __all__ = ["CloudEvent", "FunctionContext", "Handler", "Json"]
@@ -0,0 +1,10 @@
1
+ """``python -m funcd_shim`` entrypoint — runs the runtime shim (ADR-0049)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .shim import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,134 @@
1
+ """Worker-side logic for the funcd Python pool host (ADR-0050), run INSIDE each subinterpreter by
2
+ ``InterpreterPoolExecutor``. ``init`` loads the handler + the optional I/O validators once per worker
3
+ interpreter (state persists across invocations); ``invoke`` runs the contract + handler for one
4
+ request and returns a status-tagged envelope; ``ready`` is a side-effect-free load probe.
5
+
6
+ No ``concurrent.*`` here — plain per-interpreter Python. ``init``/``invoke``/``ready`` are referenced
7
+ by the executor across the interpreter boundary, so they live in this small importable module (the
8
+ host puts the package dir on ``PYTHONPATH`` so the worker can import ``funcd_shim``)."""
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from .runtime import Validators
17
+ from .types import CloudEvent, Handler
18
+
19
+ if TYPE_CHECKING:
20
+ from .blob import BlobClient
21
+ from .kv import KVClient
22
+
23
+ # Per-interpreter state, set by init() and read by invoke() — isolated to this worker interpreter.
24
+ _handler: Handler | None = None
25
+ _validators: Validators = Validators()
26
+ _channel: Any = None # the shared telemetry channel (ADR-0101), opened once in init()
27
+
28
+
29
+ def init(src: str, artifact: str, handler: str, contract_path: str | None = None) -> None:
30
+ """Load the handler + I/O validators into this interpreter (the materialization shape-gate,
31
+ ADR-0058/0123). Runs once per worker; a failure breaks the pool → exit 3.
32
+
33
+ ADR-0123: when *contract_path* is given, compile the validators from the delivered schema
34
+ (``fastjsonschema.compile``) **before** the untrusted handler module is imported — the bounded
35
+ eval-free reversal + the m3 reorder. A set-but-broken path fails the worker closed. When absent,
36
+ fall back to the module-baked ``__funcd_validate_*`` (transition back-compat)."""
37
+ global _handler, _validators, _channel
38
+ if src not in sys.path:
39
+ sys.path.insert(0, src)
40
+ from funcd_shim import contract, runtime
41
+ from funcd_shim.funclog import install_log_capture, open_channel
42
+
43
+ # Path B capture (ADR-0081) + traces (ADR-0101): each pool worker runs in its own subinterpreter
44
+ # with its own root logger, so open the channel + install capture here (per-interpreter), before
45
+ # the handler loads. One shared channel per worker. No-op unless FUNCD_LOG_FD/SOCK is set.
46
+ _channel = open_channel()
47
+ install_log_capture(_channel)
48
+
49
+ # ADR-0123: compile the delivered contract AHEAD of the handler import (m3 reorder).
50
+ delivered = contract.load_from_path(contract_path) if contract_path else None
51
+ module = runtime.load_module(artifact)
52
+ _handler = runtime.resolve_handler(module, handler)
53
+ _validators = delivered if delivered is not None else runtime.resolve_validators(module)
54
+
55
+
56
+ def ready() -> bool:
57
+ """A load probe: True once init() succeeded (no handler call). The host submits this at startup
58
+ so a bad member surfaces as a broken pool before serving."""
59
+ return _handler is not None
60
+
61
+
62
+ class _Ctx:
63
+ def log(self, *args: object) -> None:
64
+ print(*args, flush=True)
65
+
66
+ def invoke(self, alias: str, payload: Any) -> Any:
67
+ from .invoke import invoke as _invoke
68
+
69
+ return _invoke(alias, payload)
70
+
71
+ @property
72
+ def kv(self) -> KVClient:
73
+ from .kv import KVClient
74
+
75
+ return KVClient()
76
+
77
+ @property
78
+ def blob(self) -> BlobClient:
79
+ from .blob import BlobClient
80
+
81
+ return BlobClient()
82
+
83
+
84
+ def invoke(
85
+ body: str,
86
+ traceparent: str | None = None,
87
+ fn_name: str = "invoke",
88
+ span_id: str | None = None,
89
+ links: list[str] | None = None,
90
+ ) -> dict[str, Any]:
91
+ """Run one request: parse → optional input validation → handler → optional output validation →
92
+ a status-tagged envelope the host maps to the HTTP response (identical to the solo shim). ADR-0101:
93
+ a successful-past-input-validation request emits a SERVER span on the worker's channel."""
94
+ if _handler is None: # defensive — init() always runs first
95
+ return {"status": 500, "body": {"error": "handler not loaded"}}
96
+ try:
97
+ event: CloudEvent[Any] = json.loads(body) if body else CloudEvent()
98
+ except (json.JSONDecodeError, ValueError):
99
+ return {"status": 400, "body": {"error": "request body is not valid JSON"}}
100
+ if not isinstance(event, dict):
101
+ # A valid-JSON but non-object body (null / array / scalar) is not a CloudEvent envelope.
102
+ # Reject it cleanly — never let `event.get("data")` raise AttributeError and crash the pooled
103
+ # worker (that surfaced as a gateway `proxy error: EOF` / empty-body 502).
104
+ return {
105
+ "status": 400,
106
+ "body": {"error": "request body must be a JSON object (CloudEvent envelope)"},
107
+ }
108
+ if _validators.input is not None:
109
+ errors = _validators.input(event.get("data"))
110
+ if errors:
111
+ # ADR-0101: input-mismatch short-circuits before the handler → no invocation, no span.
112
+ return {
113
+ "status": 422,
114
+ "body": {"error": "event data does not match the input contract", "details": errors},
115
+ }
116
+ from .tracespan import InvocationSpan
117
+
118
+ with InvocationSpan(_channel, fn_name, traceparent, span_id, links) as span:
119
+ try:
120
+ result = _handler(_Ctx(), event)
121
+ except Exception as err: # noqa: BLE001 - user handler errors become 500
122
+ span.fail(str(err))
123
+ return {"status": 500, "body": {"error": str(err)}}
124
+ if _validators.output is not None:
125
+ errors = _validators.output(result)
126
+ if errors:
127
+ span.fail("handler result does not match the output contract")
128
+ return {
129
+ "status": 500,
130
+ "body": {"error": "handler result does not match the output contract", "details": errors},
131
+ }
132
+ if result is None:
133
+ return {"status": 204}
134
+ return {"status": 200, "body": result}
@@ -0,0 +1,117 @@
1
+ """Function-facing blob over the worker-node local API (HTTP-over-UDS, ADR-0127).
2
+
3
+ Dials the same per-sandbox socket as ``context.kv``/``context.invoke`` (``FUNCD_INVOKE_SOCKET``); the
4
+ platform routes ``/blob/…`` to the binding-gated, PDP-authorized Facade with the sandbox's function
5
+ identity (bind-as-grant on ``spec.blob``). Stdlib-only — no boto3, no keypair. The blob twin of
6
+ ``context.kv``; v1 is bytes-in-memory (streaming is a v2 follow-up).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import http.client
12
+ import json
13
+ import os
14
+ import socket
15
+ from urllib.parse import quote
16
+
17
+
18
+ class _UnixHTTPConnection(http.client.HTTPConnection):
19
+ """An HTTPConnection that dials a Unix domain socket instead of TCP."""
20
+
21
+ def __init__(self, socket_path: str) -> None:
22
+ super().__init__("localhost")
23
+ self._socket_path = socket_path
24
+
25
+ def connect(self) -> None:
26
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
27
+ sock.connect(self._socket_path)
28
+ self.sock = sock
29
+
30
+
31
+ def _conn() -> _UnixHTTPConnection:
32
+ socket_path = os.environ.get("FUNCD_INVOKE_SOCKET")
33
+ if not socket_path:
34
+ raise RuntimeError(
35
+ "context.blob: worker-node local API socket unavailable (FUNCD_INVOKE_SOCKET unset)"
36
+ )
37
+ return _UnixHTTPConnection(socket_path)
38
+
39
+
40
+ def _key_path(binding: str, key: str) -> str:
41
+ # key may be hierarchical ("a/b"); keep the "/" separators (the server's {key...} captures them).
42
+ segs = "/".join(quote(s, safe="") for s in key.split("/"))
43
+ return f"/blob/{quote(binding, safe='')}/{segs}"
44
+
45
+
46
+ class BlobClient:
47
+ """A function's binding-scoped blob storage (ADR-0127): get/put/delete/list a bound prefix's objects,
48
+ or mint a presigned URL — the blob twin of :class:`KVClient`."""
49
+
50
+ def get(self, binding: str, key: str) -> bytes | None:
51
+ conn = _conn()
52
+ try:
53
+ conn.request("GET", _key_path(binding, key))
54
+ resp = conn.getresponse()
55
+ data = resp.read()
56
+ if resp.status == 404:
57
+ return None
58
+ if not 200 <= resp.status < 300:
59
+ text = data.decode("utf-8", "replace")
60
+ raise RuntimeError(f"context.blob.get failed: {resp.status} {text}")
61
+ return data
62
+ finally:
63
+ conn.close()
64
+
65
+ def put(self, binding: str, key: str, data: bytes) -> None:
66
+ conn = _conn()
67
+ try:
68
+ conn.request("PUT", _key_path(binding, key), body=data)
69
+ resp = conn.getresponse()
70
+ text = resp.read().decode("utf-8", "replace")
71
+ if not 200 <= resp.status < 300:
72
+ raise RuntimeError(f"context.blob.put failed: {resp.status} {text}")
73
+ finally:
74
+ conn.close()
75
+
76
+ def delete(self, binding: str, key: str) -> None:
77
+ conn = _conn()
78
+ try:
79
+ conn.request("DELETE", _key_path(binding, key))
80
+ resp = conn.getresponse()
81
+ text = resp.read().decode("utf-8", "replace")
82
+ if not 200 <= resp.status < 300:
83
+ raise RuntimeError(f"context.blob.delete failed: {resp.status} {text}")
84
+ finally:
85
+ conn.close()
86
+
87
+ def list(self, binding: str, prefix: str = "") -> list[str]:
88
+ path = f"/blob/{quote(binding, safe='')}"
89
+ if prefix:
90
+ path += f"?prefix={quote(prefix, safe='')}"
91
+ conn = _conn()
92
+ try:
93
+ conn.request("GET", path)
94
+ resp = conn.getresponse()
95
+ text = resp.read().decode("utf-8")
96
+ if not 200 <= resp.status < 300:
97
+ raise RuntimeError(f"context.blob.list failed: {resp.status} {text}")
98
+ return json.loads(text) if text else []
99
+ finally:
100
+ conn.close()
101
+
102
+ def signed_url(self, binding: str, key: str, method: str = "GET", expiry: float | None = None) -> str:
103
+ """Return a presigned external URL for the object. ``method`` is GET (read) / PUT / DELETE (write);
104
+ ``expiry`` is in seconds (the driver's default when ``None``). A PUT/DELETE URL requires s3::write."""
105
+ path = f"{_key_path(binding, key)}?sign=1&method={quote(method, safe='')}"
106
+ if expiry is not None:
107
+ path += f"&expiry={expiry}s"
108
+ conn = _conn()
109
+ try:
110
+ conn.request("GET", path)
111
+ resp = conn.getresponse()
112
+ text = resp.read().decode("utf-8", "replace")
113
+ if not 200 <= resp.status < 300:
114
+ raise RuntimeError(f"context.blob.signed_url failed: {resp.status} {text}")
115
+ return text
116
+ finally:
117
+ conn.close()