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.
@@ -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)