aetherius 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. aetherius/__init__.py +42 -0
  2. aetherius/__main__.py +12 -0
  3. aetherius/_contracts/__init__.py +0 -0
  4. aetherius/_contracts/blueprint.schema.json +144 -0
  5. aetherius/acts/__init__.py +1 -0
  6. aetherius/acts/_shared.py +61 -0
  7. aetherius/acts/continuum/__init__.py +7 -0
  8. aetherius/acts/continuum/actions.py +149 -0
  9. aetherius/acts/continuum/bridge.py +159 -0
  10. aetherius/acts/continuum/browser.py +164 -0
  11. aetherius/acts/continuum/debug_overlay.py +65 -0
  12. aetherius/acts/continuum/driver.py +127 -0
  13. aetherius/acts/continuum/human_actions.py +74 -0
  14. aetherius/acts/oracle/__init__.py +1 -0
  15. aetherius/acts/oracle/driver.py +1 -0
  16. aetherius/acts/oracle/locator.py +1 -0
  17. aetherius/acts/oracle/model.py +1 -0
  18. aetherius/acts/oracle/perception.py +1 -0
  19. aetherius/acts/phantom/__init__.py +1 -0
  20. aetherius/acts/phantom/driver.py +1 -0
  21. aetherius/acts/phantom/loop.py +1 -0
  22. aetherius/acts/phantom/memory.py +1 -0
  23. aetherius/acts/phantom/perception.py +1 -0
  24. aetherius/acts/phantom/planner.py +1 -0
  25. aetherius/acts/vector/__init__.py +1 -0
  26. aetherius/acts/vector/auth.py +108 -0
  27. aetherius/acts/vector/client.py +129 -0
  28. aetherius/acts/vector/driver.py +128 -0
  29. aetherius/builder/__init__.py +40 -0
  30. aetherius/builder/catalog.py +93 -0
  31. aetherius/builder/factory.py +243 -0
  32. aetherius/builder/templates.py +150 -0
  33. aetherius/builder/validation.py +120 -0
  34. aetherius/cli.py +236 -0
  35. aetherius/config/__init__.py +1 -0
  36. aetherius/config/secrets.py +71 -0
  37. aetherius/config/settings.py +44 -0
  38. aetherius/console/__init__.py +1 -0
  39. aetherius/console/app.py +66 -0
  40. aetherius/console/console.tcss +97 -0
  41. aetherius/console/daemon_control.py +83 -0
  42. aetherius/console/run_bridge.py +43 -0
  43. aetherius/console/screens/__init__.py +1 -0
  44. aetherius/console/screens/_pending.py +38 -0
  45. aetherius/console/screens/builder/__init__.py +1 -0
  46. aetherius/console/screens/builder/act_picker.py +75 -0
  47. aetherius/console/screens/builder/io_editor.py +167 -0
  48. aetherius/console/screens/builder/options_editor.py +132 -0
  49. aetherius/console/screens/builder/preview.py +57 -0
  50. aetherius/console/screens/builder/screen.py +235 -0
  51. aetherius/console/screens/builder/step_editor.py +249 -0
  52. aetherius/console/screens/catalog.py +52 -0
  53. aetherius/console/screens/home.py +90 -0
  54. aetherius/console/screens/library.py +124 -0
  55. aetherius/console/screens/library_scan.py +99 -0
  56. aetherius/console/screens/recorder.py +193 -0
  57. aetherius/console/screens/runs.py +138 -0
  58. aetherius/console/screens/sessions.py +14 -0
  59. aetherius/console/screens/settings.py +132 -0
  60. aetherius/console/screenshots.py +173 -0
  61. aetherius/console/theme.py +114 -0
  62. aetherius/console/widgets/__init__.py +1 -0
  63. aetherius/console/widgets/event_log.py +44 -0
  64. aetherius/console/widgets/form.py +101 -0
  65. aetherius/console/widgets/json_preview.py +18 -0
  66. aetherius/console/widgets/run_summary.py +82 -0
  67. aetherius/core/__init__.py +1 -0
  68. aetherius/core/actions/__init__.py +1 -0
  69. aetherius/core/actions/base.py +99 -0
  70. aetherius/core/actions/data.py +126 -0
  71. aetherius/core/actions/flow.py +76 -0
  72. aetherius/core/actions/interaction.py +109 -0
  73. aetherius/core/actions/navigation.py +32 -0
  74. aetherius/core/actions/registry.py +70 -0
  75. aetherius/core/actions/spec.py +42 -0
  76. aetherius/core/blueprint/__init__.py +1 -0
  77. aetherius/core/blueprint/loader.py +82 -0
  78. aetherius/core/blueprint/models.py +81 -0
  79. aetherius/core/blueprint/template.py +101 -0
  80. aetherius/core/blueprint/validator.py +45 -0
  81. aetherius/core/driver.py +36 -0
  82. aetherius/core/errors.py +134 -0
  83. aetherius/core/events/__init__.py +1 -0
  84. aetherius/core/events/bus.py +28 -0
  85. aetherius/core/events/models.py +29 -0
  86. aetherius/core/events/sinks.py +55 -0
  87. aetherius/core/extraction/__init__.py +1 -0
  88. aetherius/core/extraction/html_extractor.py +52 -0
  89. aetherius/core/extraction/json_extractor.py +125 -0
  90. aetherius/core/runtime/__init__.py +1 -0
  91. aetherius/core/runtime/context.py +61 -0
  92. aetherius/core/runtime/engine.py +188 -0
  93. aetherius/core/runtime/result.py +39 -0
  94. aetherius/core/runtime/selector.py +1 -0
  95. aetherius/models/__init__.py +1 -0
  96. aetherius/models/registry.py +1 -0
  97. aetherius/models/store/.gitkeep +0 -0
  98. aetherius/recorder/__init__.py +12 -0
  99. aetherius/recorder/_capture_js.py +80 -0
  100. aetherius/recorder/_gesture_js.py +40 -0
  101. aetherius/recorder/_names.py +20 -0
  102. aetherius/recorder/_overlay_js.py +262 -0
  103. aetherius/recorder/_playwright.py +49 -0
  104. aetherius/recorder/_selector_js.py +148 -0
  105. aetherius/recorder/_transform.py +214 -0
  106. aetherius/recorder/_vector_js.py +174 -0
  107. aetherius/recorder/base.py +92 -0
  108. aetherius/recorder/blueprint_recorder.py +72 -0
  109. aetherius/recorder/capture.py +59 -0
  110. aetherius/recorder/continuum_backend.py +119 -0
  111. aetherius/recorder/gesture_recorder.py +193 -0
  112. aetherius/recorder/selector_synth.py +83 -0
  113. aetherius/recorder/session.py +101 -0
  114. aetherius/recorder/vector_backend.py +158 -0
  115. aetherius/server/__init__.py +12 -0
  116. aetherius/server/app.py +37 -0
  117. aetherius/server/config.py +25 -0
  118. aetherius/server/deps.py +50 -0
  119. aetherius/server/jobs.py +155 -0
  120. aetherius/server/routes/__init__.py +1 -0
  121. aetherius/server/routes/blueprints.py +45 -0
  122. aetherius/server/routes/recorder.py +25 -0
  123. aetherius/server/routes/runs.py +49 -0
  124. aetherius/server/routes/stream.py +53 -0
  125. aetherius/server/schemas.py +78 -0
  126. aetherius/stealth/__init__.py +1 -0
  127. aetherius/stealth/fingerprint/__init__.py +1 -0
  128. aetherius/stealth/fingerprint/patch.py +38 -0
  129. aetherius/stealth/fingerprint/profile.py +95 -0
  130. aetherius/stealth/gestures/__init__.py +1 -0
  131. aetherius/stealth/gestures/data/human_library.json +7107 -0
  132. aetherius/stealth/gestures/library.py +126 -0
  133. aetherius/stealth/gestures/seed.py +97 -0
  134. aetherius/stealth/humanizer/__init__.py +1 -0
  135. aetherius/stealth/humanizer/input.py +89 -0
  136. aetherius/stealth/humanizer/keyboard.py +75 -0
  137. aetherius/stealth/humanizer/mouse.py +153 -0
  138. aetherius/stealth/humanizer/scroll.py +55 -0
  139. aetherius/stealth/humanizer/timing.py +54 -0
  140. aetherius/stealth/ml/__init__.py +1 -0
  141. aetherius/stealth/ml/fingerprint_model.py +1 -0
  142. aetherius/stealth/ml/motion_model.py +1 -0
  143. aetherius/stealth/policy.py +133 -0
  144. aetherius/stealth/session/__init__.py +1 -0
  145. aetherius/stealth/session/store.py +35 -0
  146. aetherius/stealth/session/warmup.py +52 -0
  147. aetherius/version.py +3 -0
  148. aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
  149. aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
  150. aetherius-0.2.0.dist-info/METADATA +606 -0
  151. aetherius-0.2.0.dist-info/RECORD +154 -0
  152. aetherius-0.2.0.dist-info/WHEEL +4 -0
  153. aetherius-0.2.0.dist-info/entry_points.txt +2 -0
  154. aetherius-0.2.0.dist-info/licenses/LICENSE +11 -0
@@ -0,0 +1,81 @@
1
+ """Pydantic v2 models for a Blueprint. Single typed representation of an instruction file.
2
+
3
+ Source of truth for the shape: contracts/blueprint.schema.json.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
11
+
12
+
13
+ class InputSpec(BaseModel):
14
+ model_config = ConfigDict(extra="forbid")
15
+
16
+ type: Literal["string", "number", "integer", "boolean", "date", "path", "object", "array"]
17
+ required: bool = False
18
+ format: str | None = None
19
+ default: Any = None
20
+ description: str | None = None
21
+
22
+
23
+ class RetriesOptions(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ max: int = 0
27
+ backoff: Literal["none", "linear", "exponential"] = "none"
28
+
29
+
30
+ class SessionOptions(BaseModel):
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ profile: str | None = None
34
+ persist: bool = False
35
+
36
+
37
+ class Options(BaseModel):
38
+ model_config = ConfigDict(extra="forbid")
39
+
40
+ debug: bool = False
41
+ timeout_ms: int | None = None
42
+ retries: RetriesOptions = Field(default_factory=RetriesOptions)
43
+ stealth: Any = None
44
+ session: SessionOptions | None = None
45
+
46
+
47
+ class StepModel(BaseModel):
48
+ # extra="allow": action-specific keys are passed through and validated per-action by the engine.
49
+ model_config = ConfigDict(extra="allow")
50
+
51
+ id: str | None = None
52
+ action: str
53
+
54
+ @property
55
+ def extra_fields(self) -> dict[str, Any]:
56
+ """Action-specific parameters (all keys beyond id and action)."""
57
+ return self.model_extra or {}
58
+
59
+
60
+ class Blueprint(BaseModel):
61
+ model_config = ConfigDict(extra="forbid")
62
+
63
+ aetherius: str
64
+ name: str
65
+ description: str | None = None
66
+ act: Literal["vector", "continuum", "oracle", "phantom"]
67
+ inputs: dict[str, InputSpec] = Field(default_factory=dict)
68
+ secrets: list[str] = Field(default_factory=list)
69
+ vars: dict[str, Any] = Field(default_factory=dict)
70
+ options: Options = Field(default_factory=Options)
71
+ vision: dict[str, Any] | None = None
72
+ goal: str | None = None
73
+ constraints: list[str] = Field(default_factory=list)
74
+ steps: list[StepModel] = Field(default_factory=list)
75
+ outputs: dict[str, Any] = Field(default_factory=dict)
76
+
77
+ @model_validator(mode="after")
78
+ def _require_steps_or_goal(self) -> "Blueprint":
79
+ if not self.steps and not self.goal:
80
+ raise ValueError("A Blueprint must declare either 'steps' or 'goal'.")
81
+ return self
@@ -0,0 +1,101 @@
1
+ """Runtime interpolation of the {{ }} syntax over inputs, secrets, vars, env, and step outputs.
2
+
3
+ Uses Jinja2 SandboxedEnvironment with StrictUndefined so missing variables surface
4
+ immediately as TemplateError rather than silently rendering to empty strings.
5
+
6
+ When a value is a "bare" single-expression string like ``{{ steps.req.events }}``, the raw
7
+ Python object is returned instead of its string representation — this preserves lists and dicts
8
+ through the outputs rendering pipeline.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import datetime
14
+ import re
15
+ from typing import Any
16
+
17
+ from jinja2 import StrictUndefined, TemplateError as _Jinja2Error, Undefined as _Undefined
18
+ from jinja2.sandbox import SandboxedEnvironment
19
+
20
+ from ..errors import TemplateError
21
+
22
+ # Matches strings that are exactly one {{ … }} expression (possibly with surrounding whitespace).
23
+ _BARE_EXPR = re.compile(r"^\s*\{\{(.*?)\}\}\s*$", re.DOTALL)
24
+
25
+ # ── Custom filters ─────────────────────────────────────────────────────────────
26
+
27
+
28
+ def _filter_add_days(value: str, n: int) -> str:
29
+ """Add *n* days to an ISO 8601 date string and return an ISO 8601 date string."""
30
+ try:
31
+ date = datetime.date.fromisoformat(value)
32
+ except ValueError as exc:
33
+ raise TemplateError(f"add_days: cannot parse date {value!r}") from exc
34
+ return (date + datetime.timedelta(days=n)).isoformat()
35
+
36
+
37
+ def _filter_sub_days(value: str, n: int) -> str:
38
+ return _filter_add_days(value, -n)
39
+
40
+
41
+ def _filter_format_date(value: str, fmt: str) -> str:
42
+ """Reformat an ISO 8601 date string using a strftime format string."""
43
+ try:
44
+ date = datetime.date.fromisoformat(value)
45
+ except ValueError as exc:
46
+ raise TemplateError(f"format_date: cannot parse date {value!r}") from exc
47
+ return date.strftime(fmt)
48
+
49
+
50
+ # ── Jinja2 environment ────────────────────────────────────────────────────────
51
+
52
+
53
+ def _make_env() -> SandboxedEnvironment:
54
+ env = SandboxedEnvironment(undefined=StrictUndefined)
55
+ env.filters["add_days"] = _filter_add_days
56
+ env.filters["sub_days"] = _filter_sub_days
57
+ env.filters["format_date"] = _filter_format_date
58
+ return env
59
+
60
+
61
+ _env = _make_env()
62
+
63
+ # ── Public API ────────────────────────────────────────────────────────────────
64
+
65
+
66
+ def render_value(value: Any, ctx: dict[str, Any]) -> Any:
67
+ """Recursively render {{ }} expressions in *value* against *ctx*.
68
+
69
+ Strings are rendered through Jinja2. Dicts and lists are rendered recursively.
70
+ Non-string scalars (int, bool, None, …) are passed through unchanged.
71
+
72
+ When a string is a single bare ``{{ expr }}`` expression, the raw Python object is
73
+ returned (not its string representation), so lists and dicts survive the pipeline.
74
+
75
+ Raises:
76
+ TemplateError: on undefined variable, bad filter, or syntax error.
77
+ """
78
+ if isinstance(value, str):
79
+ m = _BARE_EXPR.match(value)
80
+ if m:
81
+ # Single-expression: evaluate and return the raw Python value.
82
+ try:
83
+ # undefined_to_none=False so we receive Undefined rather than None.
84
+ compiled = _env.compile_expression(m.group(1).strip(), undefined_to_none=False)
85
+ result = compiled(**ctx)
86
+ # compile_expression ignores StrictUndefined; raise explicitly.
87
+ if isinstance(result, _Undefined):
88
+ raise TemplateError(f"Undefined variable in expression: {m.group(1).strip()!r}")
89
+ return result
90
+ except _Jinja2Error as exc:
91
+ raise TemplateError(f"Template expression failed: {exc}") from exc
92
+ # Multi-part string: render as text.
93
+ try:
94
+ return _env.from_string(value).render(ctx)
95
+ except _Jinja2Error as exc:
96
+ raise TemplateError(f"Template rendering failed: {exc}") from exc
97
+ if isinstance(value, dict):
98
+ return {k: render_value(v, ctx) for k, v in value.items()}
99
+ if isinstance(value, list):
100
+ return [render_value(item, ctx) for item in value]
101
+ return value
@@ -0,0 +1,45 @@
1
+ """Semantic validation: verifies that every step action is supported by the Blueprint's Act."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..actions.base import ACT_CAPABILITIES
6
+ from ..errors import BlueprintValidationError
7
+ from .models import Blueprint
8
+
9
+ # Map back from Capability value to the first Act that introduces it.
10
+ _CAPABILITY_ORIGIN: dict[str, str] = {
11
+ "navigate": "continuum",
12
+ "back": "continuum",
13
+ "forward": "continuum",
14
+ "reload": "continuum",
15
+ "click": "continuum",
16
+ "fill": "continuum",
17
+ "type": "continuum",
18
+ "press": "continuum",
19
+ "select": "continuum",
20
+ "hover": "continuum",
21
+ "scroll": "continuum",
22
+ "upload": "continuum",
23
+ "drag": "continuum",
24
+ "screenshot": "continuum",
25
+ "evaluate": "continuum",
26
+ "wait_for": "continuum",
27
+ }
28
+
29
+
30
+ def validate_for_act(blueprint: Blueprint) -> None:
31
+ """Raise BlueprintValidationError if any step uses an action unsupported by blueprint.act.
32
+
33
+ This is a semantic check complementing the JSON Schema structural validation.
34
+ """
35
+ supported = ACT_CAPABILITIES.get(blueprint.act, frozenset())
36
+ supported_values = {cap.value for cap in supported}
37
+
38
+ for step in blueprint.steps:
39
+ if step.action not in supported_values:
40
+ origin = _CAPABILITY_ORIGIN.get(step.action)
41
+ hint = f" (requires act={origin!r} or higher)" if origin else ""
42
+ raise BlueprintValidationError(
43
+ f"Step {step.id!r}: action {step.action!r} is not supported "
44
+ f"by act={blueprint.act!r}{hint}."
45
+ )
@@ -0,0 +1,36 @@
1
+ """ActDriver protocol: contract that every Act (I..IV) driver must satisfy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Protocol
6
+
7
+ if TYPE_CHECKING:
8
+ from .events.bus import EventBus
9
+ from .blueprint.models import StepModel
10
+ from .runtime.context import RunContext
11
+
12
+
13
+ class ActDriver(Protocol):
14
+ act: ClassVar[str]
15
+
16
+ def setup(self, ctx: "RunContext") -> None:
17
+ """Called once before the first step. Open connections, create sessions, etc."""
18
+ ...
19
+
20
+ def teardown(self, ctx: "RunContext") -> None:
21
+ """Called once after the last step (even on failure). Close resources."""
22
+ ...
23
+
24
+ def run_step(
25
+ self,
26
+ step: "StepModel",
27
+ ctx: "RunContext",
28
+ bus: "EventBus",
29
+ renderer: Callable[[Any], Any],
30
+ ) -> dict[str, Any]:
31
+ """Execute a single step and return its output dict.
32
+
33
+ *renderer* is a callable that applies template rendering to any value.
34
+ The driver calls it on step fields before using them.
35
+ """
36
+ ...
@@ -0,0 +1,134 @@
1
+ """Typed exception hierarchy for Aetherius. Errors are never swallowed silently."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AetheriusError(Exception):
7
+ """Base for all Aetherius exceptions."""
8
+
9
+
10
+ # ── Blueprint ──────────────────────────────────────────────────────────────────
11
+
12
+
13
+ class BlueprintError(AetheriusError):
14
+ """Base for Blueprint-related errors."""
15
+
16
+
17
+ class BlueprintLoadError(BlueprintError):
18
+ """File not found, unreadable, or not valid JSON/YAML."""
19
+
20
+
21
+ class BlueprintSchemaError(BlueprintError):
22
+ """Blueprint violates the JSON Schema or Pydantic model constraints."""
23
+
24
+
25
+ class BlueprintValidationError(BlueprintError):
26
+ """Semantic error: action not supported by the declared Act, missing required input, etc."""
27
+
28
+
29
+ # ── Template ───────────────────────────────────────────────────────────────────
30
+
31
+
32
+ class TemplateError(AetheriusError):
33
+ """Jinja2 rendering failure: undefined variable, bad filter, syntax error."""
34
+
35
+
36
+ # ── Network ───────────────────────────────────────────────────────────────────
37
+
38
+
39
+ class NetworkError(AetheriusError):
40
+ """Base for transport-level failures."""
41
+
42
+
43
+ class TimeoutError(NetworkError):
44
+ """Request exceeded the configured timeout."""
45
+
46
+
47
+ class RetryExhaustedError(NetworkError):
48
+ """All retry attempts failed. Wraps the last transport exception."""
49
+
50
+ def __init__(self, message: str, last_error: Exception) -> None:
51
+ super().__init__(message)
52
+ self.last_error = last_error
53
+
54
+
55
+ # ── HTTP assertions ────────────────────────────────────────────────────────────
56
+
57
+
58
+ class StatusAssertionError(AetheriusError):
59
+ """HTTP response status does not match `expect.status`."""
60
+
61
+ def __init__(
62
+ self,
63
+ expected: int,
64
+ actual: int,
65
+ url: str,
66
+ body_preview: str = "",
67
+ ) -> None:
68
+ super().__init__(
69
+ f"Expected HTTP {expected}, got {actual} — {url}"
70
+ + (f"\n{body_preview[:200]}" if body_preview else "")
71
+ )
72
+ self.expected = expected
73
+ self.actual = actual
74
+ self.url = url
75
+ self.body_preview = body_preview
76
+
77
+
78
+ # ── Extraction ────────────────────────────────────────────────────────────────
79
+
80
+
81
+ class ExtractionError(AetheriusError):
82
+ """JSONPath / CSS / XPath / predicate evaluation failure."""
83
+
84
+
85
+ # ── Actions ───────────────────────────────────────────────────────────────────
86
+
87
+
88
+ class ActionError(AetheriusError):
89
+ """Unknown action name or missing required action parameter."""
90
+
91
+
92
+ class StepTimeoutError(AetheriusError):
93
+ """A step waited past its budget (e.g. wait_for). Carries the Blueprint's failure code."""
94
+
95
+ def __init__(self, message: str, *, code: str | None = None) -> None:
96
+ super().__init__(message)
97
+ self.code = code
98
+
99
+
100
+ # ── Dependencies ──────────────────────────────────────────────────────────────
101
+
102
+
103
+ class DependencyError(AetheriusError):
104
+ """A required optional dependency (an Act extra) is not installed."""
105
+
106
+ def __init__(self, message: str, *, extra: str | None = None) -> None:
107
+ super().__init__(message)
108
+ self.extra = extra
109
+
110
+
111
+ # ── Recorder ──────────────────────────────────────────────────────────────────
112
+
113
+
114
+ class RecorderError(AetheriusError):
115
+ """No recorder backend for the requested Act (unknown, or a pending milestone)."""
116
+
117
+
118
+ # ── Builder ───────────────────────────────────────────────────────────────────
119
+
120
+
121
+ class BuilderError(AetheriusError):
122
+ """Headless builder failure: unknown Act or template, or a name collision on save."""
123
+
124
+
125
+ # ── Runtime ───────────────────────────────────────────────────────────────────
126
+
127
+
128
+ class RunError(AetheriusError):
129
+ """Engine-level failure wrapping an underlying AetheriusError."""
130
+
131
+ def __init__(self, message: str, cause: Exception) -> None:
132
+ super().__init__(message)
133
+ self.cause = cause
134
+ self.__cause__ = cause
@@ -0,0 +1 @@
1
+ """Unified event system powering progress reporting, debug output and streaming."""
@@ -0,0 +1,28 @@
1
+ """Synchronous event fan-out: emits RunEvents to all registered sinks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from .sinks import Sink
10
+
11
+ from .models import RunEvent
12
+
13
+ _log = logging.getLogger(__name__)
14
+
15
+
16
+ class EventBus:
17
+ def __init__(self) -> None:
18
+ self._sinks: list[Sink] = []
19
+
20
+ def register(self, sink: "Sink") -> None:
21
+ self._sinks.append(sink)
22
+
23
+ def emit(self, event: RunEvent) -> None:
24
+ for sink in self._sinks:
25
+ try:
26
+ sink.on_event(event)
27
+ except Exception:
28
+ _log.exception("Event sink %r raised an error; continuing.", sink)
@@ -0,0 +1,29 @@
1
+ """Event types emitted during a Blueprint run. Mirrors contracts/events.schema.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+ from typing import Any, Literal
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class EventType(str, Enum):
13
+ PROGRESS = "progress"
14
+ STEP_STARTED = "step_started"
15
+ STEP_FINISHED = "step_finished"
16
+ DEBUG = "debug"
17
+ ARTIFACT = "artifact"
18
+ ERROR = "error"
19
+ DONE = "done"
20
+
21
+
22
+ class RunEvent(BaseModel):
23
+ run_id: str
24
+ ts: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
25
+ type: EventType
26
+ step_id: str | None = None
27
+ level: Literal["debug", "info", "warning", "error"] | None = None
28
+ message: str | None = None
29
+ data: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,55 @@
1
+ """Event sinks: LogSink for terminal output, NullSink for tests.
2
+
3
+ WebSocketSink is deferred to the daemon milestone.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from typing import Protocol, runtime_checkable
10
+
11
+ from .models import EventType, RunEvent
12
+
13
+ _log = logging.getLogger("aetherius.events")
14
+
15
+
16
+ @runtime_checkable
17
+ class Sink(Protocol):
18
+ def on_event(self, event: RunEvent) -> None: ...
19
+
20
+
21
+ class NullSink:
22
+ """Discards all events. Used in tests."""
23
+
24
+ def on_event(self, event: RunEvent) -> None:
25
+ pass
26
+
27
+
28
+ def format_event(event: RunEvent) -> str:
29
+ """Render a RunEvent as a single-line human-readable string.
30
+
31
+ Shared by LogSink and the Console's EventLog widget so the two presentations never drift.
32
+ """
33
+ msg = f"[{event.type.value}]"
34
+ if event.step_id:
35
+ msg += f" step={event.step_id}"
36
+ if event.message:
37
+ msg += f" {event.message}"
38
+ return msg
39
+
40
+
41
+ class LogSink:
42
+ """Writes events to Python logging.
43
+
44
+ In debug mode every event is logged at DEBUG; otherwise only INFO and above.
45
+ """
46
+
47
+ def __init__(self, *, debug: bool = False) -> None:
48
+ self._debug = debug
49
+
50
+ def on_event(self, event: RunEvent) -> None:
51
+ level_str = event.level or ("debug" if event.type == EventType.DEBUG else "info")
52
+ level = getattr(logging, level_str.upper(), logging.INFO)
53
+ if not self._debug and level < logging.INFO:
54
+ return
55
+ _log.log(level, format_event(event))
@@ -0,0 +1 @@
1
+ """Declarative extraction of structured data from JSON and HTML payloads."""
@@ -0,0 +1,52 @@
1
+ """HTML extraction via CSS and XPath selectors using parsel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from parsel import Selector
9
+
10
+ from ..errors import ExtractionError
11
+
12
+
13
+ @dataclass
14
+ class HtmlExtractSpec:
15
+ from_: str # "html"
16
+ selector: str # CSS or XPath expression
17
+ selector_type: str = "css" # "css" | "xpath"
18
+ attr: str | None = None # attribute to extract; None → text content
19
+ multiple: bool = True # True → list, False → first match only
20
+
21
+
22
+ def extract_html(body: bytes, spec: dict[str, "HtmlExtractSpec"]) -> dict[str, Any]:
23
+ """Run all extraction specs in *spec* against an HTML *body*.
24
+
25
+ Returns a dict mapping each output name to a string (single) or list of strings (multiple).
26
+ """
27
+ try:
28
+ sel = Selector(text=body.decode("utf-8", errors="replace"))
29
+ except Exception as exc:
30
+ raise ExtractionError(f"Cannot parse HTML response body: {exc}") from exc
31
+
32
+ result: dict[str, Any] = {}
33
+
34
+ for output_name, s in spec.items():
35
+ try:
36
+ if s.selector_type == "xpath":
37
+ nodes = sel.xpath(s.selector)
38
+ else:
39
+ nodes = sel.css(s.selector)
40
+ except Exception as exc:
41
+ raise ExtractionError(
42
+ f"Invalid {s.selector_type} selector {s.selector!r}: {exc}"
43
+ ) from exc
44
+
45
+ if s.attr:
46
+ values = [node.attrib.get(s.attr, "") for node in nodes]
47
+ else:
48
+ values = nodes.getall()
49
+
50
+ result[output_name] = values if s.multiple else (values[0] if values else None)
51
+
52
+ return result