charms-core 0.1.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.
- charms_core/__init__.py +112 -0
- charms_core/chunk.py +81 -0
- charms_core/node.py +91 -0
- charms_core/package.py +345 -0
- charms_core/protocol.py +257 -0
- charms_core/py.typed +0 -0
- charms_core/recipe.py +402 -0
- charms_core/seed.py +162 -0
- charms_core/types.py +106 -0
- charms_core-0.1.0.dist-info/METADATA +78 -0
- charms_core-0.1.0.dist-info/RECORD +12 -0
- charms_core-0.1.0.dist-info/WHEEL +4 -0
charms_core/__init__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""charms-core — shared contracts for the Charms platform."""
|
|
2
|
+
|
|
3
|
+
from charms_core import protocol
|
|
4
|
+
from charms_core.chunk import Chunk, decode_chunk_message, encode_chunk_message
|
|
5
|
+
from charms_core.node import BasicNode, ConfigT, InputT, OutputT
|
|
6
|
+
from charms_core.package import (
|
|
7
|
+
EnvVarSpec,
|
|
8
|
+
OptionType,
|
|
9
|
+
PriceUnit,
|
|
10
|
+
PyprojectInfo,
|
|
11
|
+
SeedInstallSpec,
|
|
12
|
+
SeedOption,
|
|
13
|
+
SeedPackage,
|
|
14
|
+
SeedPackageManifest,
|
|
15
|
+
SeedPrice,
|
|
16
|
+
load_seed_package,
|
|
17
|
+
validate_environment,
|
|
18
|
+
)
|
|
19
|
+
from charms_core.recipe import (
|
|
20
|
+
Binding,
|
|
21
|
+
Bindings,
|
|
22
|
+
CatalogEntry,
|
|
23
|
+
FieldValue,
|
|
24
|
+
NodeCatalog,
|
|
25
|
+
Recipe,
|
|
26
|
+
RecipeEdge,
|
|
27
|
+
RecipeNode,
|
|
28
|
+
ValidationProblem,
|
|
29
|
+
chunk_path,
|
|
30
|
+
topo_sort,
|
|
31
|
+
validate_recipe,
|
|
32
|
+
)
|
|
33
|
+
from charms_core.seed import (
|
|
34
|
+
ArtifactDownloader,
|
|
35
|
+
Seed,
|
|
36
|
+
SeedArtifact,
|
|
37
|
+
SeedContext,
|
|
38
|
+
SeedDescriptor,
|
|
39
|
+
SeedManifest,
|
|
40
|
+
SeedResources,
|
|
41
|
+
)
|
|
42
|
+
from charms_core.types import (
|
|
43
|
+
CharmsError,
|
|
44
|
+
ConfigurationError,
|
|
45
|
+
IncompatiblePortError,
|
|
46
|
+
InsufficientManaError,
|
|
47
|
+
Modality,
|
|
48
|
+
NodeExecutionError,
|
|
49
|
+
NodeMetadata,
|
|
50
|
+
NoRuneAvailableError,
|
|
51
|
+
PortSchema,
|
|
52
|
+
ProtocolError,
|
|
53
|
+
RecipeValidationError,
|
|
54
|
+
SeedPackageError,
|
|
55
|
+
TaskFailedError,
|
|
56
|
+
modalities_compatible,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"ArtifactDownloader",
|
|
61
|
+
"BasicNode",
|
|
62
|
+
"Binding",
|
|
63
|
+
"Bindings",
|
|
64
|
+
"CatalogEntry",
|
|
65
|
+
"CharmsError",
|
|
66
|
+
"Chunk",
|
|
67
|
+
"ConfigT",
|
|
68
|
+
"ConfigurationError",
|
|
69
|
+
"EnvVarSpec",
|
|
70
|
+
"FieldValue",
|
|
71
|
+
"IncompatiblePortError",
|
|
72
|
+
"InputT",
|
|
73
|
+
"InsufficientManaError",
|
|
74
|
+
"Modality",
|
|
75
|
+
"NodeCatalog",
|
|
76
|
+
"NodeExecutionError",
|
|
77
|
+
"NodeMetadata",
|
|
78
|
+
"NoRuneAvailableError",
|
|
79
|
+
"OptionType",
|
|
80
|
+
"OutputT",
|
|
81
|
+
"PortSchema",
|
|
82
|
+
"PriceUnit",
|
|
83
|
+
"ProtocolError",
|
|
84
|
+
"PyprojectInfo",
|
|
85
|
+
"Recipe",
|
|
86
|
+
"RecipeEdge",
|
|
87
|
+
"RecipeNode",
|
|
88
|
+
"RecipeValidationError",
|
|
89
|
+
"Seed",
|
|
90
|
+
"SeedArtifact",
|
|
91
|
+
"SeedContext",
|
|
92
|
+
"SeedDescriptor",
|
|
93
|
+
"SeedInstallSpec",
|
|
94
|
+
"SeedManifest",
|
|
95
|
+
"SeedOption",
|
|
96
|
+
"SeedPackage",
|
|
97
|
+
"SeedPackageError",
|
|
98
|
+
"SeedPackageManifest",
|
|
99
|
+
"SeedPrice",
|
|
100
|
+
"SeedResources",
|
|
101
|
+
"TaskFailedError",
|
|
102
|
+
"ValidationProblem",
|
|
103
|
+
"chunk_path",
|
|
104
|
+
"decode_chunk_message",
|
|
105
|
+
"encode_chunk_message",
|
|
106
|
+
"load_seed_package",
|
|
107
|
+
"modalities_compatible",
|
|
108
|
+
"protocol",
|
|
109
|
+
"topo_sort",
|
|
110
|
+
"validate_environment",
|
|
111
|
+
"validate_recipe",
|
|
112
|
+
]
|
charms_core/chunk.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The streaming unit and its wire codec.
|
|
3
|
+
|
|
4
|
+
A ``Chunk`` travels over WebSockets as one JSON text frame; if it carries a
|
|
5
|
+
binary payload the JSON includes ``"binary": true`` and **exactly one** binary
|
|
6
|
+
frame with the raw bytes follows immediately on the same connection. Text
|
|
7
|
+
chunks embed their payload inline and send no binary frame. This module is the
|
|
8
|
+
only place that framing rule is implemented — both the launcher and client
|
|
9
|
+
sockets reuse it.
|
|
10
|
+
|
|
11
|
+
Audio payloads are PCM float32 little-endian mono unless ``meta`` says
|
|
12
|
+
otherwise; ``meta.sample_rate`` is mandatory for audio chunks.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
|
19
|
+
|
|
20
|
+
from charms_core.types import Modality, ProtocolError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Chunk(BaseModel):
|
|
24
|
+
"""One unit of a realtime stream."""
|
|
25
|
+
|
|
26
|
+
stream_id: str
|
|
27
|
+
seq: int = Field(ge=0, description="0-based sequence number within the stream")
|
|
28
|
+
timestamp_ms: float = 0.0
|
|
29
|
+
is_final: bool = False # final marker; may carry a zero-length payload
|
|
30
|
+
modality: Modality
|
|
31
|
+
text: str | None = None # TEXT chunks: payload travels inline in the JSON
|
|
32
|
+
data: bytes | None = Field(default=None, exclude=True) # binary payload, never in JSON
|
|
33
|
+
meta: dict[str, Any] = Field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
@model_validator(mode="after")
|
|
36
|
+
def _check_payload(self) -> "Chunk":
|
|
37
|
+
if self.text is not None and self.data is not None:
|
|
38
|
+
raise ValueError("a chunk carries either inline text or binary data, not both")
|
|
39
|
+
if self.text is not None and self.modality is not Modality.TEXT:
|
|
40
|
+
raise ValueError("inline text payload requires modality=text")
|
|
41
|
+
if (
|
|
42
|
+
self.modality is Modality.AUDIO
|
|
43
|
+
and self.data is not None
|
|
44
|
+
and "sample_rate" not in self.meta
|
|
45
|
+
):
|
|
46
|
+
raise ValueError("audio chunks must declare meta.sample_rate")
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def encode_chunk_message(message_type: str, chunk: Chunk) -> tuple[str, bytes | None]:
|
|
51
|
+
"""
|
|
52
|
+
Encode a chunk as a wire message.
|
|
53
|
+
|
|
54
|
+
Returns ``(json_text, binary_payload)``; when the payload is not None the
|
|
55
|
+
sender MUST transmit it as the next binary frame on the same connection.
|
|
56
|
+
"""
|
|
57
|
+
payload = chunk.model_dump(mode="json")
|
|
58
|
+
payload["type"] = message_type
|
|
59
|
+
payload["binary"] = chunk.data is not None
|
|
60
|
+
return json.dumps(payload), chunk.data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_chunk_message(obj: dict[str, Any], binary: bytes | None) -> Chunk:
|
|
64
|
+
"""
|
|
65
|
+
Decode a parsed chunk-message JSON object plus its (optional) binary frame.
|
|
66
|
+
|
|
67
|
+
Raises ProtocolError when framing is broken (a declared binary frame is
|
|
68
|
+
missing, an undeclared one is supplied, or the fields don't validate).
|
|
69
|
+
"""
|
|
70
|
+
expects_binary = bool(obj.get("binary", False))
|
|
71
|
+
if expects_binary and binary is None:
|
|
72
|
+
raise ProtocolError("chunk message declared a binary frame but none was supplied")
|
|
73
|
+
if not expects_binary and binary is not None:
|
|
74
|
+
raise ProtocolError("binary frame supplied for a chunk message that declared none")
|
|
75
|
+
fields = {k: v for k, v in obj.items() if k not in ("type", "binary")}
|
|
76
|
+
if binary is not None:
|
|
77
|
+
fields["data"] = binary
|
|
78
|
+
try:
|
|
79
|
+
return Chunk.model_validate(fields)
|
|
80
|
+
except ValidationError as exc:
|
|
81
|
+
raise ProtocolError(f"invalid chunk message: {exc}") from exc
|
charms_core/node.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BasicNode — the server-executed recipe node.
|
|
3
|
+
|
|
4
|
+
Basic nodes are strictly lightweight glue (the heavy-compute principle):
|
|
5
|
+
no GPU, no model runtimes, no heavy imports, no blocking I/O. Anything
|
|
6
|
+
heavier is a Seed executed remotely by a Rune.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Any, ClassVar, Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from charms_core.chunk import Chunk
|
|
15
|
+
from charms_core.types import (
|
|
16
|
+
IncompatiblePortError,
|
|
17
|
+
NodeMetadata,
|
|
18
|
+
PortSchema,
|
|
19
|
+
modalities_compatible,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
InputT = TypeVar("InputT", bound=BaseModel)
|
|
23
|
+
OutputT = TypeVar("OutputT", bound=BaseModel)
|
|
24
|
+
ConfigT = TypeVar("ConfigT", bound="BaseModel | None")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BasicNode(ABC, Generic[InputT, OutputT, ConfigT]):
|
|
28
|
+
"""
|
|
29
|
+
Atomic in-process processing unit.
|
|
30
|
+
|
|
31
|
+
Generic parameters
|
|
32
|
+
------------------
|
|
33
|
+
InputT — Pydantic model for data inputs
|
|
34
|
+
OutputT — Pydantic model for data outputs
|
|
35
|
+
ConfigT — Pydantic model for configuration (use ``None`` if not needed)
|
|
36
|
+
|
|
37
|
+
Conventions: ``run()`` never reads env vars or files; errors at the node
|
|
38
|
+
boundary are ``CharmsError`` subclasses; ``Field(description=...)`` doubles
|
|
39
|
+
as the UI tooltip and ``json_schema_extra`` carries UI hints.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
input_model: ClassVar[type[BaseModel]]
|
|
43
|
+
output_model: ClassVar[type[BaseModel]]
|
|
44
|
+
config_model: ClassVar[type[BaseModel] | None] = None
|
|
45
|
+
supports_streaming: ClassVar[bool] = False # if True, implement transform_chunk()
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def metadata(cls) -> NodeMetadata:
|
|
50
|
+
"""Return static metadata describing this node's ports."""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def run(self, input: InputT, config: ConfigT | None = None) -> OutputT:
|
|
54
|
+
"""Execute this node's logic and return an output."""
|
|
55
|
+
|
|
56
|
+
async def transform_chunk(self, chunk: Chunk) -> Chunk | None:
|
|
57
|
+
"""
|
|
58
|
+
Per-chunk transform for streaming-capable basic nodes on a realtime
|
|
59
|
+
chunk path. Return None to swallow a chunk.
|
|
60
|
+
"""
|
|
61
|
+
raise NotImplementedError(f"{type(self).__name__} does not support streaming")
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------ #
|
|
64
|
+
# Port / compatibility helpers #
|
|
65
|
+
# ------------------------------------------------------------------ #
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def input_ports(cls) -> list[PortSchema]:
|
|
69
|
+
return cls.metadata().inputs
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def output_ports(cls) -> list[PortSchema]:
|
|
73
|
+
return cls.metadata().outputs
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def accepts(cls, port: PortSchema) -> bool:
|
|
77
|
+
"""Return True if this node has an input port compatible with *port*."""
|
|
78
|
+
return any(modalities_compatible(port.modality, inp.modality) for inp in cls.input_ports())
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def assert_compatible(cls, upstream: "type[BasicNode[Any, Any, Any]]") -> None:
|
|
82
|
+
"""
|
|
83
|
+
Raise IncompatiblePortError if no output port of *upstream* matches any
|
|
84
|
+
input port of this node.
|
|
85
|
+
"""
|
|
86
|
+
for out_port in upstream.output_ports():
|
|
87
|
+
if cls.accepts(out_port):
|
|
88
|
+
return
|
|
89
|
+
raise IncompatiblePortError(
|
|
90
|
+
f"No compatible connection: {upstream.metadata().node_id} → {cls.metadata().node_id}"
|
|
91
|
+
)
|
charms_core/package.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The seed package format.
|
|
3
|
+
|
|
4
|
+
A seed package is the distributable form of a Seed — the zip archive users
|
|
5
|
+
import into, and export from, the platform's seed registry (docker analogy:
|
|
6
|
+
the pushed image). At the archive root it must contain:
|
|
7
|
+
|
|
8
|
+
- ``manifest.json`` — a :class:`SeedPackageManifest`: the seed node's options
|
|
9
|
+
schema (drives UI widgets), the price, and environment-variable rules.
|
|
10
|
+
- ``pyproject.toml`` — packaging metadata (name, description, authors,
|
|
11
|
+
dependencies) and the ``charms.seeds`` entry point.
|
|
12
|
+
- ``README.md`` — documentation rendered in the catalog.
|
|
13
|
+
- the Python code (conventionally ``src/<package>/``) implementing the Seed.
|
|
14
|
+
|
|
15
|
+
``load_seed_package`` validates all of that without importing any seed code.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import zipfile
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from enum import Enum
|
|
24
|
+
|
|
25
|
+
import tomllib
|
|
26
|
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
|
27
|
+
|
|
28
|
+
from charms_core.seed import SeedResources
|
|
29
|
+
from charms_core.types import SeedPackageError
|
|
30
|
+
|
|
31
|
+
MANIFEST_FILENAME = "manifest.json"
|
|
32
|
+
PYPROJECT_FILENAME = "pyproject.toml"
|
|
33
|
+
README_FILENAME = "README.md"
|
|
34
|
+
SEED_ENTRY_POINT_GROUP = "charms.seeds"
|
|
35
|
+
|
|
36
|
+
_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
37
|
+
_ENV_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OptionType(str, Enum):
|
|
41
|
+
"""
|
|
42
|
+
The value type of a seed-node option; determines how the option renders
|
|
43
|
+
in the UI (boolean → switch, enum → dropdown, integer/float → number
|
|
44
|
+
input with min/max, char → length-limited text input, …).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
TEXT = "text"
|
|
48
|
+
BOOLEAN = "boolean"
|
|
49
|
+
INTEGER = "integer"
|
|
50
|
+
FLOAT = "float"
|
|
51
|
+
IMAGE = "image"
|
|
52
|
+
AUDIO = "audio"
|
|
53
|
+
VIDEO = "video"
|
|
54
|
+
CHAR = "char" # length-limited string; requires `length`
|
|
55
|
+
ENUM = "enum" # predefined list of choices; requires `choices`
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SeedOption(BaseModel):
|
|
59
|
+
"""One entry of the seed node's options schema (mirrors a Config field)."""
|
|
60
|
+
|
|
61
|
+
name: str = Field(description="snake_case option name, matching the Config model field")
|
|
62
|
+
type: OptionType
|
|
63
|
+
label: str = ""
|
|
64
|
+
description: str = ""
|
|
65
|
+
required: bool = False
|
|
66
|
+
default: str | int | float | bool | None = None
|
|
67
|
+
min: float | None = Field(default=None, description="integer/float only")
|
|
68
|
+
max: float | None = Field(default=None, description="integer/float only")
|
|
69
|
+
length: int | None = Field(default=None, ge=1, description="char only: maximum length")
|
|
70
|
+
choices: list[str] | None = Field(default=None, description="enum only: allowed values")
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def _check_constraints(self) -> "SeedOption":
|
|
74
|
+
if not _ID_RE.match(self.name):
|
|
75
|
+
raise ValueError(f"option name {self.name!r} must be snake_case")
|
|
76
|
+
numeric = self.type in (OptionType.INTEGER, OptionType.FLOAT)
|
|
77
|
+
if (self.min is not None or self.max is not None) and not numeric:
|
|
78
|
+
raise ValueError(f"option {self.name!r}: min/max only apply to integer/float")
|
|
79
|
+
if self.min is not None and self.max is not None and self.min > self.max:
|
|
80
|
+
raise ValueError(f"option {self.name!r}: min must be <= max")
|
|
81
|
+
if self.type is OptionType.CHAR and self.length is None:
|
|
82
|
+
raise ValueError(f"option {self.name!r}: char options require `length`")
|
|
83
|
+
if self.length is not None and self.type is not OptionType.CHAR:
|
|
84
|
+
raise ValueError(f"option {self.name!r}: `length` only applies to char")
|
|
85
|
+
if self.type is OptionType.ENUM and not self.choices:
|
|
86
|
+
raise ValueError(f"option {self.name!r}: enum options require non-empty `choices`")
|
|
87
|
+
if self.choices is not None and self.type is not OptionType.ENUM:
|
|
88
|
+
raise ValueError(f"option {self.name!r}: `choices` only applies to enum")
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PriceUnit(str, Enum):
|
|
93
|
+
"""
|
|
94
|
+
How the seed's price is denominated. Billing currently meters
|
|
95
|
+
``mana_per_second`` (compute time); other units are stored and shown in
|
|
96
|
+
the catalog so seeds can declare them ahead of metering support.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
MANA_PER_SECOND = "mana_per_second"
|
|
100
|
+
MANA_PER_TOKEN = "mana_per_token"
|
|
101
|
+
MANA_PER_REQUEST = "mana_per_request"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SeedPrice(BaseModel):
|
|
105
|
+
"""What running this seed costs."""
|
|
106
|
+
|
|
107
|
+
value: float = Field(default=1.0, ge=0)
|
|
108
|
+
unit: PriceUnit = PriceUnit.MANA_PER_SECOND
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class EnvVarSpec(BaseModel):
|
|
112
|
+
"""
|
|
113
|
+
An environment variable the seed reads, with validation rules the
|
|
114
|
+
launcher enforces before starting a Rune.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
name: str = Field(description="UPPER_SNAKE_CASE variable name")
|
|
118
|
+
description: str = ""
|
|
119
|
+
required: bool = False
|
|
120
|
+
secret: bool = False # UI renders the value masked
|
|
121
|
+
default: str | None = None
|
|
122
|
+
pattern: str | None = Field(default=None, description="regex the value must fully match")
|
|
123
|
+
|
|
124
|
+
@model_validator(mode="after")
|
|
125
|
+
def _check(self) -> "EnvVarSpec":
|
|
126
|
+
if not _ENV_NAME_RE.match(self.name):
|
|
127
|
+
raise ValueError(f"environment variable name {self.name!r} must be UPPER_SNAKE_CASE")
|
|
128
|
+
if self.pattern is not None:
|
|
129
|
+
try:
|
|
130
|
+
re.compile(self.pattern)
|
|
131
|
+
except re.error as exc:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"environment variable {self.name!r}: invalid pattern: {exc}"
|
|
134
|
+
) from exc
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SeedInstallSpec(BaseModel):
|
|
139
|
+
"""
|
|
140
|
+
Package-index configuration for installing the seed's dependencies into
|
|
141
|
+
its isolated environment. ``index_packages`` pins named requirements to a
|
|
142
|
+
specific index, installed in their own exclusive step — the reliable way
|
|
143
|
+
to get CUDA torch builds (``{"torch":
|
|
144
|
+
"https://download.pytorch.org/whl/cu128"}``): with a mere extra index,
|
|
145
|
+
a newer PyPI release outranks the CUDA wheels.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
index_url: str | None = Field(default=None, description="replaces PyPI entirely")
|
|
149
|
+
extra_index_urls: list[str] = Field(
|
|
150
|
+
default_factory=list, description="searched in addition to PyPI"
|
|
151
|
+
)
|
|
152
|
+
index_packages: dict[str, str] = Field(
|
|
153
|
+
default_factory=dict,
|
|
154
|
+
description="package name → index URL; installed first, from that index only",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@model_validator(mode="after")
|
|
158
|
+
def _check(self) -> "SeedInstallSpec":
|
|
159
|
+
for url in [self.index_url, *self.extra_index_urls, *self.index_packages.values()]:
|
|
160
|
+
if url is not None and not url.startswith(("http://", "https://")):
|
|
161
|
+
raise ValueError(f"package index {url!r} must be an http(s) URL")
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class SeedPackageManifest(BaseModel):
|
|
166
|
+
"""The ``manifest.json`` document at the root of every seed package."""
|
|
167
|
+
|
|
168
|
+
schema_version: int = 1
|
|
169
|
+
id: str = Field(description="snake_case, globally unique, e.g. 'echo', 'whisper'")
|
|
170
|
+
name: str
|
|
171
|
+
version: str = "0.1.0"
|
|
172
|
+
description: str = ""
|
|
173
|
+
supports_dispatch: bool = True
|
|
174
|
+
supports_streaming: bool = False
|
|
175
|
+
options: list[SeedOption] = Field(default_factory=list)
|
|
176
|
+
price: SeedPrice = Field(default_factory=SeedPrice)
|
|
177
|
+
environment: list[EnvVarSpec] = Field(default_factory=list)
|
|
178
|
+
resources: SeedResources = Field(default_factory=SeedResources)
|
|
179
|
+
install: SeedInstallSpec = Field(default_factory=SeedInstallSpec)
|
|
180
|
+
|
|
181
|
+
@model_validator(mode="after")
|
|
182
|
+
def _check(self) -> "SeedPackageManifest":
|
|
183
|
+
if self.schema_version != 1:
|
|
184
|
+
raise ValueError(f"unsupported manifest schema_version {self.schema_version}")
|
|
185
|
+
if not _ID_RE.match(self.id):
|
|
186
|
+
raise ValueError(f"seed id {self.id!r} must be snake_case")
|
|
187
|
+
names = [option.name for option in self.options]
|
|
188
|
+
if len(names) != len(set(names)):
|
|
189
|
+
raise ValueError("option names must be unique")
|
|
190
|
+
env_names = [spec.name for spec in self.environment]
|
|
191
|
+
if len(env_names) != len(set(env_names)):
|
|
192
|
+
raise ValueError("environment variable names must be unique")
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class PyprojectInfo(BaseModel):
|
|
197
|
+
"""Packaging metadata extracted from the package's ``pyproject.toml``."""
|
|
198
|
+
|
|
199
|
+
name: str
|
|
200
|
+
version: str
|
|
201
|
+
description: str = ""
|
|
202
|
+
authors: list[str] = Field(default_factory=list)
|
|
203
|
+
dependencies: list[str] = Field(default_factory=list)
|
|
204
|
+
entry_point: str | None = None # "<module>:<Class>" for the charms.seeds entry
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class SeedPackage(BaseModel):
|
|
208
|
+
"""A parsed, validated seed package (metadata only — never executes code)."""
|
|
209
|
+
|
|
210
|
+
manifest: SeedPackageManifest
|
|
211
|
+
pyproject: PyprojectInfo
|
|
212
|
+
readme: str
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validate_environment(manifest: SeedPackageManifest, env: Mapping[str, str]) -> list[str]:
|
|
216
|
+
"""
|
|
217
|
+
Check *env* against the manifest's environment rules; returns a list of
|
|
218
|
+
human-readable problems (empty when everything passes).
|
|
219
|
+
"""
|
|
220
|
+
problems: list[str] = []
|
|
221
|
+
for spec in manifest.environment:
|
|
222
|
+
value = env.get(spec.name, spec.default)
|
|
223
|
+
if value is None or value == "":
|
|
224
|
+
if spec.required:
|
|
225
|
+
hint = f": {spec.description}" if spec.description else ""
|
|
226
|
+
problems.append(f"{spec.name} is required{hint}")
|
|
227
|
+
continue
|
|
228
|
+
if spec.pattern is not None and re.fullmatch(spec.pattern, value) is None:
|
|
229
|
+
problems.append(f"{spec.name} does not match pattern {spec.pattern!r}")
|
|
230
|
+
return problems
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_pyproject(text: str, manifest: SeedPackageManifest) -> PyprojectInfo:
|
|
234
|
+
try:
|
|
235
|
+
document = tomllib.loads(text)
|
|
236
|
+
except tomllib.TOMLDecodeError as exc:
|
|
237
|
+
raise SeedPackageError(f"pyproject.toml is not valid TOML: {exc}") from exc
|
|
238
|
+
project = document.get("project")
|
|
239
|
+
if not isinstance(project, dict):
|
|
240
|
+
raise SeedPackageError("pyproject.toml has no [project] table")
|
|
241
|
+
name = project.get("name")
|
|
242
|
+
version = project.get("version")
|
|
243
|
+
if not isinstance(name, str) or not isinstance(version, str):
|
|
244
|
+
raise SeedPackageError("pyproject.toml [project] must declare name and version")
|
|
245
|
+
authors: list[str] = []
|
|
246
|
+
for author in project.get("authors", []):
|
|
247
|
+
if isinstance(author, dict):
|
|
248
|
+
label = str(author.get("name", "")).strip()
|
|
249
|
+
email = str(author.get("email", "")).strip()
|
|
250
|
+
if label and email:
|
|
251
|
+
authors.append(f"{label} <{email}>")
|
|
252
|
+
elif label or email:
|
|
253
|
+
authors.append(label or email)
|
|
254
|
+
dependencies = [dep for dep in project.get("dependencies", []) if isinstance(dep, str)]
|
|
255
|
+
entry_points = project.get("entry-points", {})
|
|
256
|
+
seed_entries = (
|
|
257
|
+
entry_points.get(SEED_ENTRY_POINT_GROUP, {}) if isinstance(entry_points, dict) else {}
|
|
258
|
+
)
|
|
259
|
+
entry_point = seed_entries.get(manifest.id) if isinstance(seed_entries, dict) else None
|
|
260
|
+
if not isinstance(entry_point, str):
|
|
261
|
+
raise SeedPackageError(
|
|
262
|
+
f'pyproject.toml must declare [project.entry-points."{SEED_ENTRY_POINT_GROUP}"] '
|
|
263
|
+
f'{manifest.id} = "<module>:<SeedClass>"'
|
|
264
|
+
)
|
|
265
|
+
return PyprojectInfo(
|
|
266
|
+
name=name,
|
|
267
|
+
version=version,
|
|
268
|
+
description=str(project.get("description", "")),
|
|
269
|
+
authors=authors,
|
|
270
|
+
dependencies=dependencies,
|
|
271
|
+
entry_point=entry_point,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _archive_root(names: list[str]) -> str:
|
|
276
|
+
"""
|
|
277
|
+
Packages may be zipped either from inside the seed directory (files at the
|
|
278
|
+
archive root) or as the directory itself (one shared top-level folder).
|
|
279
|
+
Returns the prefix ("" or "<folder>/") under which the package files live.
|
|
280
|
+
"""
|
|
281
|
+
if MANIFEST_FILENAME in names:
|
|
282
|
+
return ""
|
|
283
|
+
tops = {name.split("/", 1)[0] for name in names if name.strip("/")}
|
|
284
|
+
if len(tops) == 1:
|
|
285
|
+
prefix = f"{next(iter(tops))}/"
|
|
286
|
+
if f"{prefix}{MANIFEST_FILENAME}" in names:
|
|
287
|
+
return prefix
|
|
288
|
+
raise SeedPackageError(f"{MANIFEST_FILENAME} is missing from the package root")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _read_text(archive: zipfile.ZipFile, name: str) -> str:
|
|
292
|
+
try:
|
|
293
|
+
data = archive.read(name)
|
|
294
|
+
except KeyError:
|
|
295
|
+
raise SeedPackageError(f"{name} is missing from the package") from None
|
|
296
|
+
try:
|
|
297
|
+
return data.decode("utf-8")
|
|
298
|
+
except UnicodeDecodeError as exc:
|
|
299
|
+
raise SeedPackageError(f"{name} is not valid UTF-8") from exc
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def load_seed_package(data: bytes) -> SeedPackage:
|
|
303
|
+
"""
|
|
304
|
+
Parse and validate a seed package archive. Raises :class:`SeedPackageError`
|
|
305
|
+
with a user-facing message on any structural problem. Never imports or
|
|
306
|
+
executes packaged code.
|
|
307
|
+
"""
|
|
308
|
+
try:
|
|
309
|
+
archive = zipfile.ZipFile(io.BytesIO(data))
|
|
310
|
+
except zipfile.BadZipFile as exc:
|
|
311
|
+
raise SeedPackageError("not a valid zip archive") from exc
|
|
312
|
+
with archive:
|
|
313
|
+
names = archive.namelist()
|
|
314
|
+
for member in names:
|
|
315
|
+
normalized = member.replace("\\", "/")
|
|
316
|
+
if normalized.startswith("/") or ".." in normalized.split("/"):
|
|
317
|
+
raise SeedPackageError(f"unsafe path in archive: {member!r}")
|
|
318
|
+
root = _archive_root([name.replace("\\", "/") for name in names])
|
|
319
|
+
|
|
320
|
+
raw_manifest = _read_text(archive, f"{root}{MANIFEST_FILENAME}")
|
|
321
|
+
try:
|
|
322
|
+
manifest = SeedPackageManifest.model_validate(json.loads(raw_manifest))
|
|
323
|
+
except json.JSONDecodeError as exc:
|
|
324
|
+
raise SeedPackageError(f"{MANIFEST_FILENAME} is not valid JSON: {exc}") from exc
|
|
325
|
+
except ValidationError as exc:
|
|
326
|
+
first = exc.errors()[0]
|
|
327
|
+
location = ".".join(str(part) for part in first["loc"])
|
|
328
|
+
detail = f" ({location})" if location else ""
|
|
329
|
+
raise SeedPackageError(
|
|
330
|
+
f"{MANIFEST_FILENAME} is invalid{detail}: {first['msg']}"
|
|
331
|
+
) from exc
|
|
332
|
+
|
|
333
|
+
pyproject = _parse_pyproject(_read_text(archive, f"{root}{PYPROJECT_FILENAME}"), manifest)
|
|
334
|
+
if pyproject.version != manifest.version:
|
|
335
|
+
raise SeedPackageError(
|
|
336
|
+
f"version mismatch: manifest.json says {manifest.version}, "
|
|
337
|
+
f"pyproject.toml says {pyproject.version}"
|
|
338
|
+
)
|
|
339
|
+
readme = _read_text(archive, f"{root}{README_FILENAME}")
|
|
340
|
+
has_python = any(
|
|
341
|
+
name.replace("\\", "/").startswith(root) and name.endswith(".py") for name in names
|
|
342
|
+
)
|
|
343
|
+
if not has_python:
|
|
344
|
+
raise SeedPackageError("the package contains no Python code (*.py)")
|
|
345
|
+
return SeedPackage(manifest=manifest, pyproject=pyproject, readme=readme)
|