apagea 2026.10.1rc2__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 (41) hide show
  1. apagea-2026.10.1rc2/ActionBase.Dockerfile +34 -0
  2. apagea-2026.10.1rc2/Dockerfile +30 -0
  3. apagea-2026.10.1rc2/PKG-INFO +62 -0
  4. apagea-2026.10.1rc2/README.md +51 -0
  5. apagea-2026.10.1rc2/apagea/__init__.py +15 -0
  6. apagea-2026.10.1rc2/apagea/actions/__init__.py +4 -0
  7. apagea-2026.10.1rc2/apagea/actions/__main__.py +19 -0
  8. apagea-2026.10.1rc2/apagea/actions/artifacts.py +65 -0
  9. apagea-2026.10.1rc2/apagea/actions/build.py +45 -0
  10. apagea-2026.10.1rc2/apagea/actions/build_dependencies.py +35 -0
  11. apagea-2026.10.1rc2/apagea/actions/contract.py +278 -0
  12. apagea-2026.10.1rc2/apagea/actions/development.py +41 -0
  13. apagea-2026.10.1rc2/apagea/actions/diagnostics.py +100 -0
  14. apagea-2026.10.1rc2/apagea/actions/package_export.py +116 -0
  15. apagea-2026.10.1rc2/apagea/actions/runtime.py +166 -0
  16. apagea-2026.10.1rc2/apagea/actions/sealing.py +20 -0
  17. apagea-2026.10.1rc2/apagea/actions/timing.py +13 -0
  18. apagea-2026.10.1rc2/apagea/blocks/__init__.py +28 -0
  19. apagea-2026.10.1rc2/apagea/bootstrap.py +120 -0
  20. apagea-2026.10.1rc2/apagea/contracts/__init__.py +19 -0
  21. apagea-2026.10.1rc2/apagea/contracts/schemas.json +597 -0
  22. apagea-2026.10.1rc2/apagea/fields/__init__.py +73 -0
  23. apagea-2026.10.1rc2/apagea.egg-info/PKG-INFO +62 -0
  24. apagea-2026.10.1rc2/apagea.egg-info/SOURCES.txt +39 -0
  25. apagea-2026.10.1rc2/apagea.egg-info/dependency_links.txt +1 -0
  26. apagea-2026.10.1rc2/apagea.egg-info/requires.txt +4 -0
  27. apagea-2026.10.1rc2/apagea.egg-info/top_level.txt +1 -0
  28. apagea-2026.10.1rc2/embed.go +18 -0
  29. apagea-2026.10.1rc2/pyproject.toml +28 -0
  30. apagea-2026.10.1rc2/setup.cfg +4 -0
  31. apagea-2026.10.1rc2/tests/test_apagea.py +117 -0
  32. apagea-2026.10.1rc2/tests/test_blocks.py +46 -0
  33. apagea-2026.10.1rc2/tests/test_build_dependencies.py +35 -0
  34. apagea-2026.10.1rc2/tests/test_contract.py +172 -0
  35. apagea-2026.10.1rc2/tests/test_delivery.py +86 -0
  36. apagea-2026.10.1rc2/tests/test_diagnostics.py +161 -0
  37. apagea-2026.10.1rc2/tests/test_operations_examples.py +79 -0
  38. apagea-2026.10.1rc2/tests/test_package_export.py +91 -0
  39. apagea-2026.10.1rc2/tests/test_runtime_startup.py +63 -0
  40. apagea-2026.10.1rc2/tests/test_shared_contracts.py +66 -0
  41. apagea-2026.10.1rc2/uv.lock +451 -0
@@ -0,0 +1,34 @@
1
+ # Shared operating-system and SDK layers, built once per platform release.
2
+ ARG BASE=ubuntu:26.04
3
+ FROM ghcr.io/astral-sh/uv:0.12.18 AS uv
4
+ FROM ${BASE} AS system
5
+ RUN DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3 ca-certificates libstdc++6 && rm -rf /var/lib/apt/lists/*
6
+ RUN python3 -c 'import sys; assert sys.version_info[:2] == (3,14)'
7
+ ENV PATH=/opt/venv/bin:${PATH} PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1
8
+
9
+ FROM system AS builder
10
+ RUN DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-venv python3-dev build-essential && rm -rf /var/lib/apt/lists/*
11
+ RUN python3 -m venv /opt/venv
12
+ COPY base-requirements.txt /tmp/base-requirements.txt
13
+ RUN --mount=type=cache,target=/root/.cache/pip pip install -r /tmp/base-requirements.txt
14
+ COPY pyproject.toml README.md /sdk/
15
+ COPY apagea/ /sdk/apagea/
16
+ ARG VERSION
17
+ RUN test -n "$VERSION" && SETUPTOOLS_SCM_PRETEND_VERSION_FOR_APAGEA="$VERSION" pip install --no-deps --no-build-isolation /sdk && pip check
18
+ RUN python -c 'import os; from importlib.metadata import version; assert version("apagea") == os.environ["VERSION"]' && mkdir -p /opt/apagea && python -c 'from importlib.metadata import distributions; print("\n".join(sorted(d.metadata["Name"] + "==" + d.version for d in distributions())))' > /opt/apagea/constraints.txt
19
+ # Utilities change independently of the compiler, dependencies and SDK.
20
+ COPY --from=uv /uv /usr/local/bin/uv
21
+ ARG COMMIT=unknown
22
+ ARG REPOSITORY=apageadev/apagea
23
+ LABEL org.opencontainers.image.source="https://github.com/${REPOSITORY}" org.opencontainers.image.version="${VERSION}" org.opencontainers.image.revision="${COMMIT}" dev.apagea.base.kind="builder" dev.apagea.sdk.version="${VERSION}" dev.apagea.python.version="3.14"
24
+
25
+ FROM system AS runtime
26
+ COPY --from=builder /opt/venv /opt/venv
27
+ RUN pip uninstall -y pip setuptools wheel
28
+ ARG VERSION
29
+ ARG COMMIT=unknown
30
+ ARG REPOSITORY=apageadev/apagea
31
+ LABEL org.opencontainers.image.source="https://github.com/${REPOSITORY}" org.opencontainers.image.version="${VERSION}" org.opencontainers.image.revision="${COMMIT}" dev.apagea.base.kind="runtime" dev.apagea.sdk.version="${VERSION}" dev.apagea.python.version="3.14"
32
+ USER 65532:65532
33
+ WORKDIR /tmp
34
+ ENTRYPOINT ["python", "-m", "apagea.actions"]
@@ -0,0 +1,30 @@
1
+ # Both bases are resolved and validated by the CLI before this build starts.
2
+ ARG BUILDER=scratch
3
+ ARG RUNTIME=scratch
4
+ FROM ${BUILDER} AS build
5
+ ARG RUNTIME=scratch
6
+ ARG ACTION_PLATFORM
7
+ ARG RUNTIME_IDENTITY
8
+ ENV ACTION_BASE=${RUNTIME_IDENTITY} ACTION_PLATFORM=${ACTION_PLATFORM}
9
+ COPY metadata/ /metadata/
10
+ # BEGIN dependencies
11
+ RUN --mount=type=cache,target=/root/.cache/pip \
12
+ python -m apagea.actions.build_dependencies /metadata/pyproject.toml --build-only > /metadata/requirements.txt && \
13
+ if [ -f /metadata/uv.lock ]; then \
14
+ cd /metadata && UV_PYTHON_DOWNLOADS=never uv export --locked --no-dev --no-emit-project --no-emit-package apagea --no-editable --format requirements-txt --output-file /metadata/locked.txt >/dev/null && \
15
+ pip install --constraint /opt/apagea/constraints.txt -r /metadata/locked.txt && \
16
+ python -m apagea.actions.build_dependencies --constraints > /metadata/constraints.txt && \
17
+ pip install --constraint /opt/apagea/constraints.txt --constraint /metadata/constraints.txt -r /metadata/requirements.txt; \
18
+ else pip install --constraint /opt/apagea/constraints.txt -r /metadata/requirements.txt; fi
19
+ # END dependencies
20
+ COPY source/ /source/
21
+ ARG DEVELOPMENT=false
22
+ ARG DIAGNOSTICS=detailed
23
+ RUN --mount=type=cache,target=/root/.cache/pip pip install --constraint /opt/apagea/constraints.txt $(if [ -f /metadata/locked.txt ]; then printf "%s" "--constraint /metadata/constraints.txt"; fi) --no-build-isolation /source && if [ "$DEVELOPMENT" = "true" ]; then python -m apagea.actions.development --build /source --diagnostics "$DIAGNOSTICS"; else python -m apagea.actions build /source; fi && pip check
24
+ RUN pip uninstall -y pip setuptools wheel
25
+ FROM ${RUNTIME}
26
+ COPY --from=build /opt/venv /opt/venv
27
+ COPY --from=build /opt/apagea/action /opt/apagea/action
28
+ USER 65532:65532
29
+ WORKDIR /tmp
30
+ ENTRYPOINT ["python", "-m", "apagea.actions"]
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: apagea
3
+ Version: 2026.10.1rc2
4
+ Summary: Official APAGEA Python SDK and typed Action runtime
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: jsonschema<5,>=4.25
8
+ Requires-Dist: pydantic==2.12.5
9
+ Requires-Dist: packaging==25.0
10
+ Requires-Dist: cryptography<47,>=46
11
+
12
+ # Apagea Python SDK
13
+
14
+ The `apagea` distribution provides the typed Action runtime, extracted and adapted from Pyplines. Initial development version: `0.1.0.dev0` (unpublished).
15
+
16
+ ```python
17
+ from pydantic import SecretStr
18
+ from apagea.actions import Context, Inputs, Outputs
19
+
20
+ class Request(Inputs):
21
+ message: str
22
+
23
+ class Settings(Context):
24
+ api_token: SecretStr
25
+
26
+ class Result(Outputs):
27
+ message: str
28
+
29
+ def handler(inputs: Request, context: Settings) -> Result:
30
+ context.log.info("Processing request")
31
+ return Result(message=inputs.message)
32
+ ```
33
+
34
+ Declare the handler in `[project.entry-points."apagea.action"]`. The Go CLI discovers/builds the interface in Docker through this SDK. Context model fields describe deployment-scoped Context requirements; invocation identity, scratch space, logging, and artifact staging are runtime facilities excluded from that schema.
35
+
36
+ Logger methods are `debug`, `info`, `warn`, `error`, and `exception`, with no aliases. Records are bounded and thread-safe. Wrapped secret values are recursively masked, and known secret strings are redacted on a best-effort basis. Unwrapping/transformation is not tracked as taint. Do not write secrets to logs. Sensitive Context disables detailed local exception diagnostics; secret wrappers in public results retain Pydantic's masking. Remote protected outputs are sealed to the server, encrypted at rest, and exposed through separately authorized evidence endpoints.
37
+
38
+ `apagea.fields` supplies Pydantic-compatible `TextInput`, `NumberInput`, `Text`, `Table`, `LineChart`, and `Download` helpers. Capture validates component field references and emits versioned metadata plus explicit field order. External clients can render these contracts; `apagea.blocks` is the preferred output-authoring surface, with `Metric`, `MetricValue`, `Status`, `TimeSeries`, `Table`, `Text`, and `Download`. Existing output helpers remain compatible. The server returns ordinary fields as JSON without prescribing rendering.
39
+
40
+ `context.artifacts.add` stages bounded files from scratch space. Local limits are 16 files, 16 MiB per file, and 64 MiB total. Use the CLI's `--artifacts` to retain them after execution. Remote artifacts are retained by the server and downloaded through authenticated endpoints.
41
+
42
+ A handler explicitly annotated `-> None` may complete without returning domain data. Its wire output is an empty object. A non-None return fails validation. A numeric field can use `Metric(format="compact")`; a `MetricValue` field can additionally return `value`, `previous`, and `comparison` for a comparative metric. Logger methods remain unchanged and have no aliases.
43
+
44
+ ```sh
45
+ uv run --project apagea-sdk python -m unittest discover -s apagea-sdk/tests
46
+ ```
47
+
48
+ See [authoring contract](../design/AUTHORING.md) and [working example](../examples/README.md).
49
+
50
+ ## Helpful failures
51
+
52
+ Use `ActionError(message, code="action_failed", *, hint="")` for text intended for the person running the tool. Keep credentials and sensitive data out of all three public fields. Known secrets are additionally redacted by the SDK and server.
53
+
54
+ ```python
55
+ raise ActionError(
56
+ "GitHub rejected the configured credential.",
57
+ "github_authentication_failed",
58
+ hint="Ask a Namespace administrator to update the credential.",
59
+ ) from error
60
+ ```
61
+
62
+ Deployed failures automatically capture a protected exception chain and stack locations, including when Context contains secrets. These are sealed to the server and accessible only through separately authorized, audited diagnostic reads. Unexpected exception messages stay in that protected record. Local diagnostic behavior is unchanged. See [invocation diagnostics](../design/DIAGNOSTICS.md) for collection limits and security boundaries.
@@ -0,0 +1,51 @@
1
+ # Apagea Python SDK
2
+
3
+ The `apagea` distribution provides the typed Action runtime, extracted and adapted from Pyplines. Initial development version: `0.1.0.dev0` (unpublished).
4
+
5
+ ```python
6
+ from pydantic import SecretStr
7
+ from apagea.actions import Context, Inputs, Outputs
8
+
9
+ class Request(Inputs):
10
+ message: str
11
+
12
+ class Settings(Context):
13
+ api_token: SecretStr
14
+
15
+ class Result(Outputs):
16
+ message: str
17
+
18
+ def handler(inputs: Request, context: Settings) -> Result:
19
+ context.log.info("Processing request")
20
+ return Result(message=inputs.message)
21
+ ```
22
+
23
+ Declare the handler in `[project.entry-points."apagea.action"]`. The Go CLI discovers/builds the interface in Docker through this SDK. Context model fields describe deployment-scoped Context requirements; invocation identity, scratch space, logging, and artifact staging are runtime facilities excluded from that schema.
24
+
25
+ Logger methods are `debug`, `info`, `warn`, `error`, and `exception`, with no aliases. Records are bounded and thread-safe. Wrapped secret values are recursively masked, and known secret strings are redacted on a best-effort basis. Unwrapping/transformation is not tracked as taint. Do not write secrets to logs. Sensitive Context disables detailed local exception diagnostics; secret wrappers in public results retain Pydantic's masking. Remote protected outputs are sealed to the server, encrypted at rest, and exposed through separately authorized evidence endpoints.
26
+
27
+ `apagea.fields` supplies Pydantic-compatible `TextInput`, `NumberInput`, `Text`, `Table`, `LineChart`, and `Download` helpers. Capture validates component field references and emits versioned metadata plus explicit field order. External clients can render these contracts; `apagea.blocks` is the preferred output-authoring surface, with `Metric`, `MetricValue`, `Status`, `TimeSeries`, `Table`, `Text`, and `Download`. Existing output helpers remain compatible. The server returns ordinary fields as JSON without prescribing rendering.
28
+
29
+ `context.artifacts.add` stages bounded files from scratch space. Local limits are 16 files, 16 MiB per file, and 64 MiB total. Use the CLI's `--artifacts` to retain them after execution. Remote artifacts are retained by the server and downloaded through authenticated endpoints.
30
+
31
+ A handler explicitly annotated `-> None` may complete without returning domain data. Its wire output is an empty object. A non-None return fails validation. A numeric field can use `Metric(format="compact")`; a `MetricValue` field can additionally return `value`, `previous`, and `comparison` for a comparative metric. Logger methods remain unchanged and have no aliases.
32
+
33
+ ```sh
34
+ uv run --project apagea-sdk python -m unittest discover -s apagea-sdk/tests
35
+ ```
36
+
37
+ See [authoring contract](../design/AUTHORING.md) and [working example](../examples/README.md).
38
+
39
+ ## Helpful failures
40
+
41
+ Use `ActionError(message, code="action_failed", *, hint="")` for text intended for the person running the tool. Keep credentials and sensitive data out of all three public fields. Known secrets are additionally redacted by the SDK and server.
42
+
43
+ ```python
44
+ raise ActionError(
45
+ "GitHub rejected the configured credential.",
46
+ "github_authentication_failed",
47
+ hint="Ask a Namespace administrator to update the credential.",
48
+ ) from error
49
+ ```
50
+
51
+ Deployed failures automatically capture a protected exception chain and stack locations, including when Context contains secrets. These are sealed to the server and accessible only through separately authorized, audited diagnostic reads. Unexpected exception messages stay in that protected record. Local diagnostic behavior is unchanged. See [invocation diagnostics](../design/DIAGNOSTICS.md) for collection limits and security boundaries.
@@ -0,0 +1,15 @@
1
+ """Official APAGEA Python interface. Action authoring is available in apagea.actions."""
2
+ def __getattr__(name):
3
+ # Metadata discovery is for tooling; bootstrap must not scan distributions
4
+ # before receiving its authorized invocation envelope.
5
+ if name == "__version__":
6
+ from importlib.metadata import version
7
+ value = version("apagea")
8
+ globals()[name] = value
9
+ return value
10
+ raise AttributeError(name)
11
+
12
+ try:
13
+ from ._release import COMMIT as __commit__
14
+ except ImportError:
15
+ __commit__ = "unknown"
@@ -0,0 +1,4 @@
1
+ from .contract import Inputs, Outputs, Context, ActionError
2
+ from .artifacts import Artifact
3
+
4
+ __all__ = ["Inputs", "Outputs", "Context", "ActionError", "Artifact"]
@@ -0,0 +1,19 @@
1
+ """Command entry point for Action build and local execution."""
2
+ import sys
3
+ from .runtime import VERSION, LogWriter, run, load_handler
4
+
5
+
6
+ def build(source, manifest_path=None):
7
+ from .build import build as discover
8
+ return discover(source, manifest_path)
9
+
10
+
11
+ if __name__ == "__main__":
12
+ if len(sys.argv) == 3 and sys.argv[1] == "build":
13
+ try:
14
+ build(sys.argv[2])
15
+ except BaseException:
16
+ print("Action interface validation failed; check metadata, dependencies and handler annotations", file=sys.stderr)
17
+ raise SystemExit(1)
18
+ else:
19
+ raise SystemExit(run())
@@ -0,0 +1,65 @@
1
+ """Bounded artifact staging; remote upload belongs to the runner."""
2
+ import hashlib
3
+ import os
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+ class Artifact(BaseModel):
9
+ model_config = ConfigDict(extra="forbid", frozen=True)
10
+ id: str
11
+ filename: str
12
+ media_type: str
13
+ size: int
14
+ sha256: str
15
+
16
+ class ArtifactStore:
17
+ def __init__(self, scratch, destination):
18
+ self.scratch = Path(scratch).resolve()
19
+ self.destination = Path(destination)
20
+ self.count = 0
21
+ self.total = 0
22
+ self._result_key = None
23
+ self._result_key_id = None
24
+ self._invocation_id = None
25
+
26
+ def add(self, *, path, filename, media_type):
27
+ source = Path(path)
28
+ if source.is_symlink() or not source.resolve().is_relative_to(self.scratch):
29
+ raise ValueError("Artifact must be a regular file within scratch space")
30
+ if not filename or Path(filename).name != filename or any(c in filename for c in "\\\r\n"):
31
+ raise ValueError("Invalid artifact filename")
32
+ if not media_type or any(c in media_type for c in "\r\n"):
33
+ raise ValueError("Invalid media type")
34
+ if self.count >= 16:
35
+ raise ValueError("Artifact count exceeds 16")
36
+ import stat
37
+ fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
38
+ with os.fdopen(fd, "rb") as stream:
39
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
40
+ raise ValueError("Artifact must be a regular file")
41
+ data = stream.read((16 << 20) + 1)
42
+ if len(data) > 16 << 20 or self.total + len(data) > 64 << 20:
43
+ raise ValueError("Artifact size exceeds local limit")
44
+ self.destination.mkdir(parents=True, exist_ok=True)
45
+ identifier = str(uuid4())
46
+ target = self.destination / identifier
47
+ staged = data
48
+ if self._result_key:
49
+ import base64
50
+ import json
51
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
52
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
53
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
54
+ ephemeral = X25519PrivateKey.generate()
55
+ shared = ephemeral.exchange(X25519PublicKey.from_public_bytes(base64.b64decode(self._result_key)))
56
+ nonce = os.urandom(12)
57
+ ciphertext = AESGCM(hashlib.sha256(shared).digest()).encrypt(nonce, data, (self._invocation_id + '/' + identifier).encode())
58
+ staged = json.dumps({'key': base64.b64encode(ephemeral.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode(),
59
+ 'key_id': self._result_key_id, 'nonce': base64.b64encode(nonce).decode(), 'sealed': base64.b64encode(ciphertext).decode()}).encode()
60
+ with target.open("xb") as stream:
61
+ stream.write(staged)
62
+ self.count += 1
63
+ self.total += len(data)
64
+ return Artifact(id=identifier, filename=filename, media_type=media_type,
65
+ size=len(data), sha256=hashlib.sha256(data).hexdigest())
@@ -0,0 +1,45 @@
1
+ """Build-time Action interface discovery; never imported by deployed execution."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ from apagea.contracts import validate as validate_contract
8
+ from .contract import validate_handler, context_type
9
+ from .runtime import VERSION, MANIFEST, load_handler
10
+
11
+
12
+ def build(source, manifest_path=None):
13
+ import importlib.metadata
14
+ import tomllib
15
+ from packaging.specifiers import SpecifierSet
16
+ manifest_path = manifest_path or MANIFEST
17
+ config = tomllib.loads((Path(source) / "pyproject.toml").read_text())
18
+ project = config["project"]
19
+ if sys.version_info[:2] != (3, 14) or not SpecifierSet(project["requires-python"]).contains(".".join(map(str, sys.version_info[:3]))):
20
+ raise ValueError("Action requires-python must permit the supplied Python 3.14 interpreter")
21
+ entry = project["entry-points"]["apagea.action"]
22
+ if list(entry) != ["handler"]:
23
+ raise ValueError("Exactly one apagea.action handler entry point is required")
24
+ handler = load_handler(entry["handler"])
25
+ input_type, output_type = validate_handler(handler)
26
+ metadata = config.get("tool", {}).get("apagea", {})
27
+ from apagea.fields import schema_for
28
+ manifest = {"version": VERSION, "name": project["name"], "action_version": project["version"],
29
+ "description": project.get("description", ""), "handler": entry["handler"],
30
+ "platform": os.environ["ACTION_PLATFORM"], "python": sys.version.split()[0],
31
+ "sdk_version": importlib.metadata.version("apagea"),
32
+ "base_image": os.environ["ACTION_BASE"],
33
+ "network_required": metadata.get("network_required", False),
34
+ "title": metadata.get("title", project["name"].replace("-", " ").title()),
35
+ "submit_label": metadata.get("submit-label", "Invoke"),
36
+ "context_schema": schema_for(context_type(handler)),
37
+ "timeout_seconds": metadata.get("timeout_seconds", 60),
38
+ "input_schema": schema_for(input_type), "output_schema": schema_for(output_type, mode="serialization")}
39
+ validate_contract("action/v1/manifest", manifest)
40
+ raw = json.dumps(manifest, sort_keys=True, separators=(",", ":"), allow_nan=False)
41
+ if len(raw.encode()) > 128 << 10:
42
+ raise ValueError("Action manifest exceeds 128 KiB")
43
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
44
+ manifest_path.write_text(raw)
45
+
@@ -0,0 +1,35 @@
1
+ """Render validated requirements; the Dockerfile owns package installation."""
2
+ from pathlib import Path
3
+ import sys
4
+ import tomllib
5
+ from packaging.requirements import InvalidRequirement, Requirement
6
+
7
+
8
+ def requirements(path, *, build_only=False):
9
+ metadata = tomllib.loads(Path(path).read_text())
10
+ requirements = [*metadata.get('build-system', {}).get('requires', []),
11
+ *([] if build_only else metadata['project'].get('dependencies', []))]
12
+ # Parse PEP 508 requirements before writing the pip requirements file. Dynamic project
13
+ # dependencies are resolved later when the complete source is installed.
14
+ for value in requirements:
15
+ if "\n" in value or "\r" in value:
16
+ raise InvalidRequirement("Requirements must occupy a single line")
17
+ Requirement(value)
18
+ return "\n".join(requirements) + ("\n" if requirements else "")
19
+
20
+
21
+ def installed_constraints():
22
+ """Freeze the installed target environment after the hashed lock install.
23
+
24
+ Build-system installation must not upgrade either runtime pins or the selected
25
+ locked dependencies. No second lock export or custom lock parser is needed.
26
+ """
27
+ from importlib.metadata import distributions
28
+ return "\n".join(sorted(f"{d.metadata['Name']}=={d.version}" for d in distributions())) + "\n"
29
+
30
+
31
+ if __name__ == '__main__':
32
+ if sys.argv[1] == '--constraints':
33
+ sys.stdout.write(installed_constraints())
34
+ else:
35
+ sys.stdout.write(requirements(sys.argv[1], build_only='--build-only' in sys.argv[2:]))
@@ -0,0 +1,278 @@
1
+ """Typed handler validation adapted from the Pyplines Action SDK.
2
+
3
+ Typed Context requirements describe dependencies; scheduling and approval remain server responsibilities.
4
+ """
5
+ import inspect
6
+ import json
7
+ import sys
8
+ import threading
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import get_type_hints
12
+
13
+ from .timing import measure
14
+
15
+ from pydantic import BaseModel, ConfigDict, PrivateAttr, SecretStr
16
+
17
+
18
+ class Inputs(BaseModel):
19
+ model_config = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True)
20
+
21
+
22
+ class Outputs(BaseModel):
23
+ model_config = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True)
24
+
25
+
26
+ class ActionError(Exception):
27
+ """An intentionally public failure; authors MUST omit credentials and secrets."""
28
+ def __init__(self, message: str, code: str = "action_failed", *, hint: str = ""):
29
+ self.message = message[:1024]
30
+ self.code = code[:64]
31
+ self.hint = hint[:1024]
32
+ super().__init__(self.message)
33
+
34
+
35
+ def _log_json(value):
36
+ # Match transport escaping so SDK and server enforce the same byte limits.
37
+ encoded = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
38
+ for character in ("<", ">", "&", "\u2028", "\u2029"):
39
+ encoded = encoded.replace(character, "\\u%04x" % ord(character))
40
+ return encoded
41
+
42
+
43
+ class Logger:
44
+ """Bounded structured logging. Public methods have no aliases."""
45
+ def __init__(self, invocation_id, stream, secrets=()):
46
+ self.invocation_id = invocation_id
47
+ self._stream = stream
48
+ self._sequence = 0
49
+ self._lock = threading.Lock()
50
+ self._secrets = tuple(sorted(set(s for s in secrets if s), key=len, reverse=True))
51
+
52
+ def _redact(self, value):
53
+ if isinstance(value, SecretStr):
54
+ return "**********"
55
+ if isinstance(value, dict):
56
+ return {self._redact(k): self._redact(v) for k, v in value.items()}
57
+ if isinstance(value, list):
58
+ return [self._redact(v) for v in value]
59
+ if isinstance(value, str):
60
+ for secret in self._secrets:
61
+ value = value.replace(secret, "**********")
62
+ return value
63
+
64
+ def debug(self, message, **fields):
65
+ self._emit(message, level="debug", **fields)
66
+
67
+ def info(self, message, **fields):
68
+ self._emit(message, level="info", **fields)
69
+
70
+ def warn(self, message, **fields):
71
+ self._emit(message, level="warning", **fields)
72
+
73
+ def error(self, message, **fields):
74
+ self._emit(message, level="error", **fields)
75
+
76
+ def exception(self, message, **fields):
77
+ import traceback
78
+ fields["exception"] = traceback.format_exc()[-4096:]
79
+ self._emit(message, level="error", **fields)
80
+
81
+ def _emit(self, message: str, stream: str = "stdout", *, level: str = "info", **fields) -> None:
82
+ """Emit one bounded JSON record. Wrapped and known secret values are masked."""
83
+ if level not in ("debug", "info", "warning", "error"):
84
+ raise ValueError("Log level must be debug, info, warning, or error")
85
+ if stream not in ("stdout", "stderr"):
86
+ raise ValueError("Log stream must be stdout or stderr")
87
+ if not isinstance(message, str) or len(message) > 4096:
88
+ raise ValueError("Log message must be a string of at most 4096 characters")
89
+ fields = self._redact(fields)
90
+ for secret in self._secrets:
91
+ message = message.replace(secret, "**********")
92
+ def check(value):
93
+ if isinstance(value, dict):
94
+ if any(not isinstance(key, str) for key in value):
95
+ raise ValueError("Log field keys must be strings")
96
+ for item in value.values():
97
+ check(item)
98
+ elif isinstance(value, list):
99
+ for item in value:
100
+ check(item)
101
+ elif value is not None and not isinstance(value, (str, bool, int, float)):
102
+ raise ValueError("Log fields must contain JSON-compatible values")
103
+ try:
104
+ check(fields)
105
+ encoded_fields = _log_json(fields)
106
+ if len(encoded_fields.encode()) > 8192:
107
+ raise ValueError("Log fields exceed 8 KiB")
108
+ except (TypeError, OverflowError, RecursionError):
109
+ raise ValueError("Log fields must contain finite JSON-compatible values") from None
110
+ with self._lock:
111
+ record = {"sequence": self._sequence + 1,
112
+ "invocation_id": self.invocation_id,
113
+ "timestamp": datetime.now(timezone.utc).isoformat(),
114
+ "stream": stream, "level": level, "message": message}
115
+ if fields:
116
+ record["fields"] = fields
117
+ encoded = _log_json(record)
118
+ if len(encoded.encode()) > 16384:
119
+ raise ValueError("Log record exceeds 16 KiB")
120
+ self._stream.write(encoded + "\n")
121
+ self._stream.flush()
122
+ self._sequence += 1
123
+
124
+
125
+
126
+ def secret_values(value):
127
+ if isinstance(value, SecretStr):
128
+ return [value.get_secret_value()]
129
+ if isinstance(value, BaseModel):
130
+ return secret_values(value.__dict__)
131
+ if isinstance(value, dict):
132
+ return [s for v in value.values() for s in secret_values(v)]
133
+ if isinstance(value, (tuple, list)):
134
+ return [s for v in value for s in secret_values(v)]
135
+ return []
136
+
137
+
138
+ class Context(BaseModel):
139
+ """Typed deployment-scoped Context requirements plus runtime facilities (not schema fields)."""
140
+ model_config = ConfigDict(extra="forbid", strict=True, frozen=True, hide_input_in_errors=True)
141
+ _invocation_id: str = PrivateAttr()
142
+ _scratch_dir: Path = PrivateAttr()
143
+ _logger: Logger = PrivateAttr()
144
+ _artifacts: object = PrivateAttr()
145
+
146
+ @classmethod
147
+ def __pydantic_init_subclass__(cls, **kwargs):
148
+ super().__pydantic_init_subclass__(**kwargs)
149
+ reserved = {"invocation_id", "scratch_dir", "log", "artifacts", "stream", "artifact_dir"}
150
+ if reserved.intersection(cls.model_fields):
151
+ raise ValueError("Context fields cannot shadow platform facilities")
152
+
153
+ def __init__(self, invocation_id="local", scratch_dir="/tmp", stream=None, *, artifact_dir=None, **values):
154
+ super().__init__(**values)
155
+ self._invocation_id = invocation_id
156
+ self._scratch_dir = Path(scratch_dir)
157
+ self._logger = Logger(invocation_id, stream if stream is not None else sys.stdout, secret_values(self))
158
+ from .artifacts import ArtifactStore
159
+ self._artifacts = ArtifactStore(self._scratch_dir, Path(artifact_dir) if artifact_dir else self._scratch_dir / "artifacts")
160
+
161
+ @property
162
+ def invocation_id(self):
163
+ return self._invocation_id
164
+
165
+ @property
166
+ def scratch_dir(self):
167
+ return self._scratch_dir
168
+
169
+ @property
170
+ def log(self):
171
+ return self._logger
172
+
173
+ @property
174
+ def artifacts(self):
175
+ return self._artifacts
176
+
177
+
178
+ def context_type(handler):
179
+ params = list(inspect.signature(handler).parameters.values())
180
+ return get_type_hints(handler)[params[1].name]
181
+
182
+ def validate_handler(handler):
183
+ """Require a synchronous (Inputs, Context) -> Outputs function."""
184
+ if not callable(handler) or inspect.iscoroutinefunction(handler) or inspect.isgeneratorfunction(handler):
185
+ raise ValueError("Action handler must be a synchronous function")
186
+ params = list(inspect.signature(handler).parameters.values())
187
+ if len(params) != 2 or any(p.kind not in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) for p in params):
188
+ raise ValueError("Action handler must accept exactly (inputs, context)")
189
+ try:
190
+ hints = get_type_hints(handler)
191
+ except Exception:
192
+ raise ValueError("Unable to resolve Action type annotations") from None
193
+ input_type, output_type = hints.get(params[0].name), hints.get("return")
194
+ if not isinstance(input_type, type) or not issubclass(input_type, Inputs) or input_type is Inputs:
195
+ raise ValueError("First parameter must be an Inputs subclass")
196
+ if not isinstance(hints.get(params[1].name), type) or not issubclass(hints[params[1].name], Context):
197
+ raise ValueError("Second parameter must be a Context subclass")
198
+ if output_type is type(None):
199
+ return input_type, Outputs
200
+ if not isinstance(output_type, type) or not issubclass(output_type, Outputs) or output_type is Outputs:
201
+ raise ValueError("Return annotation must be an Outputs subclass")
202
+ return input_type, output_type
203
+
204
+
205
+ def execute(handler, payload, context, *, private=False, timings=None):
206
+ with measure(timings, "handler_validation_ms"):
207
+ input_type, output_type = validate_handler(handler)
208
+ try:
209
+ with measure(timings, "input_validation_ms"):
210
+ value = input_type.model_validate_json(json.dumps(payload, allow_nan=False), strict=True)
211
+ except Exception as exc:
212
+ raise ActionError("Inputs does not satisfy the Action schema", "invalid_input") from exc
213
+ context.log._secrets = tuple(sorted(set(context.log._secrets + tuple(secret_values(value))), key=len, reverse=True))
214
+ with measure(timings, "handler_ms"):
215
+ result = handler(value, context)
216
+ if output_type is Outputs:
217
+ if result is not None:
218
+ raise ActionError("Handler annotated None returned a value", "invalid_output")
219
+ return {}
220
+ if not isinstance(result, output_type):
221
+ raise ActionError("Handler returned an invalid output type", "invalid_output")
222
+ try:
223
+ with measure(timings, "output_validation_ms"):
224
+ # Revalidate constructed/mutated model instances as well as normal returns.
225
+ validated = output_type.model_validate(_model_values(result), strict=True,
226
+ by_name=True, by_alias=False)
227
+ output = validated.model_dump(mode="json", by_alias=True, warnings="error")
228
+ if private:
229
+ output = _private_values(validated, output)
230
+ json.dumps(output, allow_nan=False)
231
+ return output
232
+ except Exception as exc:
233
+ raise ActionError("Outputs does not satisfy the Action schema", "invalid_output") from exc
234
+
235
+
236
+ def _validation_values(value):
237
+ """Revalidate actual field values, without serializers or computed properties."""
238
+ if isinstance(value, BaseModel):
239
+ data = (_validation_values(value.root) if value.__pydantic_root_model__
240
+ else _model_values(value))
241
+ return type(value).model_validate(data, strict=True, by_name=True, by_alias=False)
242
+ if isinstance(value, dict):
243
+ return {key: _validation_values(item) for key, item in value.items()}
244
+ if isinstance(value, (list, tuple, set, frozenset)):
245
+ return type(value)(_validation_values(item) for item in value)
246
+ return value
247
+
248
+
249
+ def _model_values(value):
250
+ fields = {name: _validation_values(value.__dict__[name])
251
+ for name in type(value).model_fields if name in value.__dict__}
252
+ fields.update({key: _validation_values(item)
253
+ for key, item in (value.__pydantic_extra__ or {}).items()})
254
+ return fields
255
+
256
+
257
+ def _private_values(value, encoded):
258
+ """Preserve declared secrets only inside the result that the runtime seals."""
259
+ if isinstance(value, SecretStr):
260
+ return value.get_secret_value()
261
+ if isinstance(value, BaseModel) and value.__pydantic_root_model__:
262
+ return _private_values(value.root, encoded)
263
+ if isinstance(value, BaseModel) and isinstance(encoded, dict):
264
+ for name, field in type(value).model_fields.items():
265
+ key = field.serialization_alias or field.alias or name
266
+ if key in encoded:
267
+ encoded[key] = _private_values(getattr(value, name), encoded[key])
268
+ for name, field in type(value).model_computed_fields.items():
269
+ key = field.alias or name
270
+ if key in encoded:
271
+ encoded[key] = _private_values(getattr(value, name), encoded[key])
272
+ elif isinstance(value, dict) and isinstance(encoded, dict):
273
+ for key, item in value.items():
274
+ if key in encoded:
275
+ encoded[key] = _private_values(item, encoded[key])
276
+ elif isinstance(value, (list, tuple)) and isinstance(encoded, list):
277
+ encoded = [_private_values(item, val) for item, val in zip(value, encoded)]
278
+ return encoded