synax-sdk 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synax_sdk/__init__.py +42 -0
- synax_sdk/build.py +164 -0
- synax_sdk/errors.py +140 -0
- synax_sdk/manifest.py +305 -0
- synax_sdk/packaging.py +202 -0
- synax_sdk/publishing.py +372 -0
- synax_sdk/py.typed +0 -0
- synax_sdk/runtime/__init__.py +24 -0
- synax_sdk/runtime/_backends/__init__.py +2 -0
- synax_sdk/runtime/_backends/base.py +142 -0
- synax_sdk/runtime/_backends/mock.py +245 -0
- synax_sdk/runtime/_capabilities.py +104 -0
- synax_sdk/runtime/context.py +86 -0
- synax_sdk/runtime/logger.py +62 -0
- synax_sdk/runtime/metrics.py +90 -0
- synax_sdk/runtime/network.py +255 -0
- synax_sdk/runtime/secrets.py +26 -0
- synax_sdk/schema/__init__.py +34 -0
- synax_sdk/schema/manifest_v1_0.json +207 -0
- synax_sdk/signing.py +343 -0
- synax_sdk/skill.py +385 -0
- synax_sdk/testing/__init__.py +41 -0
- synax_sdk/testing/_mock_platform.py +323 -0
- synax_sdk/testing/declarative.py +372 -0
- synax_sdk/testing/pytest_plugin.py +63 -0
- synax_sdk/testing/runners.py +42 -0
- synax_sdk/tool.py +401 -0
- synax_sdk/validation.py +179 -0
- synax_sdk/version.py +3 -0
- synax_sdk-0.2.0.dist-info/METADATA +197 -0
- synax_sdk-0.2.0.dist-info/RECORD +51 -0
- synax_sdk-0.2.0.dist-info/WHEEL +4 -0
- synax_sdk-0.2.0.dist-info/entry_points.txt +5 -0
- synax_sdk-0.2.0.dist-info/licenses/LICENSE +201 -0
- synax_skill_cli/__init__.py +1 -0
- synax_skill_cli/__main__.py +6 -0
- synax_skill_cli/commands/__init__.py +1 -0
- synax_skill_cli/commands/keys.py +197 -0
- synax_skill_cli/commands/skill.py +534 -0
- synax_skill_cli/main.py +75 -0
- synax_skill_cli/templates/default/.gitignore.tmpl +22 -0
- synax_skill_cli/templates/default/.synax-skillignore.tmpl +24 -0
- synax_skill_cli/templates/default/README.md.tmpl +40 -0
- synax_skill_cli/templates/default/pyproject.toml.tmpl +27 -0
- synax_skill_cli/templates/default/skill.py.tmpl +29 -0
- synax_skill_cli/templates/default/test_inputs/greet/case_basic.yaml.tmpl +13 -0
- synax_skill_cli/templates/default/tests/test_skill.py.tmpl +24 -0
- synax_skill_cli/utils/__init__.py +1 -0
- synax_skill_cli/utils/config.py +131 -0
- synax_skill_cli/utils/project.py +146 -0
- synax_skill_cli/utils/templating.py +104 -0
synax_sdk/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Synax SDK — Python library for building skills on the Synax agentic platform.
|
|
2
|
+
|
|
3
|
+
Public API surface. Anything exported here is part of the stable (within a
|
|
4
|
+
minor version) API; anything not exported is internal and may change.
|
|
5
|
+
|
|
6
|
+
See ``docs/build_plan.md`` for what's wired up at each milestone.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from synax_sdk.errors import (
|
|
10
|
+
CapabilityError,
|
|
11
|
+
ManifestError,
|
|
12
|
+
SkillDefinitionError,
|
|
13
|
+
SkillRuntimeError,
|
|
14
|
+
SkillValidationError,
|
|
15
|
+
SynaxSDKError,
|
|
16
|
+
)
|
|
17
|
+
from synax_sdk.manifest import Manifest
|
|
18
|
+
from synax_sdk.runtime import Context, Invoker, ctx, logger, metrics, network, secrets
|
|
19
|
+
from synax_sdk.skill import Secret, Skill, SkillDependency, SkillMetadata
|
|
20
|
+
from synax_sdk.version import __version__
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CapabilityError",
|
|
24
|
+
"Context",
|
|
25
|
+
"Invoker",
|
|
26
|
+
"Manifest",
|
|
27
|
+
"ManifestError",
|
|
28
|
+
"Secret",
|
|
29
|
+
"Skill",
|
|
30
|
+
"SkillDefinitionError",
|
|
31
|
+
"SkillDependency",
|
|
32
|
+
"SkillMetadata",
|
|
33
|
+
"SkillRuntimeError",
|
|
34
|
+
"SkillValidationError",
|
|
35
|
+
"SynaxSDKError",
|
|
36
|
+
"__version__",
|
|
37
|
+
"ctx",
|
|
38
|
+
"logger",
|
|
39
|
+
"metrics",
|
|
40
|
+
"network",
|
|
41
|
+
"secrets",
|
|
42
|
+
]
|
synax_sdk/build.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""End-to-end build pipeline: derive manifest → validate → package → sign → write.
|
|
2
|
+
|
|
3
|
+
This module orchestrates every other M4 module. The CLI's
|
|
4
|
+
``synax skill build`` command loads the :class:`Skill` and the project's
|
|
5
|
+
``entry_point`` (via :mod:`synax_skill_cli.utils.project`) and passes
|
|
6
|
+
them in; ``synax_sdk.build`` itself stays independent of the CLI package
|
|
7
|
+
to avoid a circular dependency.
|
|
8
|
+
|
|
9
|
+
The pipeline:
|
|
10
|
+
|
|
11
|
+
1. Caller has already loaded a :class:`Skill` and read the project's
|
|
12
|
+
``entry_point`` from ``pyproject.toml [tool.synax]``.
|
|
13
|
+
2. Derive an in-memory :class:`Manifest` (M2 logic).
|
|
14
|
+
3. Build the deterministic tar.gz artifact (M4 packaging); pin its
|
|
15
|
+
``sha256`` onto ``implementation.hash`` and set ``entry_point``.
|
|
16
|
+
4. Validate the now-populated manifest. Schema-layer and consistency
|
|
17
|
+
errors are blocking; warnings are surfaced but don't fail the build.
|
|
18
|
+
5. Sign the canonical form with the chosen key (defaults to the store's
|
|
19
|
+
active key). Append the :class:`Signature` to the manifest.
|
|
20
|
+
6. Write the signed bundle to ``<project_root>/build/`` by default.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from synax_sdk.errors import BuildError
|
|
30
|
+
from synax_sdk.manifest import Manifest, Signature
|
|
31
|
+
from synax_sdk.packaging import ArtifactResult, build_implementation_artifact
|
|
32
|
+
from synax_sdk.signing import KeyStore
|
|
33
|
+
from synax_sdk.skill import Skill
|
|
34
|
+
from synax_sdk.validation import ValidationError, validate_manifest
|
|
35
|
+
|
|
36
|
+
DEFAULT_OUTPUT_SUBDIR = "build"
|
|
37
|
+
BUNDLE_SUBDIR = "signed_bundle"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class BuildResult:
|
|
42
|
+
"""Everything a successful build produced."""
|
|
43
|
+
|
|
44
|
+
manifest: Manifest
|
|
45
|
+
artifact: ArtifactResult
|
|
46
|
+
signature: Signature
|
|
47
|
+
output_dir: Path
|
|
48
|
+
bundle_dir: Path
|
|
49
|
+
warnings: list[ValidationError]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build(
|
|
53
|
+
project_root: Path,
|
|
54
|
+
skill: Skill,
|
|
55
|
+
*,
|
|
56
|
+
entry_point: str,
|
|
57
|
+
key_fingerprint: str | None = None,
|
|
58
|
+
output_dir: Path | None = None,
|
|
59
|
+
key_store: KeyStore | None = None,
|
|
60
|
+
) -> BuildResult:
|
|
61
|
+
"""Run the full build pipeline and return the :class:`BuildResult`.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
project_root: the directory whose contents will be packaged.
|
|
65
|
+
skill: the loaded skill object (caller has already imported it).
|
|
66
|
+
entry_point: ``module:variable`` import path (becomes
|
|
67
|
+
``implementation.entry_point`` in the manifest).
|
|
68
|
+
key_fingerprint: signing key. Defaults to the key store's active
|
|
69
|
+
default (``synax keys set-default``).
|
|
70
|
+
output_dir: where to write build outputs. Defaults to
|
|
71
|
+
``<project_root>/build``.
|
|
72
|
+
key_store: override the key store location (test seam). Defaults
|
|
73
|
+
to ``~/.synax/keys``.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
BuildError: if validation fails with blocking errors, or no
|
|
77
|
+
signing key is available.
|
|
78
|
+
"""
|
|
79
|
+
project_root = project_root.resolve()
|
|
80
|
+
output_dir = (output_dir or project_root / DEFAULT_OUTPUT_SUBDIR).resolve()
|
|
81
|
+
key_store = key_store or KeyStore.default()
|
|
82
|
+
|
|
83
|
+
manifest = Manifest.from_skill(skill)
|
|
84
|
+
manifest.implementation.entry_point = entry_point
|
|
85
|
+
|
|
86
|
+
artifact = build_implementation_artifact(
|
|
87
|
+
project_root,
|
|
88
|
+
extra_ignore=_build_output_relative_pattern(project_root, output_dir),
|
|
89
|
+
)
|
|
90
|
+
manifest.implementation.hash = artifact.sha256
|
|
91
|
+
|
|
92
|
+
errors = validate_manifest(manifest)
|
|
93
|
+
blocking = [e for e in errors if e.kind != "warning"]
|
|
94
|
+
warnings = [e for e in errors if e.kind == "warning"]
|
|
95
|
+
if blocking:
|
|
96
|
+
raise BuildError(
|
|
97
|
+
f"Manifest failed validation ({len(blocking)} error(s)). "
|
|
98
|
+
"Fix the issues below and re-run `synax skill build`.",
|
|
99
|
+
errors=list(blocking),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
fingerprint = key_fingerprint or key_store.default_fingerprint
|
|
103
|
+
if fingerprint is None:
|
|
104
|
+
raise BuildError(
|
|
105
|
+
"No signing key available. Run `synax keys generate` to create one, "
|
|
106
|
+
"or pass --key <fingerprint>."
|
|
107
|
+
)
|
|
108
|
+
keypair = key_store.load(fingerprint)
|
|
109
|
+
canonical = manifest.to_canonical_json()
|
|
110
|
+
signature_b64 = keypair.signer().sign(canonical)
|
|
111
|
+
signature = Signature(
|
|
112
|
+
key_fingerprint=fingerprint,
|
|
113
|
+
signer_type="author",
|
|
114
|
+
signature=signature_b64,
|
|
115
|
+
signed_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
|
116
|
+
)
|
|
117
|
+
manifest.signatures.append(signature)
|
|
118
|
+
|
|
119
|
+
bundle_dir = _write_outputs(output_dir, manifest, artifact, canonical)
|
|
120
|
+
|
|
121
|
+
return BuildResult(
|
|
122
|
+
manifest=manifest,
|
|
123
|
+
artifact=artifact,
|
|
124
|
+
signature=signature,
|
|
125
|
+
output_dir=output_dir,
|
|
126
|
+
bundle_dir=bundle_dir,
|
|
127
|
+
warnings=warnings,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_output_relative_pattern(project_root: Path, output_dir: Path) -> list[str] | None:
|
|
132
|
+
"""Return a ``.synax-skillignore``-compatible pattern excluding ``output_dir``.
|
|
133
|
+
|
|
134
|
+
Returns ``None`` when ``output_dir`` is outside the project tree (no
|
|
135
|
+
need to ignore — packaging wouldn't pick it up anyway).
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
rel = output_dir.relative_to(project_root)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return None
|
|
141
|
+
rel_str = rel.as_posix().strip("/")
|
|
142
|
+
return [f"{rel_str}/"] if rel_str else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _write_outputs(
|
|
146
|
+
output_dir: Path,
|
|
147
|
+
manifest: Manifest,
|
|
148
|
+
artifact: ArtifactResult,
|
|
149
|
+
canonical: bytes,
|
|
150
|
+
) -> Path:
|
|
151
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
bundle_dir = output_dir / BUNDLE_SUBDIR
|
|
153
|
+
bundle_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
|
|
155
|
+
# Bundle: what gets uploaded to the platform.
|
|
156
|
+
(bundle_dir / "manifest.yaml").write_text(manifest.to_yaml(), encoding="utf-8")
|
|
157
|
+
(bundle_dir / "implementation.tar.gz").write_bytes(artifact.tar_gz_bytes)
|
|
158
|
+
|
|
159
|
+
# Diagnostic files at the build root.
|
|
160
|
+
(output_dir / "manifest.yaml").write_text(manifest.to_yaml(), encoding="utf-8")
|
|
161
|
+
(output_dir / "manifest.json").write_text(manifest.to_json(), encoding="utf-8")
|
|
162
|
+
(output_dir / "manifest.canonical").write_bytes(canonical)
|
|
163
|
+
|
|
164
|
+
return bundle_dir
|
synax_sdk/errors.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Exception hierarchy for the Synax SDK.
|
|
2
|
+
|
|
3
|
+
All SDK-raised exceptions inherit from :class:`SynaxSDKError`, so users can
|
|
4
|
+
catch every SDK error with one ``except`` clause if desired. More specific
|
|
5
|
+
subclasses are preferred when the error category is known.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SynaxSDKError(Exception):
|
|
12
|
+
"""Base class for every exception raised by the Synax SDK."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SkillDefinitionError(SynaxSDKError):
|
|
16
|
+
"""A :class:`~synax_sdk.skill.Skill` or tool is defined improperly.
|
|
17
|
+
|
|
18
|
+
Raised at construction time (in ``Skill.__init__`` or in the
|
|
19
|
+
``@skill.tool`` decorator) when the author's code violates the SDK's
|
|
20
|
+
structural rules — for example an invalid skill name, a tool with an
|
|
21
|
+
untyped parameter, or a secret that lacks a matching capability.
|
|
22
|
+
|
|
23
|
+
These errors are fatal at *build* time. They never propagate to a
|
|
24
|
+
running skill, because a skill that fails to define cannot be invoked.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SkillValidationError(SynaxSDKError):
|
|
29
|
+
"""A skill or manifest failed semantic validation.
|
|
30
|
+
|
|
31
|
+
Distinct from :class:`SkillDefinitionError`: definition errors are about
|
|
32
|
+
*shape* (caught while the Python is being loaded), validation errors
|
|
33
|
+
are about *consistency* across the whole skill — e.g. a manifest whose
|
|
34
|
+
JSON Schema doesn't match the schema spec.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ManifestError(SynaxSDKError):
|
|
39
|
+
"""A manifest could not be constructed, serialized, or parsed."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SkillRuntimeError(SynaxSDKError):
|
|
43
|
+
"""A skill attempted to use the runtime API in a way that failed.
|
|
44
|
+
|
|
45
|
+
Common cases:
|
|
46
|
+
|
|
47
|
+
* No runtime backend is active (skill imported but not running inside
|
|
48
|
+
the platform or a :class:`~synax_sdk.testing.MockPlatform`).
|
|
49
|
+
* The mock backend was active but the requested secret / context
|
|
50
|
+
wasn't configured before the skill was invoked.
|
|
51
|
+
|
|
52
|
+
The message names what was attempted and what to do about it.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CapabilityError(SynaxSDKError):
|
|
57
|
+
"""A skill attempted a gated operation without the required capability.
|
|
58
|
+
|
|
59
|
+
Raised by the runtime API (e.g. :mod:`synax_sdk.runtime.network`) when
|
|
60
|
+
the skill's runtime-effective capability set — supplied by the platform
|
|
61
|
+
via the runtime contract, or programmatically via
|
|
62
|
+
:class:`~synax_sdk.testing.MockPlatform` in tests — does not include
|
|
63
|
+
the capability needed for the call.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
requested: The capability string the call required, in its most
|
|
67
|
+
specific form (e.g. ``"network:api.github.com:443"`` for a URL
|
|
68
|
+
with an explicit port).
|
|
69
|
+
available: The capabilities currently allowed for the invocation.
|
|
70
|
+
Capability names are not sensitive and are safe to log.
|
|
71
|
+
|
|
72
|
+
The error message names the missing capability and lists what *is*
|
|
73
|
+
allowed, so the skill author can see at a glance what to add.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
requested: str,
|
|
80
|
+
available: frozenset[str],
|
|
81
|
+
) -> None:
|
|
82
|
+
self.requested = requested
|
|
83
|
+
self.available = frozenset(available)
|
|
84
|
+
listed = ", ".join(sorted(self.available)) if self.available else "(none)"
|
|
85
|
+
super().__init__(
|
|
86
|
+
f"Skill did not declare the required capability {requested!r}. "
|
|
87
|
+
f"Currently allowed: {listed}. "
|
|
88
|
+
f"Add it to the Skill's capabilities list to enable this operation."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BuildError(SynaxSDKError):
|
|
93
|
+
"""A build step (load, validate, package, sign, write) failed.
|
|
94
|
+
|
|
95
|
+
Aggregates validation errors when validation is the cause; the CLI
|
|
96
|
+
formats them into a coloured list. Construct via :meth:`with_errors`
|
|
97
|
+
when carrying a structured list.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, message: str, *, errors: list[object] | None = None) -> None:
|
|
101
|
+
super().__init__(message)
|
|
102
|
+
self.errors = errors or []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PublishError(SynaxSDKError):
|
|
106
|
+
"""Base class for publishing failures.
|
|
107
|
+
|
|
108
|
+
Subclasses distinguish between authentication problems, server-side
|
|
109
|
+
rejection of the manifest, version conflicts, and network/transient
|
|
110
|
+
failures so the CLI can render the right message and exit code.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PublishAuthError(PublishError):
|
|
115
|
+
"""The platform rejected the auth token (HTTP 401/403)."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PublishValidationError(PublishError):
|
|
119
|
+
"""The platform rejected the manifest (HTTP 400). Typically because the
|
|
120
|
+
SDK's vendored schema is ahead of or behind the platform's."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, message: str, *, details: object | None = None) -> None:
|
|
123
|
+
super().__init__(message)
|
|
124
|
+
self.details = details
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PublishVersionConflictError(PublishError):
|
|
128
|
+
"""Version already published (HTTP 409). The author must bump the version."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, message: str, *, existing_version_id: str | None = None) -> None:
|
|
131
|
+
super().__init__(message)
|
|
132
|
+
self.existing_version_id = existing_version_id
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Backwards-compatible alias for the original name (without the Error suffix).
|
|
136
|
+
PublishVersionConflict = PublishVersionConflictError
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class PublishNetworkError(PublishError):
|
|
140
|
+
"""Network failure or persistent 5xx after retries."""
|
synax_sdk/manifest.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Skill manifest dataclasses, the ``from_skill`` factory, and serialization.
|
|
2
|
+
|
|
3
|
+
A manifest is the YAML/JSON representation of a skill that the platform
|
|
4
|
+
consumes. This module is the SDK's source of truth for the manifest shape;
|
|
5
|
+
the schema it produces must match ``docs/manifest_spec.md``.
|
|
6
|
+
|
|
7
|
+
At Milestone 2 the manifest is *in-memory only* — no file I/O, no signing,
|
|
8
|
+
no JSON Schema validation. ``Implementation.entry_point`` and
|
|
9
|
+
``Implementation.hash`` are placeholders that later milestones (4: packaging
|
|
10
|
+
& signing) fill in.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from synax_sdk import version as _version_module
|
|
23
|
+
from synax_sdk.errors import ManifestError
|
|
24
|
+
from synax_sdk.skill import Secret, Skill, SkillDependency, SkillMetadata
|
|
25
|
+
from synax_sdk.tool import Tool
|
|
26
|
+
|
|
27
|
+
SCHEMA_VERSION = "1.0"
|
|
28
|
+
|
|
29
|
+
SignerType = Literal["author", "platform_verifier", "audit", "customer_internal"]
|
|
30
|
+
ImplementationType = Literal["python_code", "manifest_only", "typescript_code"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Sub-dataclasses
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
@dataclass
|
|
37
|
+
class Implementation:
|
|
38
|
+
"""How the skill is implemented.
|
|
39
|
+
|
|
40
|
+
Only ``type`` is required at construction time. ``entry_point`` and
|
|
41
|
+
``hash`` are filled in by the build pipeline (Milestone 4).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
type: ImplementationType = "python_code"
|
|
45
|
+
entry_point: str | None = None
|
|
46
|
+
hash: str | None = None
|
|
47
|
+
runtime_requirements: dict[str, str] | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ToolManifest:
|
|
52
|
+
"""A tool as it appears in the manifest."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
description: str
|
|
56
|
+
parameters: dict[str, Any]
|
|
57
|
+
returns: dict[str, Any] | None = None
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_tool(cls, tool: Tool) -> ToolManifest:
|
|
61
|
+
return cls(
|
|
62
|
+
name=tool.name,
|
|
63
|
+
description=tool.description,
|
|
64
|
+
parameters=dict(tool.parameters_schema),
|
|
65
|
+
returns=dict(tool.returns_schema) if tool.returns_schema is not None else None,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class SecretManifest:
|
|
71
|
+
"""A secret as it appears in the manifest."""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
type: str
|
|
75
|
+
description: str
|
|
76
|
+
scope_required: str | None = None
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_secret(cls, secret: Secret) -> SecretManifest:
|
|
80
|
+
return cls(
|
|
81
|
+
name=secret.name,
|
|
82
|
+
type=secret.type,
|
|
83
|
+
description=secret.description,
|
|
84
|
+
scope_required=secret.scope_required,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class DependencyManifest:
|
|
90
|
+
skill: str
|
|
91
|
+
version: str
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_dependency(cls, dep: SkillDependency) -> DependencyManifest:
|
|
95
|
+
return cls(skill=dep.skill, version=dep.version)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class ManifestMetadata:
|
|
100
|
+
"""Manifest-side metadata. Mirrors :class:`SkillMetadata` and adds
|
|
101
|
+
``sdk_version`` (populated automatically by ``Manifest.from_skill``)."""
|
|
102
|
+
|
|
103
|
+
category: str | None = None
|
|
104
|
+
tags: list[str] = field(default_factory=list)
|
|
105
|
+
homepage: str | None = None
|
|
106
|
+
source: str | None = None
|
|
107
|
+
documentation: str | None = None
|
|
108
|
+
license: str | None = None
|
|
109
|
+
long_description: str | None = None
|
|
110
|
+
icon_url: str | None = None
|
|
111
|
+
sdk_version: str | None = None
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_skill_metadata(
|
|
115
|
+
cls, meta: SkillMetadata, *, sdk_version: str | None = None
|
|
116
|
+
) -> ManifestMetadata:
|
|
117
|
+
return cls(
|
|
118
|
+
category=meta.category,
|
|
119
|
+
tags=list(meta.tags),
|
|
120
|
+
homepage=meta.homepage,
|
|
121
|
+
source=meta.source,
|
|
122
|
+
documentation=meta.documentation,
|
|
123
|
+
license=meta.license,
|
|
124
|
+
long_description=meta.long_description,
|
|
125
|
+
icon_url=meta.icon_url,
|
|
126
|
+
sdk_version=sdk_version,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class Signature:
|
|
132
|
+
key_fingerprint: str
|
|
133
|
+
signer_type: SignerType
|
|
134
|
+
signature: str
|
|
135
|
+
signed_at: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# The top-level Manifest
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
@dataclass
|
|
142
|
+
class Manifest:
|
|
143
|
+
"""The top-level skill manifest.
|
|
144
|
+
|
|
145
|
+
Construct via :meth:`Manifest.from_skill` rather than instantiating
|
|
146
|
+
directly. Serialize via :meth:`to_yaml`, :meth:`to_json`, or
|
|
147
|
+
:meth:`to_canonical_json` (the bytes used as input to Ed25519 signing).
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
schema_version: str
|
|
151
|
+
name: str
|
|
152
|
+
version: str
|
|
153
|
+
display_name: str
|
|
154
|
+
description: str
|
|
155
|
+
implementation: Implementation
|
|
156
|
+
tools: list[ToolManifest]
|
|
157
|
+
namespace: str | None = None
|
|
158
|
+
capabilities: list[str] = field(default_factory=list)
|
|
159
|
+
execution_location: str = "either"
|
|
160
|
+
secrets: list[SecretManifest] = field(default_factory=list)
|
|
161
|
+
dependencies: list[DependencyManifest] = field(default_factory=list)
|
|
162
|
+
metadata: ManifestMetadata = field(default_factory=ManifestMetadata)
|
|
163
|
+
signatures: list[Signature] = field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
# -----------------------------------------------------------------------
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_skill(
|
|
168
|
+
cls,
|
|
169
|
+
skill: Skill,
|
|
170
|
+
*,
|
|
171
|
+
sdk_version: str | None = None,
|
|
172
|
+
) -> Manifest:
|
|
173
|
+
"""Build a Manifest from a :class:`~synax_sdk.skill.Skill`.
|
|
174
|
+
|
|
175
|
+
The resulting manifest has ``implementation.entry_point`` and
|
|
176
|
+
``implementation.hash`` left as ``None``; the build pipeline
|
|
177
|
+
(Milestone 4) fills them in once it knows the packaged artifact's
|
|
178
|
+
module path and hash.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
skill: source of truth.
|
|
182
|
+
sdk_version: overrides the auto-detected SDK version stamp in
|
|
183
|
+
``metadata.sdk_version``. Mostly useful for reproducible
|
|
184
|
+
tests.
|
|
185
|
+
"""
|
|
186
|
+
return cls(
|
|
187
|
+
schema_version=SCHEMA_VERSION,
|
|
188
|
+
name=skill.name,
|
|
189
|
+
namespace=skill.namespace,
|
|
190
|
+
version=skill.version,
|
|
191
|
+
display_name=skill.display_name,
|
|
192
|
+
description=skill.description,
|
|
193
|
+
implementation=Implementation(type="python_code"),
|
|
194
|
+
tools=[ToolManifest.from_tool(t) for t in skill.tools],
|
|
195
|
+
capabilities=list(skill.capabilities),
|
|
196
|
+
execution_location=skill.execution_location,
|
|
197
|
+
secrets=[SecretManifest.from_secret(s) for s in skill.secrets],
|
|
198
|
+
dependencies=[DependencyManifest.from_dependency(d) for d in skill.dependencies],
|
|
199
|
+
metadata=ManifestMetadata.from_skill_metadata(
|
|
200
|
+
skill.metadata,
|
|
201
|
+
sdk_version=(
|
|
202
|
+
sdk_version if sdk_version is not None else _version_module.__version__
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
signatures=[],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# -----------------------------------------------------------------------
|
|
209
|
+
# Serialization
|
|
210
|
+
# -----------------------------------------------------------------------
|
|
211
|
+
def to_dict(self) -> dict[str, Any]:
|
|
212
|
+
"""Return a plain ``dict`` matching ``manifest_spec.md`` field order.
|
|
213
|
+
|
|
214
|
+
Empty optional collections, ``None`` scalars, and defaulted values
|
|
215
|
+
(``execution_location == "either"``) are omitted so the resulting
|
|
216
|
+
document is concise.
|
|
217
|
+
"""
|
|
218
|
+
result: dict[str, Any] = {
|
|
219
|
+
"schema_version": self.schema_version,
|
|
220
|
+
"name": self.name,
|
|
221
|
+
}
|
|
222
|
+
if self.namespace:
|
|
223
|
+
result["namespace"] = self.namespace
|
|
224
|
+
result["version"] = self.version
|
|
225
|
+
result["display_name"] = self.display_name
|
|
226
|
+
result["description"] = self.description
|
|
227
|
+
result["implementation"] = _filter_dataclass(self.implementation)
|
|
228
|
+
result["tools"] = [_filter_dataclass(t) for t in self.tools]
|
|
229
|
+
if self.capabilities:
|
|
230
|
+
result["capabilities"] = list(self.capabilities)
|
|
231
|
+
if self.execution_location != "either":
|
|
232
|
+
result["execution_location"] = self.execution_location
|
|
233
|
+
if self.secrets:
|
|
234
|
+
result["secrets"] = [_filter_dataclass(s) for s in self.secrets]
|
|
235
|
+
if self.dependencies:
|
|
236
|
+
result["dependencies"] = [_filter_dataclass(d) for d in self.dependencies]
|
|
237
|
+
meta = _filter_dataclass(self.metadata)
|
|
238
|
+
if meta:
|
|
239
|
+
result["metadata"] = meta
|
|
240
|
+
if self.signatures:
|
|
241
|
+
result["signatures"] = [_filter_dataclass(s) for s in self.signatures]
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
def to_yaml(self) -> str:
|
|
245
|
+
"""Serialize as a YAML document with the spec's field order preserved."""
|
|
246
|
+
try:
|
|
247
|
+
return yaml.safe_dump(
|
|
248
|
+
self.to_dict(),
|
|
249
|
+
sort_keys=False,
|
|
250
|
+
default_flow_style=False,
|
|
251
|
+
allow_unicode=True,
|
|
252
|
+
)
|
|
253
|
+
except yaml.YAMLError as exc: # pragma: no cover - defensive
|
|
254
|
+
raise ManifestError(f"Failed to serialize manifest to YAML: {exc}") from exc
|
|
255
|
+
|
|
256
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
257
|
+
"""Serialize as JSON. Default is indented for human review."""
|
|
258
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
259
|
+
|
|
260
|
+
def to_canonical_json(self) -> bytes:
|
|
261
|
+
"""Return the canonical signing bytes.
|
|
262
|
+
|
|
263
|
+
The canonical form (per ``manifest_spec.md`` §Canonical form for
|
|
264
|
+
signing):
|
|
265
|
+
|
|
266
|
+
1. JSON object representation
|
|
267
|
+
2. Drop ``signatures`` (signatures sign everything *except* themselves)
|
|
268
|
+
3. Sort keys recursively
|
|
269
|
+
4. No whitespace
|
|
270
|
+
5. UTF-8 encoded
|
|
271
|
+
"""
|
|
272
|
+
payload = self.to_dict()
|
|
273
|
+
payload.pop("signatures", None)
|
|
274
|
+
return json.dumps(
|
|
275
|
+
payload,
|
|
276
|
+
sort_keys=True,
|
|
277
|
+
separators=(",", ":"),
|
|
278
|
+
ensure_ascii=False,
|
|
279
|
+
).encode("utf-8")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
# Helpers
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
def _filter_dataclass(obj: Any) -> Any:
|
|
286
|
+
"""Convert a dataclass to dict, omitting None and empty list/dict fields.
|
|
287
|
+
|
|
288
|
+
Plain ``dict`` instances inside the dataclass (such as a JSON Schema
|
|
289
|
+
payload) are returned unchanged — we only filter at the dataclass
|
|
290
|
+
boundary, not inside opaque payloads.
|
|
291
|
+
"""
|
|
292
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
293
|
+
result: dict[str, Any] = {}
|
|
294
|
+
for f in dataclasses.fields(obj):
|
|
295
|
+
value = getattr(obj, f.name)
|
|
296
|
+
cleaned = _filter_dataclass(value)
|
|
297
|
+
if cleaned is None:
|
|
298
|
+
continue
|
|
299
|
+
if isinstance(cleaned, list | dict) and len(cleaned) == 0:
|
|
300
|
+
continue
|
|
301
|
+
result[f.name] = cleaned
|
|
302
|
+
return result
|
|
303
|
+
if isinstance(obj, list):
|
|
304
|
+
return [_filter_dataclass(item) for item in obj]
|
|
305
|
+
return obj
|