charms-core 0.1.0__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.
- charms_core-0.1.0/.gitignore +28 -0
- charms_core-0.1.0/PKG-INFO +78 -0
- charms_core-0.1.0/README.md +61 -0
- charms_core-0.1.0/pyproject.toml +31 -0
- charms_core-0.1.0/src/charms_core/__init__.py +112 -0
- charms_core-0.1.0/src/charms_core/chunk.py +81 -0
- charms_core-0.1.0/src/charms_core/node.py +91 -0
- charms_core-0.1.0/src/charms_core/package.py +345 -0
- charms_core-0.1.0/src/charms_core/protocol.py +257 -0
- charms_core-0.1.0/src/charms_core/py.typed +0 -0
- charms_core-0.1.0/src/charms_core/recipe.py +402 -0
- charms_core-0.1.0/src/charms_core/seed.py +162 -0
- charms_core-0.1.0/src/charms_core/types.py +106 -0
- charms_core-0.1.0/tests/test_chunk.py +87 -0
- charms_core-0.1.0/tests/test_node.py +90 -0
- charms_core-0.1.0/tests/test_package.py +231 -0
- charms_core-0.1.0/tests/test_protocol.py +106 -0
- charms_core-0.1.0/tests/test_recipe.py +246 -0
- charms_core-0.1.0/tests/test_seed.py +68 -0
- charms_core-0.1.0/tests/test_types.py +54 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.venv/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.pytest_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
|
|
12
|
+
# Node
|
|
13
|
+
node_modules/
|
|
14
|
+
web/dist/
|
|
15
|
+
|
|
16
|
+
# Runtime state
|
|
17
|
+
*.db
|
|
18
|
+
*.db-journal
|
|
19
|
+
.env
|
|
20
|
+
logs/
|
|
21
|
+
.rune_pids
|
|
22
|
+
seed_store/
|
|
23
|
+
|
|
24
|
+
# Model artifacts / caches
|
|
25
|
+
seed-cache/
|
|
26
|
+
checkpoints/
|
|
27
|
+
*.db-shm
|
|
28
|
+
*.db-wal
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: charms-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Charms shared contracts: node & seed SDK, recipes, chunks, wire protocol, seed packages
|
|
5
|
+
Project-URL: Repository, https://github.com/ArioAtlas/charms-launcher
|
|
6
|
+
Author: ArioAtlas
|
|
7
|
+
Keywords: ai,charms,distributed-computing,sdk,workflow
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: pydantic>=2.7
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# charms-core
|
|
19
|
+
|
|
20
|
+
Shared contracts for the **Charms** platform — a community-powered AI
|
|
21
|
+
workflow system where users design **Charms** (node-graph **Recipes**) and
|
|
22
|
+
run them on a distributed fleet of **Runes**: instances of model-loading
|
|
23
|
+
**Seeds** contributed by users who share their compute and earn **Mana**.
|
|
24
|
+
|
|
25
|
+
This package is the dependency-light foundation both the Charms server and
|
|
26
|
+
the [charms-launcher](https://github.com/ArioAtlas/charms-launcher) build
|
|
27
|
+
on. Pure Pydantic v2 models plus stdlib — no web framework, no database, no
|
|
28
|
+
model runtimes.
|
|
29
|
+
|
|
30
|
+
## What's inside
|
|
31
|
+
|
|
32
|
+
| Module | Contents |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `charms_core.types` | Modalities, port schemas, node metadata, the `CharmsError` hierarchy |
|
|
35
|
+
| `charms_core.node` | `BasicNode` — the in-process glue-node SDK |
|
|
36
|
+
| `charms_core.seed` | `Seed` — the model-module SDK (`load()` / `run()` / streaming trio), manifests, descriptors |
|
|
37
|
+
| `charms_core.package` | The seed package format: `manifest.json` models (typed options, prices, env rules, install indexes) + zip validation |
|
|
38
|
+
| `charms_core.recipe` | Recipe graph models, validation, topological sort |
|
|
39
|
+
| `charms_core.chunk` | The streaming chunk model + binary framing codec |
|
|
40
|
+
| `charms_core.protocol` | Every WebSocket message on the launcher and realtime-client wires |
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install charms-core
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Python ≥ 3.12. Fully typed (`py.typed`).
|
|
49
|
+
|
|
50
|
+
## Building a Seed
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from pydantic import BaseModel
|
|
54
|
+
from charms_core.seed import Seed, SeedContext, SeedManifest
|
|
55
|
+
from charms_core.types import Modality, PortSchema
|
|
56
|
+
|
|
57
|
+
class EchoInput(BaseModel):
|
|
58
|
+
text: str
|
|
59
|
+
|
|
60
|
+
class EchoOutput(BaseModel):
|
|
61
|
+
text: str
|
|
62
|
+
|
|
63
|
+
class EchoSeed(Seed[EchoInput, EchoOutput, None]):
|
|
64
|
+
manifest = SeedManifest(id="echo", name="Echo")
|
|
65
|
+
inputs = [PortSchema(name="text", modality=Modality.TEXT)]
|
|
66
|
+
outputs = [PortSchema(name="text", modality=Modality.TEXT)]
|
|
67
|
+
input_model = EchoInput
|
|
68
|
+
output_model = EchoOutput
|
|
69
|
+
|
|
70
|
+
async def load(self, ctx: SeedContext) -> None: ...
|
|
71
|
+
|
|
72
|
+
async def run(self, input: EchoInput, config: None = None) -> EchoOutput:
|
|
73
|
+
return EchoOutput(text=input.text)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Package it (with a `manifest.json`, `pyproject.toml`, and `README.md`) and
|
|
77
|
+
import it into a Charms server's seed registry; contributors then pull and
|
|
78
|
+
run it as a Rune via the launcher.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# charms-core
|
|
2
|
+
|
|
3
|
+
Shared contracts for the **Charms** platform — a community-powered AI
|
|
4
|
+
workflow system where users design **Charms** (node-graph **Recipes**) and
|
|
5
|
+
run them on a distributed fleet of **Runes**: instances of model-loading
|
|
6
|
+
**Seeds** contributed by users who share their compute and earn **Mana**.
|
|
7
|
+
|
|
8
|
+
This package is the dependency-light foundation both the Charms server and
|
|
9
|
+
the [charms-launcher](https://github.com/ArioAtlas/charms-launcher) build
|
|
10
|
+
on. Pure Pydantic v2 models plus stdlib — no web framework, no database, no
|
|
11
|
+
model runtimes.
|
|
12
|
+
|
|
13
|
+
## What's inside
|
|
14
|
+
|
|
15
|
+
| Module | Contents |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `charms_core.types` | Modalities, port schemas, node metadata, the `CharmsError` hierarchy |
|
|
18
|
+
| `charms_core.node` | `BasicNode` — the in-process glue-node SDK |
|
|
19
|
+
| `charms_core.seed` | `Seed` — the model-module SDK (`load()` / `run()` / streaming trio), manifests, descriptors |
|
|
20
|
+
| `charms_core.package` | The seed package format: `manifest.json` models (typed options, prices, env rules, install indexes) + zip validation |
|
|
21
|
+
| `charms_core.recipe` | Recipe graph models, validation, topological sort |
|
|
22
|
+
| `charms_core.chunk` | The streaming chunk model + binary framing codec |
|
|
23
|
+
| `charms_core.protocol` | Every WebSocket message on the launcher and realtime-client wires |
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install charms-core
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Python ≥ 3.12. Fully typed (`py.typed`).
|
|
32
|
+
|
|
33
|
+
## Building a Seed
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from pydantic import BaseModel
|
|
37
|
+
from charms_core.seed import Seed, SeedContext, SeedManifest
|
|
38
|
+
from charms_core.types import Modality, PortSchema
|
|
39
|
+
|
|
40
|
+
class EchoInput(BaseModel):
|
|
41
|
+
text: str
|
|
42
|
+
|
|
43
|
+
class EchoOutput(BaseModel):
|
|
44
|
+
text: str
|
|
45
|
+
|
|
46
|
+
class EchoSeed(Seed[EchoInput, EchoOutput, None]):
|
|
47
|
+
manifest = SeedManifest(id="echo", name="Echo")
|
|
48
|
+
inputs = [PortSchema(name="text", modality=Modality.TEXT)]
|
|
49
|
+
outputs = [PortSchema(name="text", modality=Modality.TEXT)]
|
|
50
|
+
input_model = EchoInput
|
|
51
|
+
output_model = EchoOutput
|
|
52
|
+
|
|
53
|
+
async def load(self, ctx: SeedContext) -> None: ...
|
|
54
|
+
|
|
55
|
+
async def run(self, input: EchoInput, config: None = None) -> EchoOutput:
|
|
56
|
+
return EchoOutput(text=input.text)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Package it (with a `manifest.json`, `pyproject.toml`, and `README.md`) and
|
|
60
|
+
import it into a Charms server's seed registry; contributors then pull and
|
|
61
|
+
run it as a Rune via the launcher.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "charms-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Charms shared contracts: node & seed SDK, recipes, chunks, wire protocol, seed packages"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
authors = [{ name = "ArioAtlas" }]
|
|
8
|
+
keywords = ["charms", "ai", "workflow", "distributed-computing", "sdk"]
|
|
9
|
+
# NOTE: no license declared yet — decide one before the next release.
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"pydantic>=2.7",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
# The public home of these contracts (vendored into the launcher repo).
|
|
24
|
+
Repository = "https://github.com/ArioAtlas/charms-launcher"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/charms_core"]
|
|
@@ -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
|
+
]
|
|
@@ -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
|
|
@@ -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
|
+
)
|