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.
- automation/__init__.py +25 -0
- automation/assistant.py +141 -0
- automation/builtins/__init__.py +27 -0
- automation/builtins/action/__init__.py +0 -0
- automation/builtins/action/call_entity_method.py +35 -0
- automation/builtins/action/delay.py +18 -0
- automation/builtins/action/log.py +20 -0
- automation/builtins/action/set_attribute.py +27 -0
- automation/builtins/entity/__init__.py +0 -0
- automation/builtins/entity/time.py +29 -0
- automation/builtins/entity/variable.py +81 -0
- automation/builtins/event/__init__.py +0 -0
- automation/builtins/event/_scheduled.py +34 -0
- automation/builtins/event/at.py +27 -0
- automation/builtins/event/callback.py +65 -0
- automation/builtins/event/every.py +32 -0
- automation/builtins/event/state_changed.py +58 -0
- automation/core/__init__.py +13 -0
- automation/core/action.py +22 -0
- automation/core/base.py +59 -0
- automation/core/composite_action.py +47 -0
- automation/core/entity.py +178 -0
- automation/core/event.py +87 -0
- automation/core/event_context.py +10 -0
- automation/core/trigger.py +151 -0
- automation/errors.py +42 -0
- automation/executor.py +46 -0
- automation/hub.py +52 -0
- automation/listeners/__init__.py +17 -0
- automation/listeners/base.py +23 -0
- automation/listeners/console.py +82 -0
- automation/listeners/instance_schema.py +26 -0
- automation/listeners/record.py +29 -0
- automation/listeners/trace.py +70 -0
- automation/listeners/type_schema.py +26 -0
- automation/loader.py +131 -0
- automation/renderer.py +311 -0
- automation/schema.py +142 -0
- automation/updater.py +84 -0
- python_library_automation-0.1.16.dist-info/METADATA +8 -0
- python_library_automation-0.1.16.dist-info/RECORD +42 -0
- 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
|