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.
- aetherius/__init__.py +42 -0
- aetherius/__main__.py +12 -0
- aetherius/_contracts/__init__.py +0 -0
- aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius/acts/__init__.py +1 -0
- aetherius/acts/_shared.py +61 -0
- aetherius/acts/continuum/__init__.py +7 -0
- aetherius/acts/continuum/actions.py +149 -0
- aetherius/acts/continuum/bridge.py +159 -0
- aetherius/acts/continuum/browser.py +164 -0
- aetherius/acts/continuum/debug_overlay.py +65 -0
- aetherius/acts/continuum/driver.py +127 -0
- aetherius/acts/continuum/human_actions.py +74 -0
- aetherius/acts/oracle/__init__.py +1 -0
- aetherius/acts/oracle/driver.py +1 -0
- aetherius/acts/oracle/locator.py +1 -0
- aetherius/acts/oracle/model.py +1 -0
- aetherius/acts/oracle/perception.py +1 -0
- aetherius/acts/phantom/__init__.py +1 -0
- aetherius/acts/phantom/driver.py +1 -0
- aetherius/acts/phantom/loop.py +1 -0
- aetherius/acts/phantom/memory.py +1 -0
- aetherius/acts/phantom/perception.py +1 -0
- aetherius/acts/phantom/planner.py +1 -0
- aetherius/acts/vector/__init__.py +1 -0
- aetherius/acts/vector/auth.py +108 -0
- aetherius/acts/vector/client.py +129 -0
- aetherius/acts/vector/driver.py +128 -0
- aetherius/builder/__init__.py +40 -0
- aetherius/builder/catalog.py +93 -0
- aetherius/builder/factory.py +243 -0
- aetherius/builder/templates.py +150 -0
- aetherius/builder/validation.py +120 -0
- aetherius/cli.py +236 -0
- aetherius/config/__init__.py +1 -0
- aetherius/config/secrets.py +71 -0
- aetherius/config/settings.py +44 -0
- aetherius/console/__init__.py +1 -0
- aetherius/console/app.py +66 -0
- aetherius/console/console.tcss +97 -0
- aetherius/console/daemon_control.py +83 -0
- aetherius/console/run_bridge.py +43 -0
- aetherius/console/screens/__init__.py +1 -0
- aetherius/console/screens/_pending.py +38 -0
- aetherius/console/screens/builder/__init__.py +1 -0
- aetherius/console/screens/builder/act_picker.py +75 -0
- aetherius/console/screens/builder/io_editor.py +167 -0
- aetherius/console/screens/builder/options_editor.py +132 -0
- aetherius/console/screens/builder/preview.py +57 -0
- aetherius/console/screens/builder/screen.py +235 -0
- aetherius/console/screens/builder/step_editor.py +249 -0
- aetherius/console/screens/catalog.py +52 -0
- aetherius/console/screens/home.py +90 -0
- aetherius/console/screens/library.py +124 -0
- aetherius/console/screens/library_scan.py +99 -0
- aetherius/console/screens/recorder.py +193 -0
- aetherius/console/screens/runs.py +138 -0
- aetherius/console/screens/sessions.py +14 -0
- aetherius/console/screens/settings.py +132 -0
- aetherius/console/screenshots.py +173 -0
- aetherius/console/theme.py +114 -0
- aetherius/console/widgets/__init__.py +1 -0
- aetherius/console/widgets/event_log.py +44 -0
- aetherius/console/widgets/form.py +101 -0
- aetherius/console/widgets/json_preview.py +18 -0
- aetherius/console/widgets/run_summary.py +82 -0
- aetherius/core/__init__.py +1 -0
- aetherius/core/actions/__init__.py +1 -0
- aetherius/core/actions/base.py +99 -0
- aetherius/core/actions/data.py +126 -0
- aetherius/core/actions/flow.py +76 -0
- aetherius/core/actions/interaction.py +109 -0
- aetherius/core/actions/navigation.py +32 -0
- aetherius/core/actions/registry.py +70 -0
- aetherius/core/actions/spec.py +42 -0
- aetherius/core/blueprint/__init__.py +1 -0
- aetherius/core/blueprint/loader.py +82 -0
- aetherius/core/blueprint/models.py +81 -0
- aetherius/core/blueprint/template.py +101 -0
- aetherius/core/blueprint/validator.py +45 -0
- aetherius/core/driver.py +36 -0
- aetherius/core/errors.py +134 -0
- aetherius/core/events/__init__.py +1 -0
- aetherius/core/events/bus.py +28 -0
- aetherius/core/events/models.py +29 -0
- aetherius/core/events/sinks.py +55 -0
- aetherius/core/extraction/__init__.py +1 -0
- aetherius/core/extraction/html_extractor.py +52 -0
- aetherius/core/extraction/json_extractor.py +125 -0
- aetherius/core/runtime/__init__.py +1 -0
- aetherius/core/runtime/context.py +61 -0
- aetherius/core/runtime/engine.py +188 -0
- aetherius/core/runtime/result.py +39 -0
- aetherius/core/runtime/selector.py +1 -0
- aetherius/models/__init__.py +1 -0
- aetherius/models/registry.py +1 -0
- aetherius/models/store/.gitkeep +0 -0
- aetherius/recorder/__init__.py +12 -0
- aetherius/recorder/_capture_js.py +80 -0
- aetherius/recorder/_gesture_js.py +40 -0
- aetherius/recorder/_names.py +20 -0
- aetherius/recorder/_overlay_js.py +262 -0
- aetherius/recorder/_playwright.py +49 -0
- aetherius/recorder/_selector_js.py +148 -0
- aetherius/recorder/_transform.py +214 -0
- aetherius/recorder/_vector_js.py +174 -0
- aetherius/recorder/base.py +92 -0
- aetherius/recorder/blueprint_recorder.py +72 -0
- aetherius/recorder/capture.py +59 -0
- aetherius/recorder/continuum_backend.py +119 -0
- aetherius/recorder/gesture_recorder.py +193 -0
- aetherius/recorder/selector_synth.py +83 -0
- aetherius/recorder/session.py +101 -0
- aetherius/recorder/vector_backend.py +158 -0
- aetherius/server/__init__.py +12 -0
- aetherius/server/app.py +37 -0
- aetherius/server/config.py +25 -0
- aetherius/server/deps.py +50 -0
- aetherius/server/jobs.py +155 -0
- aetherius/server/routes/__init__.py +1 -0
- aetherius/server/routes/blueprints.py +45 -0
- aetherius/server/routes/recorder.py +25 -0
- aetherius/server/routes/runs.py +49 -0
- aetherius/server/routes/stream.py +53 -0
- aetherius/server/schemas.py +78 -0
- aetherius/stealth/__init__.py +1 -0
- aetherius/stealth/fingerprint/__init__.py +1 -0
- aetherius/stealth/fingerprint/patch.py +38 -0
- aetherius/stealth/fingerprint/profile.py +95 -0
- aetherius/stealth/gestures/__init__.py +1 -0
- aetherius/stealth/gestures/data/human_library.json +7107 -0
- aetherius/stealth/gestures/library.py +126 -0
- aetherius/stealth/gestures/seed.py +97 -0
- aetherius/stealth/humanizer/__init__.py +1 -0
- aetherius/stealth/humanizer/input.py +89 -0
- aetherius/stealth/humanizer/keyboard.py +75 -0
- aetherius/stealth/humanizer/mouse.py +153 -0
- aetherius/stealth/humanizer/scroll.py +55 -0
- aetherius/stealth/humanizer/timing.py +54 -0
- aetherius/stealth/ml/__init__.py +1 -0
- aetherius/stealth/ml/fingerprint_model.py +1 -0
- aetherius/stealth/ml/motion_model.py +1 -0
- aetherius/stealth/policy.py +133 -0
- aetherius/stealth/session/__init__.py +1 -0
- aetherius/stealth/session/store.py +35 -0
- aetherius/stealth/session/warmup.py +52 -0
- aetherius/version.py +3 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius-0.2.0.dist-info/METADATA +606 -0
- aetherius-0.2.0.dist-info/RECORD +154 -0
- aetherius-0.2.0.dist-info/WHEEL +4 -0
- aetherius-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
aetherius/core/driver.py
ADDED
|
@@ -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
|
+
...
|
aetherius/core/errors.py
ADDED
|
@@ -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
|