python-library-automation 0.1.16__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 (42) hide show
  1. automation/__init__.py +25 -0
  2. automation/assistant.py +141 -0
  3. automation/builtins/__init__.py +27 -0
  4. automation/builtins/action/__init__.py +0 -0
  5. automation/builtins/action/call_entity_method.py +35 -0
  6. automation/builtins/action/delay.py +18 -0
  7. automation/builtins/action/log.py +20 -0
  8. automation/builtins/action/set_attribute.py +27 -0
  9. automation/builtins/entity/__init__.py +0 -0
  10. automation/builtins/entity/time.py +29 -0
  11. automation/builtins/entity/variable.py +81 -0
  12. automation/builtins/event/__init__.py +0 -0
  13. automation/builtins/event/_scheduled.py +34 -0
  14. automation/builtins/event/at.py +27 -0
  15. automation/builtins/event/callback.py +65 -0
  16. automation/builtins/event/every.py +32 -0
  17. automation/builtins/event/state_changed.py +58 -0
  18. automation/core/__init__.py +13 -0
  19. automation/core/action.py +22 -0
  20. automation/core/base.py +59 -0
  21. automation/core/composite_action.py +47 -0
  22. automation/core/entity.py +178 -0
  23. automation/core/event.py +87 -0
  24. automation/core/event_context.py +10 -0
  25. automation/core/trigger.py +151 -0
  26. automation/errors.py +42 -0
  27. automation/executor.py +46 -0
  28. automation/hub.py +52 -0
  29. automation/listeners/__init__.py +17 -0
  30. automation/listeners/base.py +23 -0
  31. automation/listeners/console.py +82 -0
  32. automation/listeners/instance_schema.py +26 -0
  33. automation/listeners/record.py +29 -0
  34. automation/listeners/trace.py +70 -0
  35. automation/listeners/type_schema.py +26 -0
  36. automation/loader.py +131 -0
  37. automation/renderer.py +311 -0
  38. automation/schema.py +142 -0
  39. automation/updater.py +84 -0
  40. python_library_automation-0.1.16.dist-info/METADATA +8 -0
  41. python_library_automation-0.1.16.dist-info/RECORD +42 -0
  42. python_library_automation-0.1.16.dist-info/WHEEL +4 -0
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from automation.hub import Hub
6
+
7
+ class BaseListener:
8
+ """自动化生命周期监听器"""
9
+
10
+ def on_loaded(self, hub: Hub) -> None: pass
11
+ def on_start(self) -> None: pass
12
+ def on_stop(self) -> None: pass
13
+ def on_event_fired(self, event_name: str) -> None: pass
14
+ def on_trigger_started(self, trigger_name: str) -> None: pass
15
+ def on_trigger_skipped(self, trigger_name: str) -> None: pass
16
+ def on_trigger_completed(self, trigger_name: str, elapsed: float) -> None: pass
17
+ def on_trigger_aborted(self, trigger_name: str, condition_name: str) -> None: pass
18
+ def on_trigger_error(self, trigger_name: str, error: Exception) -> None: pass
19
+ def on_condition_checked(self, trigger_name: str, condition_name: str, passed: bool) -> None: pass
20
+ def on_action_started(self, trigger_name: str, action_name: str, *, params: dict | None = None) -> None: pass
21
+ def on_action_completed(self, trigger_name: str, action_name: str, elapsed: float, *, params: dict | None = None) -> None: pass
22
+ def on_action_error(self, trigger_name: str, action_name: str, error: Exception) -> None: pass
23
+ def on_load_error(self,section: str,instance: str,phase: str,code: str,error: Exception) -> None: pass
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+ import sys
3
+ from .base import BaseListener
4
+
5
+ class _Ansi:
6
+ RESET = "\033[0m"
7
+ BOLD = "\033[1m"
8
+ DIM = "\033[2m"
9
+ RED = "\033[31m"
10
+ GREEN = "\033[32m"
11
+ YELLOW = "\033[33m"
12
+ CYAN = "\033[36m"
13
+
14
+ class ConsoleListener(BaseListener):
15
+ """默认彩色控制台渲染器"""
16
+
17
+ def _write(self, msg: str, prefix: str = "") -> None:
18
+ line = f"{_Ansi.DIM}[{prefix}]{_Ansi.RESET} {msg}" if prefix else msg
19
+ sys.stderr.write(line + "\n")
20
+ sys.stderr.flush()
21
+
22
+ def on_start(self) -> None:
23
+ self._write(f"{_Ansi.DIM}── Assistant started ──{_Ansi.RESET}")
24
+
25
+ def on_stop(self) -> None:
26
+ self._write(f"{_Ansi.DIM}── Assistant stopped ──{_Ansi.RESET}")
27
+
28
+ def on_event_fired(self, event_name: str) -> None:
29
+ self._write(f"{_Ansi.CYAN}{_Ansi.BOLD}⚡ {event_name}{_Ansi.RESET}")
30
+
31
+ def on_trigger_started(self, trigger_name: str) -> None:
32
+ self._write(f"{_Ansi.YELLOW}┌ {trigger_name}{_Ansi.RESET}")
33
+
34
+ def on_trigger_skipped(self, trigger_name: str) -> None:
35
+ self._write(f"{_Ansi.DIM}⏭ {trigger_name} (busy){_Ansi.RESET}")
36
+
37
+ def on_trigger_completed(self, trigger_name: str, elapsed: float) -> None:
38
+ time_str = f" {_Ansi.DIM}({elapsed:.2f}s){_Ansi.RESET}" if elapsed >= 0.005 else ""
39
+ self._write(
40
+ f"{_Ansi.YELLOW}└ {trigger_name}{_Ansi.RESET}{time_str}"
41
+ )
42
+
43
+ def on_trigger_aborted(self, trigger_name: str, condition_name: str) -> None:
44
+ self._write(
45
+ f"{_Ansi.YELLOW}└ {trigger_name}{_Ansi.RESET}"
46
+ f" {_Ansi.DIM}aborted by {condition_name}{_Ansi.RESET}"
47
+ )
48
+
49
+ def on_trigger_error(self, trigger_name: str, error: Exception) -> None:
50
+ self._write(
51
+ f"{_Ansi.YELLOW}└ {trigger_name}{_Ansi.RESET}"
52
+ f" {_Ansi.RED}error{_Ansi.RESET}"
53
+ )
54
+
55
+ def on_condition_checked(
56
+ self, trigger_name: str, condition_name: str, passed: bool
57
+ ) -> None:
58
+ if passed:
59
+ self._write(f"{_Ansi.GREEN}│ ✓ {condition_name}{_Ansi.RESET}")
60
+ else:
61
+ self._write(f"{_Ansi.RED}│ ✗ {condition_name}{_Ansi.RESET}")
62
+
63
+ def on_action_started(self, trigger_name: str, action_name: str, **_) -> None:
64
+ self._write(f"│ ▶ {action_name}", prefix=trigger_name)
65
+
66
+ def on_action_completed(self, trigger_name: str, action_name: str, elapsed: float, **_) -> None:
67
+ time_str = f" {_Ansi.DIM}({elapsed:.2f}s){_Ansi.RESET}" if elapsed >= 0.005 else ""
68
+ self._write(
69
+ f"{_Ansi.GREEN}│ ✓ {action_name}{_Ansi.RESET}{time_str}",
70
+ prefix=trigger_name,
71
+ )
72
+
73
+ def on_action_error(
74
+ self, trigger_name: str, action_name: str, error: Exception
75
+ ) -> None:
76
+ self._write(f"{_Ansi.RED}│ ✗ {action_name}: {error}{_Ansi.RESET}")
77
+
78
+ def on_load_error(self, section, instance, phase, code, error):
79
+ self._write(
80
+ f"{_Ansi.RED}✗ {section}.{instance} "
81
+ f"[{phase}/{code}]: {error}{_Ansi.RESET}"
82
+ )
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .base import BaseListener
7
+
8
+ if TYPE_CHECKING:
9
+ from automation.hub import Hub
10
+
11
+
12
+ class InstanceSchemaListener(BaseListener):
13
+ """加载后输出实例 schema 到文件,展示当前运行时状态"""
14
+
15
+ def __init__(self, output_path: str | Path):
16
+ self._output_path = Path(output_path)
17
+
18
+ def on_loaded(self, hub: Hub) -> None:
19
+ from automation.schema import export_instance_schema
20
+
21
+ schema = export_instance_schema(hub)
22
+ self._output_path.parent.mkdir(parents=True, exist_ok=True)
23
+ self._output_path.write_text(
24
+ json.dumps(schema, indent=2, ensure_ascii=False, default=str),
25
+ encoding="utf-8",
26
+ )
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+ from typing import Literal
3
+ from datetime import datetime
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class ConditionRecord(BaseModel):
8
+ expr: str
9
+ passed: bool
10
+
11
+
12
+ class ActionRecord(BaseModel):
13
+ action: str
14
+ params: dict | None = None
15
+ status: Literal["running", "completed", "error"] = "running"
16
+ elapsed: float | None = None
17
+ error: str | None = None
18
+
19
+
20
+ class TriggerRecord(BaseModel):
21
+ trigger: str
22
+ started_at: datetime = Field(default_factory=datetime.now)
23
+ finished_at: datetime | None = None
24
+ status: Literal["running", "completed", "error", "aborted"] = "running"
25
+ elapsed: float | None = None
26
+ error: str | None = None
27
+ aborted_by: str | None = None
28
+ conditions: list[ConditionRecord] = Field(default_factory=list)
29
+ actions: list[ActionRecord] = Field(default_factory=list)
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from datetime import datetime
5
+
6
+ from .base import BaseListener
7
+ from .record import TriggerRecord, ActionRecord, ConditionRecord
8
+
9
+
10
+ class TraceListener(BaseListener):
11
+ """将每次触发的执行记录写入独立 JSON 文件,用于调试回溯"""
12
+
13
+ def __init__(self, output_dir: str | Path):
14
+ self._output_dir = Path(output_dir)
15
+ self._output_dir.mkdir(parents=True, exist_ok=True)
16
+ self._pending: dict[str, TriggerRecord] = {}
17
+
18
+ def on_trigger_started(self, trigger_name: str) -> None:
19
+ self._pending[trigger_name] = TriggerRecord(trigger=trigger_name)
20
+
21
+ def on_condition_checked(self, trigger_name: str, condition_name: str, passed: bool) -> None:
22
+ if record := self._pending.get(trigger_name):
23
+ record.conditions.append(ConditionRecord(expr=condition_name, passed=passed))
24
+
25
+ def on_action_started(self, trigger_name: str, action_name: str, *, params: dict | None = None) -> None:
26
+ if record := self._pending.get(trigger_name):
27
+ record.actions.append(ActionRecord(action=action_name, params=params))
28
+
29
+ def on_action_completed(self, trigger_name: str, action_name: str, elapsed: float, *, params: dict | None = None) -> None:
30
+ if record := self._pending.get(trigger_name):
31
+ if record.actions and record.actions[-1].action == action_name:
32
+ record.actions[-1].status = "completed"
33
+ record.actions[-1].elapsed = round(elapsed, 4)
34
+
35
+ def on_action_error(self, trigger_name: str, action_name: str, error: Exception) -> None:
36
+ if record := self._pending.get(trigger_name):
37
+ if record.actions and record.actions[-1].action == action_name:
38
+ record.actions[-1].status = "error"
39
+ record.actions[-1].error = str(error)
40
+
41
+ def on_trigger_completed(self, trigger_name: str, elapsed: float) -> None:
42
+ self._flush(trigger_name, "completed", elapsed)
43
+
44
+ def on_trigger_error(self, trigger_name: str, error: Exception) -> None:
45
+ if record := self._pending.get(trigger_name):
46
+ record.error = str(error)
47
+ self._flush(trigger_name, "error")
48
+
49
+ def on_trigger_aborted(self, trigger_name: str, condition_name: str) -> None:
50
+ if record := self._pending.get(trigger_name):
51
+ record.aborted_by = condition_name
52
+ self._flush(trigger_name, "aborted")
53
+
54
+ def _flush(self, trigger_name: str, status: str, elapsed: float | None = None) -> None:
55
+ record = self._pending.pop(trigger_name, None)
56
+ if record is None:
57
+ return
58
+ record.status = status
59
+ record.finished_at = datetime.now()
60
+ if elapsed is not None:
61
+ record.elapsed = round(elapsed, 4)
62
+
63
+ now = datetime.now()
64
+ trigger_dir = self._output_dir / now.strftime("%Y-%m-%d") / trigger_name
65
+ trigger_dir.mkdir(parents=True, exist_ok=True)
66
+ filename = f"{now.strftime('%H-%M-%S-%f')}.json"
67
+ (trigger_dir / filename).write_text(
68
+ record.model_dump_json(indent=2),
69
+ encoding="utf-8",
70
+ )
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .base import BaseListener
7
+
8
+ if TYPE_CHECKING:
9
+ from automation.hub import Hub
10
+
11
+
12
+ class TypeSchemaListener(BaseListener):
13
+ """加载后输出类型 schema 到文件,告诉用户怎么写配置"""
14
+
15
+ def __init__(self, output_path: str | Path):
16
+ self._output_path = Path(output_path)
17
+
18
+ def on_loaded(self, hub: Hub) -> None:
19
+ from automation.schema import export_type_schema
20
+
21
+ schema = export_type_schema()
22
+ self._output_path.parent.mkdir(parents=True, exist_ok=True)
23
+ self._output_path.write_text(
24
+ json.dumps(schema, indent=2, ensure_ascii=False, default=str),
25
+ encoding="utf-8",
26
+ )
automation/loader.py ADDED
@@ -0,0 +1,131 @@
1
+ import logging
2
+ from typing import Any
3
+ from .hub import Hub
4
+ from .core.entity import entity_registry
5
+ from .core.event import event_registry
6
+ from .core.trigger import trigger_registry
7
+ from .core.composite_action import CompositeAction
8
+ from .errors import ConfigLoadError, LoadPhase, LoadErrorCode
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ SECTION_TO_REGISTRIES = {
13
+ "entities": entity_registry,
14
+ "events": event_registry,
15
+ "triggers": trigger_registry,
16
+ }
17
+
18
+ DEFAULT_TYPES: dict[str, str] = {
19
+ "triggers": "trigger",
20
+ }
21
+
22
+
23
+ def build_section(section_name, section_data):
24
+ registry = SECTION_TO_REGISTRIES[section_name]
25
+ default_type = DEFAULT_TYPES.get(section_name)
26
+ result = {}
27
+ for name, spec in section_data.items():
28
+ spec = dict(spec)
29
+ type_name = spec.pop("type", default_type)
30
+ if type_name is None:
31
+ raise ConfigLoadError(
32
+ section=section_name, instance=name,
33
+ phase=LoadPhase.BUILD, code=LoadErrorCode.MISSING_TYPE,
34
+ )
35
+ try:
36
+ cls = registry.get(type_name)
37
+ except KeyError as e:
38
+ raise ConfigLoadError(
39
+ section=section_name, instance=name,
40
+ phase=LoadPhase.BUILD, code=LoadErrorCode.UNKNOWN_TYPE,
41
+ cause=e,
42
+ ) from e
43
+ try:
44
+ result[name] = cls(instance_name=name, **spec)
45
+ except Exception as e:
46
+ raise ConfigLoadError(
47
+ section=section_name, instance=name,
48
+ phase=LoadPhase.BUILD, code=LoadErrorCode.INVALID_CONFIG,
49
+ cause=e,
50
+ ) from e
51
+ return result
52
+
53
+
54
+ def build_actions(
55
+ section_data: dict[str, dict[str, Any]]
56
+ ) -> dict[str, CompositeAction]:
57
+ result = {}
58
+ for name, spec in section_data.items():
59
+ spec = dict(spec)
60
+ params = spec.get("params", {})
61
+ conditions = spec.get("conditions", [])
62
+ action_specs = spec.get("actions", [])
63
+ if not action_specs:
64
+ raise ValueError(
65
+ f"actions.{name} must have at least one child action"
66
+ )
67
+ result[name] = CompositeAction(
68
+ name=name,
69
+ params=params,
70
+ conditions=conditions,
71
+ action_specs=action_specs,
72
+ )
73
+ return result
74
+
75
+
76
+ async def load(hub: Hub, config: dict[str, Any]) -> None:
77
+ built: dict[str, dict[str, Any]] = {}
78
+ for section_name in hub.AUTOMATION_SECTIONS:
79
+ data = config.get(section_name, {})
80
+ built[section_name] = build_section(section_name, data)
81
+
82
+ built_actions = build_actions(config.get("actions", {}))
83
+
84
+ old_config = hub.config
85
+ old_sections = {
86
+ name: getattr(hub, name) for name in hub.AUTOMATION_SECTIONS
87
+ }
88
+ old_actions = hub.actions
89
+
90
+ hub.config = config
91
+ for section_name, items in built.items():
92
+ setattr(hub, section_name, items)
93
+ hub.actions = built_actions
94
+
95
+ activated: list[tuple[str, str]] = []
96
+ try:
97
+ for section_name in hub.AUTOMATION_SECTIONS:
98
+ for obj in hub.section(section_name).values():
99
+ obj._hub = hub
100
+ try:
101
+ await obj.on_validate(hub)
102
+ except ConfigLoadError:
103
+ raise
104
+ except Exception as e:
105
+ err = ConfigLoadError(
106
+ section=section_name, instance=obj.instance_name,
107
+ phase=LoadPhase.VALIDATE,
108
+ code=LoadErrorCode.VALIDATION_FAILED, cause=e,
109
+ )
110
+ hub.notify("on_load_error", section_name,
111
+ obj.instance_name, err.phase, err.code, e)
112
+ raise err from e
113
+
114
+ for composite in hub.actions.values():
115
+ composite.validate(hub)
116
+
117
+ for section_name in hub.AUTOMATION_SECTIONS:
118
+ for name, obj in hub.section(section_name).items():
119
+ await obj.on_activate(hub)
120
+ activated.append((section_name, name))
121
+ except Exception:
122
+ for sec, n in reversed(activated):
123
+ try:
124
+ await hub.section(sec)[n].on_stop()
125
+ except Exception:
126
+ logger.exception("Cleanup failed for %s.%s", sec, n)
127
+ hub.config = old_config
128
+ for section_name, items in old_sections.items():
129
+ setattr(hub, section_name, items)
130
+ hub.actions = old_actions
131
+ raise