iowap-node 2.3.20__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,520 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Node-side capability data model.
4
+
5
+ Nodes define their capabilities locally, typically in a YAML file
6
+ (e.g. nodes/worker/capabilities.yaml). This module loads those definitions
7
+ and maps them to the schema expected by the relay server's API.
8
+
9
+ This model is used for:
10
+ - Loading capability definitions from YAML config files
11
+ - Serializing capabilities for registration and heartbeat payloads
12
+ - Schema validation BEFORE sending to the server (fail fast locally)
13
+
14
+ The server has its own copy in src/relay_server/models/capability.py for
15
+ inbound validation. The two models are intentionally separate: the node
16
+ owns the definition, the server only mediates and routes.
17
+ The ``node_config.py`` module bridges this gap by loading YAML and
18
+ mapping to the server-expected JSON schema.
19
+
20
+ Provided API:
21
+
22
+ * ``CapabilityType`` - Enum of available capability types.
23
+ * ``CapabilityInputField`` - Description of a single input field.
24
+ * ``CapabilityInputSchema`` - Schema over all input fields with
25
+ payload validation.
26
+ * ``Capability`` - A single capability of a node.
27
+ * ``CapabilitySet`` - Management of a node's full
28
+ capability inventory.
29
+ * ``load_capabilities_from_yaml`` - Loads a CapabilitySet from YAML.
30
+ * ``diff_capabilities`` - Compares two CapabilitySets.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import copy
36
+ from dataclasses import asdict, dataclass, field
37
+ from enum import Enum
38
+ from pathlib import Path
39
+ from typing import Any, Optional
40
+
41
+ import yaml
42
+
43
+
44
+ class CapabilityType(str, Enum):
45
+ """Type of a capability - determines routing in the scheduler."""
46
+
47
+ AI = "ai"
48
+ TOOL = "tool"
49
+ SCRIPT = "script"
50
+ WORKFLOW = "workflow"
51
+ RESOURCE = "resource"
52
+
53
+ def __str__(self) -> str:
54
+ """Return the string value of this enum member."""
55
+ return self.value
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Input Schema
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ @dataclass
64
+ class CapabilityInputField:
65
+ """
66
+ Describes a single input field of a capability.
67
+
68
+ Attributes:
69
+ name: Identifier of the field.
70
+ type: Type identifier as string (e.g. ``"string"``,
71
+ ``"integer"``, ``"number"``, ``"boolean"``,
72
+ ``"object"``, ``"array"``). Default is ``"string"``.
73
+ required: Whether the field is mandatory.
74
+ default: Default value used when the field is missing from
75
+ the payload.
76
+ enum: Optional list of allowed values.
77
+ ge: Optional numeric lower bound (>=).
78
+ le: Optional numeric upper bound (<=).
79
+ description: Human-readable description of the field.
80
+ """
81
+
82
+ name: str
83
+ type: str = "string"
84
+ required: bool = False
85
+ default: Any = None
86
+ enum: Optional[list[Any]] = None
87
+ ge: Optional[float] = None
88
+ le: Optional[float] = None
89
+ description: str = ""
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ """Serialize this field to a dictionary."""
93
+ d: dict[str, Any] = {
94
+ "name": self.name,
95
+ "type": self.type,
96
+ "required": self.required,
97
+ "default": self.default,
98
+ "enum": self.enum,
99
+ "ge": self.ge,
100
+ "le": self.le,
101
+ "description": self.description,
102
+ }
103
+ return d
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: dict[str, Any]) -> CapabilityInputField:
107
+ """Create a field from a dictionary."""
108
+ return cls(
109
+ name=data["name"],
110
+ type=data.get("type", "string"),
111
+ required=data.get("required", False),
112
+ default=data.get("default", None),
113
+ enum=data.get("enum"),
114
+ ge=data.get("ge"),
115
+ le=data.get("le"),
116
+ description=data.get("description", ""),
117
+ )
118
+
119
+ def validate(self, value: Any) -> list[str]:
120
+ """
121
+ Validate a single value against this field schema.
122
+
123
+ Returns:
124
+ List of error messages (empty if the value is valid).
125
+ """
126
+ errors: list[str] = []
127
+
128
+ if value is None:
129
+ if self.required and self.default is None:
130
+ errors.append(f"Field '{self.name}' is required.")
131
+ return errors
132
+
133
+ # Enum check
134
+ if self.enum is not None and value not in self.enum:
135
+ errors.append(
136
+ f"Field '{self.name}': value {value!r} is not in "
137
+ f"allowed values {self.enum!r}."
138
+ )
139
+
140
+ # Numeric bounds
141
+ if self.ge is not None or self.le is not None:
142
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
143
+ errors.append(
144
+ f"Field '{self.name}': value {value!r} must be numeric "
145
+ f"for ge/le validation."
146
+ )
147
+ else:
148
+ if self.ge is not None and value < self.ge:
149
+ errors.append(
150
+ f"Field '{self.name}': value {value!r} must >= {self.ge}."
151
+ )
152
+ if self.le is not None and value > self.le:
153
+ errors.append(
154
+ f"Field '{self.name}': value {value!r} must <= {self.le}."
155
+ )
156
+
157
+ return errors
158
+
159
+
160
+ @dataclass
161
+ class CapabilityInputSchema:
162
+ """
163
+ Schema over all input fields of a capability.
164
+
165
+ Attributes:
166
+ fields: Mapping of field name to the corresponding \
167
+ ``CapabilityInputField``.
168
+ """
169
+
170
+ fields: dict[str, CapabilityInputField] = field(default_factory=dict)
171
+
172
+ def to_dict(self) -> dict[str, Any]:
173
+ """Serialize this schema to a dictionary."""
174
+ return {
175
+ "fields": {name: f.to_dict() for name, f in self.fields.items()}
176
+ }
177
+
178
+ @classmethod
179
+ def from_dict(cls, data: dict[str, Any]) -> CapabilityInputSchema:
180
+ """Create a schema from a dictionary."""
181
+ if not data:
182
+ return cls()
183
+ raw_fields = data.get("fields", data) if isinstance(data, dict) else {}
184
+ fields: dict[str, CapabilityInputField] = {}
185
+ for raw in raw_fields.values() if isinstance(raw_fields, dict) else raw_fields:
186
+ fld = CapabilityInputField.from_dict(raw)
187
+ fields[fld.name] = fld
188
+ return cls(fields=fields)
189
+
190
+ def validate_payload(self, payload: dict[str, Any]) -> tuple[bool, list[str]]:
191
+ """
192
+ Validate a payload against this schema.
193
+
194
+ Rules checked:
195
+ * All fields marked ``required`` must be present.
196
+ * Fields without a value receive their ``default``.
197
+ * Enum constraints are enforced.
198
+ * Numeric bounds (``ge`` / ``le``) are enforced.
199
+ * Unexpected fields not defined in the schema are reported as
200
+ errors.
201
+
202
+ Returns:
203
+ Tuple ``(is_valid, error_messages)``.
204
+ """
205
+ errors: list[str] = []
206
+ if not isinstance(payload, dict):
207
+ return False, ["Payload must be a dictionary."]
208
+
209
+ # Check for unexpected fields
210
+ for key in payload:
211
+ if key not in self.fields:
212
+ errors.append(f"Unexpected field '{key}' in payload.")
213
+
214
+ # Check expected fields
215
+ for name, fld in self.fields.items():
216
+ if name not in payload or payload[name] is None:
217
+ if fld.required and fld.default is None:
218
+ errors.append(f"Field '{name}' is required.")
219
+ continue
220
+ # default is not written back into the payload -
221
+ # validation uses the default if present.
222
+ value = fld.default
223
+ else:
224
+ value = payload[name]
225
+
226
+ if value is None:
227
+ continue
228
+
229
+ errors.extend(fld.validate(value))
230
+ return (len(errors) == 0), errors
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Capability
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ @dataclass
239
+ class Capability:
240
+ """
241
+ A single capability of a worker node.
242
+
243
+ Attributes:
244
+ name: Unique identifier of the capability.
245
+ type: Type of capability (see ``CapabilityType``).
246
+ description: Human-readable description.
247
+ version: Semantic version of the capability.
248
+ available: Whether the capability is currently usable.
249
+ input_schema: Optional input schema for validating call payloads.
250
+ config: Free-form configuration values of the capability.
251
+ metadata: Free-form metadata of the capability.
252
+ """
253
+
254
+ name: str
255
+ type: CapabilityType
256
+ description: str = ""
257
+ version: str = "1.0.0"
258
+ available: bool = True
259
+ input_schema: Optional[CapabilityInputSchema] = None
260
+ config: dict[str, Any] = field(default_factory=dict)
261
+ metadata: dict[str, Any] = field(default_factory=dict)
262
+ # T-004 (iowap-flow): dot-paths into the task result this capability
263
+ # produces. T-005c (Envelope-Contract, design.md T-005a section 4.2):
264
+ # paths are RELATIVE TO THE INNER result of the handler result envelope
265
+ # ({"status": "completed", "result": X}) — e.g. ["answer"], NOT
266
+ # ["result.answer"]: flow unwraps the envelope exactly once, so the
267
+ # template ${ref.result.<hint>} navigates the unwrapped object directly.
268
+ # Pure metadata — consumers (flow planner) use it for template paths;
269
+ # routing ignores it.
270
+ result_path_hints: Optional[list[str]] = None
271
+
272
+ def to_dict(self) -> dict[str, Any]:
273
+ """Serialize this capability to a dictionary."""
274
+ d: dict[str, Any] = asdict(self)
275
+ d["type"] = self.type.value if isinstance(self.type, CapabilityType) else self.type
276
+ if self.input_schema is not None:
277
+ d["input_schema"] = self.input_schema.to_dict()
278
+ else:
279
+ d["input_schema"] = None
280
+ return d
281
+
282
+ @classmethod
283
+ def from_dict(cls, data: dict[str, Any]) -> Capability:
284
+ """Create a capability from a dictionary."""
285
+ raw_type = data.get("type", "tool")
286
+ raw_schema = data.get("input_schema")
287
+ input_schema: Optional[CapabilityInputSchema] = None
288
+ if raw_schema:
289
+ input_schema = CapabilityInputSchema.from_dict(raw_schema)
290
+ return cls(
291
+ name=data["name"],
292
+ type=CapabilityType(raw_type) if isinstance(raw_type, str) else raw_type,
293
+ description=data.get("description", ""),
294
+ version=data.get("version", "1.0.0"),
295
+ available=data.get("available", True),
296
+ input_schema=input_schema,
297
+ config=data.get("config", {}) or {},
298
+ metadata=data.get("metadata", {}) or {},
299
+ result_path_hints=data.get("result_path_hints"),
300
+ )
301
+
302
+ def merge(self, other: Capability) -> Capability:
303
+ """
304
+ Merge fields from ``other`` into this capability.
305
+
306
+ Fields that are None or empty in ``other`` are skipped.
307
+ ``name`` and ``type`` are always taken from ``self``.
308
+ ``config`` and ``metadata`` are merged (values from ``other``
309
+ override same-named keys from ``self``).
310
+ """
311
+ merged = copy.deepcopy(self)
312
+ if other.description:
313
+ merged.description = other.description
314
+ if other.version:
315
+ merged.version = other.version
316
+ # ``available`` is also taken when False - otherwise there
317
+ # would be no way to intentionally disable a capability.
318
+ merged.available = other.available
319
+ if other.input_schema is not None:
320
+ merged.input_schema = copy.deepcopy(other.input_schema)
321
+ merged.config = {**merged.config, **(other.config or {})}
322
+ merged.metadata = {**merged.metadata, **(other.metadata or {})}
323
+ if other.result_path_hints:
324
+ merged.result_path_hints = list(other.result_path_hints)
325
+ return merged
326
+
327
+ def patch_config(self, updates: dict[str, Any]) -> None:
328
+ """Patch individual config values without replacing the entire config."""
329
+ self.config.update(updates)
330
+
331
+ def matches(
332
+ self,
333
+ name: Optional[str] = None,
334
+ ctype: Optional[CapabilityType | str] = None,
335
+ available_only: bool = True,
336
+ ) -> bool:
337
+ """Check if this capability matches the given filter criteria."""
338
+ if name is not None and self.name != name:
339
+ return False
340
+ if ctype is not None:
341
+ check = ctype.value if isinstance(ctype, CapabilityType) else str(ctype)
342
+ self_val = self.type.value if isinstance(self.type, CapabilityType) else str(self.type)
343
+ if self_val != check:
344
+ return False
345
+ if available_only and not self.available:
346
+ return False
347
+ return True
348
+
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # CapabilitySet
352
+ # ---------------------------------------------------------------------------
353
+
354
+
355
+ @dataclass
356
+ class CapabilitySet:
357
+ """
358
+ Manage the full inventory of capabilities for a node.
359
+
360
+ Attributes:
361
+ _caps: Internal mapping of capability name to ``Capability``.
362
+ """
363
+
364
+ _caps: dict[str, Capability] = field(default_factory=dict)
365
+
366
+ def __post_init__(self) -> None:
367
+ """Ensure ``_caps`` is a dictionary."""
368
+ if self._caps is None:
369
+ self._caps = {}
370
+
371
+ # ---- Registration ----------------------------------------------------
372
+
373
+ def register(self, cap: Capability) -> None:
374
+ """Register a capability. Existing entries are overwritten."""
375
+ self._caps[cap.name] = cap
376
+
377
+ def deregister(self, name: str) -> bool:
378
+ """Remove a capability by name. Returns True if removed."""
379
+ if name in self._caps:
380
+ del self._caps[name]
381
+ return True
382
+ return False
383
+
384
+ # ---- Access -----------------------------------------------------------
385
+
386
+ def get(self, name: str) -> Optional[Capability]:
387
+ """Get the capability with the given name or ``None``."""
388
+ return self._caps.get(name)
389
+
390
+ def update(self, name: str, **kwargs: Any) -> Optional[Capability]:
391
+ """Update individual fields of a registered capability."""
392
+ cap = self._caps.get(name)
393
+ if cap is None:
394
+ return None
395
+ for key in ("description", "version", "available", "input_schema",
396
+ "config", "metadata", "result_path_hints"):
397
+ if key in kwargs and kwargs[key] is not None:
398
+ setattr(cap, key, kwargs[key])
399
+ return cap
400
+
401
+ def filter(
402
+ self,
403
+ name: Optional[str] = None,
404
+ ctype: Optional[CapabilityType | str] = None,
405
+ available_only: bool = True,
406
+ ) -> list[Capability]:
407
+ """Return all capabilities matching the filter criteria."""
408
+ return [
409
+ c for c in self._caps.values()
410
+ if c.matches(name, ctype, available_only)
411
+ ]
412
+
413
+ @property
414
+ def names(self) -> list[str]:
415
+ """Return the list of all registered capability names."""
416
+ return list(self._caps.keys())
417
+
418
+ # ---- Serialization ----------------------------------------------------
419
+
420
+ def to_list(self) -> list[dict[str, Any]]:
421
+ """Serialize all capabilities to a list of dictionaries."""
422
+ return [c.to_dict() for c in self._caps.values()]
423
+
424
+ @classmethod
425
+ def from_list(cls, data: list[dict[str, Any]]) -> CapabilitySet:
426
+ """Create a CapabilitySet from a list of dictionaries."""
427
+ return cls(
428
+ _caps={c["name"]: Capability.from_dict(c) for c in data}
429
+ )
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # YAML Loader
434
+ # ---------------------------------------------------------------------------
435
+
436
+
437
+ def load_capabilities_from_yaml(path: Path) -> CapabilitySet:
438
+ """
439
+ Load capabilities from a YAML file.
440
+
441
+ Expected YAML structure::
442
+
443
+ capabilities:
444
+ - name: vault
445
+ type: tool
446
+ description: "..."
447
+ version: "1.2.0"
448
+ available: true
449
+ input_schema:
450
+ fields:
451
+ query:
452
+ name: query
453
+ type: string
454
+ required: true
455
+ config: {}
456
+ metadata: {}
457
+
458
+ If the file does not exist, an empty ``CapabilitySet`` is returned.
459
+ """
460
+ path = Path(path)
461
+ if not path.exists():
462
+ return CapabilitySet()
463
+
464
+ with open(path, encoding="utf-8") as f:
465
+ raw = yaml.safe_load(f) or {}
466
+
467
+ raw_caps = raw.get("capabilities", []) if isinstance(raw, dict) else []
468
+ caps: dict[str, Capability] = {}
469
+ for entry in raw_caps:
470
+ if not isinstance(entry, dict) or "name" not in entry:
471
+ continue
472
+ cap = Capability.from_dict(entry)
473
+ caps[cap.name] = cap
474
+ return CapabilitySet(_caps=caps)
475
+
476
+
477
+ # ---------------------------------------------------------------------------
478
+ # Diff Function
479
+ # ---------------------------------------------------------------------------
480
+
481
+
482
+ def diff_capabilities(old: CapabilitySet, new: CapabilitySet) -> dict[str, Any]:
483
+ """
484
+ Compare two CapabilitySets and return the differences.
485
+
486
+ Return structure::
487
+
488
+ {
489
+ "added": [<Capability.to_dict>, ...],
490
+ "removed": [<name:str>, ...],
491
+ "changed": [
492
+ {"name": <str>, "old": <dict>, "new": <dict>},
493
+ ...
494
+ ],
495
+ }
496
+ """
497
+ result: dict[str, Any] = {"added": [], "removed": [], "changed": []}
498
+ old_names = set(old.names)
499
+ new_names = set(new.names)
500
+
501
+ for name in sorted(new_names - old_names):
502
+ cap = new.get(name)
503
+ if cap is not None:
504
+ result["added"].append(cap.to_dict())
505
+
506
+ for name in sorted(old_names - new_names):
507
+ result["removed"].append(name)
508
+
509
+ for name in sorted(old_names & new_names):
510
+ old_cap = old.get(name)
511
+ new_cap = new.get(name)
512
+ if old_cap is not None and new_cap is not None:
513
+ if old_cap.to_dict() != new_cap.to_dict():
514
+ result["changed"].append({
515
+ "name": name,
516
+ "old": old_cap.to_dict(),
517
+ "new": new_cap.to_dict(),
518
+ })
519
+
520
+ return result
File without changes
@@ -0,0 +1,42 @@
1
+ """CLI artifact subcommands — download / upload (T-117 split).
2
+
3
+ Handler signatures are plain ``(client, args) -> int``; the
4
+ ``node_cli.with_client`` decorator is applied at parser-registration time
5
+ in the facade.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from nodes.common.relay_client import RelayClient
15
+
16
+
17
+ def _cmd_artifact_download(client: RelayClient, args) -> int:
18
+ target = client.download_artifact(args.artifact_id, output_path=args.output)
19
+ size = target.stat().st_size if target.exists() else 0
20
+ if args.json:
21
+ print(json.dumps({"path": str(target), "size_bytes": size}, default=str))
22
+ return 0
23
+ print(f"Downloaded {size} bytes to {target}")
24
+ return 0
25
+
26
+
27
+ def _cmd_artifact_upload(client: RelayClient, args) -> int:
28
+ file_path = Path(args.file)
29
+ if not file_path.exists():
30
+ print(f"file not found: {file_path}", file=sys.stderr)
31
+ return 2
32
+ result = client.upload_artifact(
33
+ file_path,
34
+ name=args.name,
35
+ task_id=args.task_id,
36
+ stage_id=args.stage_id,
37
+ )
38
+ if args.json:
39
+ print(json.dumps(result, default=str))
40
+ return 0
41
+ print(json.dumps(result, indent=2, default=str))
42
+ return 0