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.
- apagea-2026.10.1rc2/ActionBase.Dockerfile +34 -0
- apagea-2026.10.1rc2/Dockerfile +30 -0
- apagea-2026.10.1rc2/PKG-INFO +62 -0
- apagea-2026.10.1rc2/README.md +51 -0
- apagea-2026.10.1rc2/apagea/__init__.py +15 -0
- apagea-2026.10.1rc2/apagea/actions/__init__.py +4 -0
- apagea-2026.10.1rc2/apagea/actions/__main__.py +19 -0
- apagea-2026.10.1rc2/apagea/actions/artifacts.py +65 -0
- apagea-2026.10.1rc2/apagea/actions/build.py +45 -0
- apagea-2026.10.1rc2/apagea/actions/build_dependencies.py +35 -0
- apagea-2026.10.1rc2/apagea/actions/contract.py +278 -0
- apagea-2026.10.1rc2/apagea/actions/development.py +41 -0
- apagea-2026.10.1rc2/apagea/actions/diagnostics.py +100 -0
- apagea-2026.10.1rc2/apagea/actions/package_export.py +116 -0
- apagea-2026.10.1rc2/apagea/actions/runtime.py +166 -0
- apagea-2026.10.1rc2/apagea/actions/sealing.py +20 -0
- apagea-2026.10.1rc2/apagea/actions/timing.py +13 -0
- apagea-2026.10.1rc2/apagea/blocks/__init__.py +28 -0
- apagea-2026.10.1rc2/apagea/bootstrap.py +120 -0
- apagea-2026.10.1rc2/apagea/contracts/__init__.py +19 -0
- apagea-2026.10.1rc2/apagea/contracts/schemas.json +597 -0
- apagea-2026.10.1rc2/apagea/fields/__init__.py +73 -0
- apagea-2026.10.1rc2/apagea.egg-info/PKG-INFO +62 -0
- apagea-2026.10.1rc2/apagea.egg-info/SOURCES.txt +39 -0
- apagea-2026.10.1rc2/apagea.egg-info/dependency_links.txt +1 -0
- apagea-2026.10.1rc2/apagea.egg-info/requires.txt +4 -0
- apagea-2026.10.1rc2/apagea.egg-info/top_level.txt +1 -0
- apagea-2026.10.1rc2/embed.go +18 -0
- apagea-2026.10.1rc2/pyproject.toml +28 -0
- apagea-2026.10.1rc2/setup.cfg +4 -0
- apagea-2026.10.1rc2/tests/test_apagea.py +117 -0
- apagea-2026.10.1rc2/tests/test_blocks.py +46 -0
- apagea-2026.10.1rc2/tests/test_build_dependencies.py +35 -0
- apagea-2026.10.1rc2/tests/test_contract.py +172 -0
- apagea-2026.10.1rc2/tests/test_delivery.py +86 -0
- apagea-2026.10.1rc2/tests/test_diagnostics.py +161 -0
- apagea-2026.10.1rc2/tests/test_operations_examples.py +79 -0
- apagea-2026.10.1rc2/tests/test_package_export.py +91 -0
- apagea-2026.10.1rc2/tests/test_runtime_startup.py +63 -0
- apagea-2026.10.1rc2/tests/test_shared_contracts.py +66 -0
- 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,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
|