powerrules 0.1.0b0__tar.gz
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.
- powerrules-0.1.0b0/LICENSE +21 -0
- powerrules-0.1.0b0/PKG-INFO +24 -0
- powerrules-0.1.0b0/README.md +2 -0
- powerrules-0.1.0b0/pyproject.toml +25 -0
- powerrules-0.1.0b0/src/powerrules/__main__.py +4 -0
- powerrules-0.1.0b0/src/powerrules/actions/base.py +11 -0
- powerrules-0.1.0b0/src/powerrules/actions/power.py +70 -0
- powerrules-0.1.0b0/src/powerrules/application/runtime.py +100 -0
- powerrules-0.1.0b0/src/powerrules/cli/app.py +114 -0
- powerrules-0.1.0b0/src/powerrules/cli/errors.py +113 -0
- powerrules-0.1.0b0/src/powerrules/conditions/base.py +14 -0
- powerrules-0.1.0b0/src/powerrules/conditions/datetime.py +86 -0
- powerrules-0.1.0b0/src/powerrules/conditions/operators.py +51 -0
- powerrules-0.1.0b0/src/powerrules/conditions/process.py +33 -0
- powerrules-0.1.0b0/src/powerrules/config/builder.py +179 -0
- powerrules-0.1.0b0/src/powerrules/config/loader.py +28 -0
- powerrules-0.1.0b0/src/powerrules/config/models.py +212 -0
- powerrules-0.1.0b0/src/powerrules/engine/exceptions.py +14 -0
- powerrules-0.1.0b0/src/powerrules/engine/models.py +28 -0
- powerrules-0.1.0b0/src/powerrules/engine/rule_engine.py +46 -0
- powerrules-0.1.0b0/src/powerrules/platform/windows/power.py +51 -0
- powerrules-0.1.0b0/src/powerrules/platform/windows/process.py +20 -0
- powerrules-0.1.0b0/src/powerrules/providers/clock.py +14 -0
- powerrules-0.1.0b0/src/powerrules/providers/power.py +19 -0
- powerrules-0.1.0b0/src/powerrules/providers/process.py +7 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 LeoTN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: powerrules
|
|
3
|
+
Version: 0.1.0b0
|
|
4
|
+
Summary: A rule-based computer power state management tool
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: LeoTN
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: psutil (>=7.0,<8.0)
|
|
17
|
+
Requires-Dist: pydantic (>=2.0,<3.0)
|
|
18
|
+
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
19
|
+
Requires-Dist: typer (>=0.27.1,<0.28.0)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# PowerRules
|
|
23
|
+
A rule-based computer power state management tool
|
|
24
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "powerrules"
|
|
3
|
+
version = "0.1.0b0"
|
|
4
|
+
description = "A rule-based computer power state management tool"
|
|
5
|
+
authors = ["LeoTN"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
packages = [{ include = "powerrules", from = "src" }]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = ">=3.10,<4.0"
|
|
12
|
+
pyyaml = "^6.0"
|
|
13
|
+
pydantic = "^2.0"
|
|
14
|
+
typer = "^0.27.1"
|
|
15
|
+
psutil = "^7.0"
|
|
16
|
+
|
|
17
|
+
[tool.poetry.group.dev.dependencies]
|
|
18
|
+
pytest = "^8.0"
|
|
19
|
+
|
|
20
|
+
[tool.poetry.scripts]
|
|
21
|
+
pwru = "powerrules.cli.app:app"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["poetry-core>=2.0.0"]
|
|
25
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from powerrules.engine.exceptions import ActionExecutionError
|
|
2
|
+
from powerrules.providers.power import PowerProvider
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ShutdownAction:
|
|
6
|
+
def __init__(self, power_provider: PowerProvider):
|
|
7
|
+
self.power_provider = power_provider
|
|
8
|
+
|
|
9
|
+
def execute(self) -> None:
|
|
10
|
+
"""Shut down the computer.
|
|
11
|
+
|
|
12
|
+
Raises:
|
|
13
|
+
ActionExecutionError: If the computer cannot be shut down.
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
self.power_provider.shutdown()
|
|
17
|
+
except Exception as e:
|
|
18
|
+
raise ActionExecutionError("Failed to shut down the computer") from e
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SleepAction:
|
|
22
|
+
def __init__(self, power_provider: PowerProvider):
|
|
23
|
+
self.power_provider = power_provider
|
|
24
|
+
|
|
25
|
+
def execute(self) -> None:
|
|
26
|
+
"""Put the computer into sleep mode.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ActionExecutionError: If the computer cannot be put into sleep mode.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
self.power_provider.sleep()
|
|
33
|
+
except Exception as e:
|
|
34
|
+
raise ActionExecutionError(
|
|
35
|
+
"Failed to put the computer into sleep mode"
|
|
36
|
+
) from e
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HibernateAction:
|
|
40
|
+
def __init__(self, power_provider: PowerProvider):
|
|
41
|
+
self.power_provider = power_provider
|
|
42
|
+
|
|
43
|
+
def execute(self) -> None:
|
|
44
|
+
"""Put the computer into hibernation.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ActionExecutionError: If the computer cannot be put into hibernation.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
self.power_provider.hibernate()
|
|
51
|
+
except Exception as e:
|
|
52
|
+
raise ActionExecutionError(
|
|
53
|
+
"Failed to put the computer into hibernation"
|
|
54
|
+
) from e
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RebootAction:
|
|
58
|
+
def __init__(self, power_provider: PowerProvider):
|
|
59
|
+
self.power_provider = power_provider
|
|
60
|
+
|
|
61
|
+
def execute(self) -> None:
|
|
62
|
+
"""Reboot the computer.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ActionExecutionError: If the computer cannot be rebooted.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
self.power_provider.reboot()
|
|
69
|
+
except Exception as e:
|
|
70
|
+
raise ActionExecutionError("Failed to reboot the computer") from e
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from powerrules.config.builder import ConfigurationBuilder
|
|
5
|
+
from powerrules.config.loader import ConfigurationLoader
|
|
6
|
+
from powerrules.engine.models import Rule, RuleEvaluationResult
|
|
7
|
+
from powerrules.engine.rule_engine import RuleEngine
|
|
8
|
+
from powerrules.platform.windows.power import WindowsPowerProvider
|
|
9
|
+
from powerrules.platform.windows.process import WindowsProcessProvider
|
|
10
|
+
from powerrules.providers.clock import SystemClockProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PowerRulesRuntime:
|
|
14
|
+
"""Coordinate PowerRules configuration loading and rule evaluation and action execution."""
|
|
15
|
+
|
|
16
|
+
def run_once(self, configuration_path: Path) -> RuleEvaluationResult:
|
|
17
|
+
"""Load and evaluate the configured policy once.
|
|
18
|
+
|
|
19
|
+
The policy is loaded only for this evaluation.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
configuration_path: Path to the PowerRules policy file.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The result of the rule evaluation.
|
|
26
|
+
"""
|
|
27
|
+
rule_engine = self._build_rule_engine(configuration_path)
|
|
28
|
+
|
|
29
|
+
return rule_engine.evaluate()
|
|
30
|
+
|
|
31
|
+
def run_continuously(
|
|
32
|
+
self,
|
|
33
|
+
configuration_path: Path,
|
|
34
|
+
evaluation_interval: float = 10.0,
|
|
35
|
+
stop_on_match: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Load a policy once and continuously evaluate it.
|
|
38
|
+
|
|
39
|
+
The policy is loaded only once when the method starts.
|
|
40
|
+
Changes to the policy file are ignored until the process is restarted.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
configuration_path: Path to the PowerRules policy file.
|
|
44
|
+
evaluation_interval: Delay between evaluations in seconds.
|
|
45
|
+
stop_on_match: Stop the evaluation when a rule matches.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValueError: If the evaluation interval is less than or equal to zero.
|
|
49
|
+
ConditionEvaluationError: If a condition cannot be evaluated.
|
|
50
|
+
ActionExecutionError: If a matching action cannot be executed.
|
|
51
|
+
"""
|
|
52
|
+
if evaluation_interval <= 0:
|
|
53
|
+
raise ValueError("Evaluation interval must be greater than zero")
|
|
54
|
+
|
|
55
|
+
rule_engine = self._build_rule_engine(configuration_path)
|
|
56
|
+
last_matched_rule: Rule | None = None
|
|
57
|
+
|
|
58
|
+
while True:
|
|
59
|
+
matched_rule = rule_engine.find_match()
|
|
60
|
+
|
|
61
|
+
if matched_rule is None:
|
|
62
|
+
last_matched_rule = None
|
|
63
|
+
|
|
64
|
+
elif matched_rule is not last_matched_rule:
|
|
65
|
+
matched_rule.action.execute()
|
|
66
|
+
last_matched_rule = matched_rule
|
|
67
|
+
|
|
68
|
+
if stop_on_match:
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
time.sleep(evaluation_interval)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _build_rule_engine(configuration_path: Path) -> RuleEngine:
|
|
75
|
+
"""Build a rule engine from a policy file.
|
|
76
|
+
|
|
77
|
+
The policy is loaded and built exactly once per invocation.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
configuration_path: Path to the PowerRules policy file.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A configured rule engine.
|
|
84
|
+
"""
|
|
85
|
+
configuration = ConfigurationLoader().load(configuration_path)
|
|
86
|
+
|
|
87
|
+
clock_provider = SystemClockProvider()
|
|
88
|
+
process_provider = WindowsProcessProvider()
|
|
89
|
+
power_provider = WindowsPowerProvider()
|
|
90
|
+
|
|
91
|
+
rule_set = ConfigurationBuilder(
|
|
92
|
+
# Information about the current date and time
|
|
93
|
+
clock_provider=clock_provider,
|
|
94
|
+
# Information about running processes
|
|
95
|
+
process_provider=process_provider,
|
|
96
|
+
# Basically an API to interact with the power state of the OS
|
|
97
|
+
power_provider=power_provider,
|
|
98
|
+
).build(configuration)
|
|
99
|
+
|
|
100
|
+
return RuleEngine(rule_set.rules)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from powerrules.application.runtime import PowerRulesRuntime
|
|
7
|
+
from powerrules.cli.errors import cli_command
|
|
8
|
+
from powerrules.config.loader import ConfigurationLoader
|
|
9
|
+
|
|
10
|
+
# Main application
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="pwru",
|
|
13
|
+
help="A rule-based computer power state management tool.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Policy subcommand
|
|
18
|
+
policy_app = typer.Typer(
|
|
19
|
+
name="policy",
|
|
20
|
+
help="Manage PowerRules policies.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app.add_typer(policy_app, name="policy")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def version_callback(value: bool) -> None:
|
|
28
|
+
"""Display the installed PowerRules version."""
|
|
29
|
+
if value:
|
|
30
|
+
typer.echo(f"PowerRules {version('powerrules')}")
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback()
|
|
35
|
+
def main(
|
|
36
|
+
version: bool = typer.Option(
|
|
37
|
+
False,
|
|
38
|
+
"--version",
|
|
39
|
+
help="Display the installed PowerRules version.",
|
|
40
|
+
callback=version_callback,
|
|
41
|
+
is_eager=True,
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""A rule-based computer power state management tool."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@policy_app.command("validate")
|
|
48
|
+
@cli_command
|
|
49
|
+
def validate(
|
|
50
|
+
policy: Path = typer.Option(
|
|
51
|
+
Path("powerrules.yaml"),
|
|
52
|
+
"--policy",
|
|
53
|
+
"-p",
|
|
54
|
+
help="Path to the PowerRules policy file.",
|
|
55
|
+
),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Validate a PowerRules policy file."""
|
|
58
|
+
ConfigurationLoader().load(policy)
|
|
59
|
+
|
|
60
|
+
typer.echo("[INFO] Policy is valid")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@policy_app.command("show")
|
|
64
|
+
@cli_command
|
|
65
|
+
def show(
|
|
66
|
+
policy: Path = typer.Option(
|
|
67
|
+
Path("powerrules.yaml"),
|
|
68
|
+
"--policy",
|
|
69
|
+
"-p",
|
|
70
|
+
help="Path to the PowerRules policy file.",
|
|
71
|
+
),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Display the configured rules of a PowerRules policy."""
|
|
74
|
+
policy_configuration = ConfigurationLoader().load(policy)
|
|
75
|
+
|
|
76
|
+
for index, rule in enumerate(policy_configuration.rules, start=1):
|
|
77
|
+
status = "enabled" if rule.enabled else "disabled"
|
|
78
|
+
typer.echo(f"{index}. {rule.name} [{status}]")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@policy_app.command("run")
|
|
82
|
+
@cli_command
|
|
83
|
+
def run(
|
|
84
|
+
once: bool = typer.Option(
|
|
85
|
+
False,
|
|
86
|
+
"--once",
|
|
87
|
+
help="Evaluate the policy once and then exit.",
|
|
88
|
+
),
|
|
89
|
+
stop_on_match: bool = typer.Option(
|
|
90
|
+
False,
|
|
91
|
+
"--stop-on-match",
|
|
92
|
+
help="Stop the continuous evaluation after the first rule match.",
|
|
93
|
+
),
|
|
94
|
+
policy: Path = typer.Option(
|
|
95
|
+
Path("powerrules.yaml"),
|
|
96
|
+
"--policy",
|
|
97
|
+
"-p",
|
|
98
|
+
help="Path to the PowerRules policy file.",
|
|
99
|
+
),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Evaluate a PowerRules policy continuously or once."""
|
|
102
|
+
runtime = PowerRulesRuntime()
|
|
103
|
+
|
|
104
|
+
if once:
|
|
105
|
+
result = runtime.run_once(configuration_path=policy)
|
|
106
|
+
|
|
107
|
+
if result.matched_rule is None:
|
|
108
|
+
typer.echo("[INFO] No rule matched")
|
|
109
|
+
else:
|
|
110
|
+
typer.echo(f"[INFO] Rule '{result.matched_rule.name}' matched")
|
|
111
|
+
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
runtime.run_continuously(configuration_path=policy, stop_on_match=stop_on_match)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Any, NoReturn, TypeVar
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from powerrules.engine.exceptions import (
|
|
10
|
+
ActionExecutionError,
|
|
11
|
+
ConditionEvaluationError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
EXIT_RUNTIME_ERROR = 1
|
|
15
|
+
EXIT_POLICY_ERROR = 2
|
|
16
|
+
|
|
17
|
+
ReturnType = TypeVar("ReturnType")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def handle_cli_error(error: Exception) -> NoReturn:
|
|
21
|
+
"""Handle a command execution error.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
error: Exception raised during command execution.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
typer.Exit: Always raised after displaying the error.
|
|
28
|
+
"""
|
|
29
|
+
# The policy file type is currently the only type being used in CLI commands
|
|
30
|
+
if isinstance(error, FileNotFoundError):
|
|
31
|
+
typer.echo(
|
|
32
|
+
f"[ERROR] Policy file not found: {error.filename}",
|
|
33
|
+
err=True,
|
|
34
|
+
)
|
|
35
|
+
raise typer.Exit(code=EXIT_POLICY_ERROR)
|
|
36
|
+
|
|
37
|
+
if isinstance(error, yaml.YAMLError):
|
|
38
|
+
typer.echo(
|
|
39
|
+
"[ERROR] Failed to parse policy file",
|
|
40
|
+
err=True,
|
|
41
|
+
)
|
|
42
|
+
raise typer.Exit(code=EXIT_POLICY_ERROR)
|
|
43
|
+
|
|
44
|
+
if isinstance(error, ValidationError):
|
|
45
|
+
typer.echo(
|
|
46
|
+
"[ERROR] Policy validation failed",
|
|
47
|
+
err=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Output the cryptic Pydantic errors anyway. This should be reworked in the future for a nicer output
|
|
51
|
+
for validation_error in error.errors():
|
|
52
|
+
location = ".".join(str(item) for item in validation_error["loc"])
|
|
53
|
+
message = validation_error["msg"]
|
|
54
|
+
|
|
55
|
+
typer.echo(
|
|
56
|
+
f"[ERROR] {location}: {message}",
|
|
57
|
+
err=True,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
raise typer.Exit(code=EXIT_POLICY_ERROR)
|
|
61
|
+
|
|
62
|
+
# The existing error messages for conditions and actions
|
|
63
|
+
if isinstance(error, ConditionEvaluationError):
|
|
64
|
+
typer.echo(
|
|
65
|
+
f"[ERROR] Failed to evaluate condition: {error}",
|
|
66
|
+
err=True,
|
|
67
|
+
)
|
|
68
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR)
|
|
69
|
+
|
|
70
|
+
if isinstance(error, ActionExecutionError):
|
|
71
|
+
typer.echo(
|
|
72
|
+
f"[ERROR] Failed to execute action: {error}",
|
|
73
|
+
err=True,
|
|
74
|
+
)
|
|
75
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR)
|
|
76
|
+
|
|
77
|
+
if isinstance(error, ValueError):
|
|
78
|
+
typer.echo(
|
|
79
|
+
f"[ERROR] {error}",
|
|
80
|
+
err=True,
|
|
81
|
+
)
|
|
82
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR)
|
|
83
|
+
|
|
84
|
+
typer.echo(
|
|
85
|
+
f"[ERROR] Unexpected error: {error}",
|
|
86
|
+
err=True,
|
|
87
|
+
)
|
|
88
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cli_command(
|
|
92
|
+
function: Callable[..., ReturnType],
|
|
93
|
+
) -> Callable[..., ReturnType]:
|
|
94
|
+
"""Wrap a CLI command with centralized error handling.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
function: CLI command function.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Wrapped CLI command function.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
@wraps(function)
|
|
104
|
+
def wrapper(*args: Any, **kwargs: Any) -> ReturnType:
|
|
105
|
+
try:
|
|
106
|
+
return function(*args, **kwargs)
|
|
107
|
+
except typer.Exit:
|
|
108
|
+
raise
|
|
109
|
+
except Exception as e:
|
|
110
|
+
# Handle errors which result from CLI commands. This avoids tracebacks and instead shows a readable error message
|
|
111
|
+
handle_cli_error(e)
|
|
112
|
+
|
|
113
|
+
return wrapper
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Condition(Protocol):
|
|
5
|
+
def evaluate(self) -> bool:
|
|
6
|
+
"""Evaluate the condition.
|
|
7
|
+
|
|
8
|
+
Returns:
|
|
9
|
+
True if the condition matches, otherwise False.
|
|
10
|
+
|
|
11
|
+
Raises:
|
|
12
|
+
ConditionEvaluationError: If the condition cannot be evaluated.
|
|
13
|
+
"""
|
|
14
|
+
...
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import time
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from powerrules.providers.clock import ClockProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Weekday(StrEnum):
|
|
9
|
+
"""Represent all weekdays."""
|
|
10
|
+
|
|
11
|
+
MONDAY = "Monday"
|
|
12
|
+
TUESDAY = "Tuesday"
|
|
13
|
+
WEDNESDAY = "Wednesday"
|
|
14
|
+
THURSDAY = "Thursday"
|
|
15
|
+
FRIDAY = "Friday"
|
|
16
|
+
SATURDAY = "Saturday"
|
|
17
|
+
SUNDAY = "Sunday"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class TimeRange:
|
|
22
|
+
"""Represents a range of time within a day."""
|
|
23
|
+
|
|
24
|
+
start: time
|
|
25
|
+
end: time
|
|
26
|
+
|
|
27
|
+
def contains(self, current_time: time) -> bool:
|
|
28
|
+
"""Return whether the given time is within the range.
|
|
29
|
+
|
|
30
|
+
The start time is inclusive and the end time is exclusive (6:00 is in the range 23:00-7:00, but 7:00 is not).
|
|
31
|
+
|
|
32
|
+
Ranges crossing midnight are supported.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
current_time: Time to check.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
True if the time is within the range, otherwise False.
|
|
39
|
+
"""
|
|
40
|
+
# The range does not cross midnight
|
|
41
|
+
if self.start <= self.end:
|
|
42
|
+
# Does the current_time fall within the range?
|
|
43
|
+
return self.start <= current_time < self.end
|
|
44
|
+
|
|
45
|
+
# The range crosses midnight (e.g., 23:00-7:00)
|
|
46
|
+
# Does the current_time fall within the range?
|
|
47
|
+
return current_time >= self.start or current_time < self.end
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DateTimeCondition:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
clock_provider: ClockProvider,
|
|
54
|
+
*,
|
|
55
|
+
time_range: TimeRange | None = None,
|
|
56
|
+
weekdays: frozenset[Weekday] | None = None,
|
|
57
|
+
):
|
|
58
|
+
self.clock_provider = clock_provider
|
|
59
|
+
self.time_range = time_range
|
|
60
|
+
self.weekdays = weekdays
|
|
61
|
+
|
|
62
|
+
def evaluate(self) -> bool:
|
|
63
|
+
"""Evaluate the configured date and time condition.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if the current date and time matches the condition,
|
|
67
|
+
otherwise False.
|
|
68
|
+
"""
|
|
69
|
+
current_datetime = self.clock_provider.now()
|
|
70
|
+
|
|
71
|
+
if self.time_range is not None:
|
|
72
|
+
return self.time_range.contains(current_datetime.time())
|
|
73
|
+
|
|
74
|
+
if self.weekdays is not None:
|
|
75
|
+
current_weekday = (
|
|
76
|
+
Weekday.MONDAY,
|
|
77
|
+
Weekday.TUESDAY,
|
|
78
|
+
Weekday.WEDNESDAY,
|
|
79
|
+
Weekday.THURSDAY,
|
|
80
|
+
Weekday.FRIDAY,
|
|
81
|
+
Weekday.SATURDAY,
|
|
82
|
+
Weekday.SUNDAY,
|
|
83
|
+
)[current_datetime.weekday()]
|
|
84
|
+
return current_weekday in self.weekdays
|
|
85
|
+
|
|
86
|
+
return False
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
from powerrules.conditions.base import Condition
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AndCondition:
|
|
7
|
+
def __init__(self, conditions: Sequence[Condition]):
|
|
8
|
+
self.conditions = tuple(conditions)
|
|
9
|
+
|
|
10
|
+
def evaluate(self) -> bool:
|
|
11
|
+
"""Evaluate all conditions using logical AND.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
True if all conditions match, otherwise False.
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
ConditionEvaluationError: If a condition cannot be evaluated.
|
|
18
|
+
"""
|
|
19
|
+
return all(condition.evaluate() for condition in self.conditions)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OrCondition:
|
|
23
|
+
def __init__(self, conditions: Sequence[Condition]):
|
|
24
|
+
self.conditions = tuple(conditions)
|
|
25
|
+
|
|
26
|
+
def evaluate(self) -> bool:
|
|
27
|
+
"""Evaluate all conditions using logical OR.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
True if at least one condition matches, otherwise False.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ConditionEvaluationError: If a condition cannot be evaluated.
|
|
34
|
+
"""
|
|
35
|
+
return any(condition.evaluate() for condition in self.conditions)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotCondition:
|
|
39
|
+
def __init__(self, condition: Condition):
|
|
40
|
+
self.condition = condition
|
|
41
|
+
|
|
42
|
+
def evaluate(self) -> bool:
|
|
43
|
+
"""Evaluate the condition using logical NOT.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if the condition does not match, otherwise False.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ConditionEvaluationError: If the condition cannot be evaluated.
|
|
50
|
+
"""
|
|
51
|
+
return not self.condition.evaluate()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from powerrules.engine.exceptions import ConditionEvaluationError
|
|
2
|
+
from powerrules.providers.process import ProcessProvider
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ProcessCondition:
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
process_name: str,
|
|
9
|
+
expected_running: bool,
|
|
10
|
+
# Basically a wrapper object to interact with the OS to provide information about the running processes
|
|
11
|
+
process_provider: ProcessProvider,
|
|
12
|
+
):
|
|
13
|
+
self.process_name = process_name
|
|
14
|
+
self.expected_running = expected_running
|
|
15
|
+
self.process_provider = process_provider
|
|
16
|
+
|
|
17
|
+
def evaluate(self) -> bool:
|
|
18
|
+
"""Evaluate whether the configured process is in the expected state.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
True if the process state matches the expected state.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ConditionEvaluationError: If the process state cannot be determined.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
is_running = self.process_provider.is_running(self.process_name)
|
|
28
|
+
except Exception as e:
|
|
29
|
+
raise ConditionEvaluationError(
|
|
30
|
+
f"Failed to determine whether process '{self.process_name}' is running."
|
|
31
|
+
) from e
|
|
32
|
+
|
|
33
|
+
return is_running == self.expected_running
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from powerrules.actions.base import Action
|
|
2
|
+
from powerrules.actions.power import (
|
|
3
|
+
HibernateAction,
|
|
4
|
+
RebootAction,
|
|
5
|
+
ShutdownAction,
|
|
6
|
+
SleepAction,
|
|
7
|
+
)
|
|
8
|
+
from powerrules.conditions.base import Condition
|
|
9
|
+
from powerrules.conditions.datetime import DateTimeCondition, TimeRange
|
|
10
|
+
from powerrules.conditions.operators import AndCondition, NotCondition, OrCondition
|
|
11
|
+
from powerrules.conditions.process import ProcessCondition
|
|
12
|
+
from powerrules.config.models import (
|
|
13
|
+
ActionConfiguration,
|
|
14
|
+
ConditionConfiguration,
|
|
15
|
+
DateTimeConditionConfiguration,
|
|
16
|
+
ProcessConditionConfiguration,
|
|
17
|
+
RuleConfiguration,
|
|
18
|
+
RuleSetConfiguration,
|
|
19
|
+
)
|
|
20
|
+
from powerrules.engine.exceptions import ConfigurationError
|
|
21
|
+
from powerrules.engine.models import Rule, RuleSet
|
|
22
|
+
from powerrules.providers.clock import ClockProvider
|
|
23
|
+
from powerrules.providers.power import PowerProvider
|
|
24
|
+
from powerrules.providers.process import ProcessProvider
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigurationBuilder:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
clock_provider: ClockProvider,
|
|
31
|
+
process_provider: ProcessProvider,
|
|
32
|
+
power_provider: PowerProvider,
|
|
33
|
+
):
|
|
34
|
+
self.clock_provider = clock_provider
|
|
35
|
+
self.process_provider = process_provider
|
|
36
|
+
self.power_provider = power_provider
|
|
37
|
+
|
|
38
|
+
def build(self, configuration: RuleSetConfiguration) -> RuleSet:
|
|
39
|
+
"""Build a rule set from the validated configuration. This allows the rule engine to process the rules.
|
|
40
|
+
|
|
41
|
+
NOTE: The builder dos NOT validate the configuration. This needs to be done in advance.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
configuration: Validated (with Pydantic) PowerRules configuration.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The executable rule set.
|
|
48
|
+
"""
|
|
49
|
+
rules = tuple(
|
|
50
|
+
self._build_rule(rule_configuration)
|
|
51
|
+
for rule_configuration in configuration.rules
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return RuleSet(rules=rules)
|
|
55
|
+
|
|
56
|
+
def _build_rule(self, rule_configuration: RuleConfiguration) -> Rule:
|
|
57
|
+
"""Build a domain rule (which can be processed by the rule engine) from its configuration.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
rule_configuration: Configuration of the rule.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The executable rule.
|
|
64
|
+
"""
|
|
65
|
+
return Rule(
|
|
66
|
+
name=rule_configuration.name,
|
|
67
|
+
enabled=rule_configuration.enabled,
|
|
68
|
+
condition=self._build_condition(rule_configuration.conditions),
|
|
69
|
+
action=self._build_action(rule_configuration.action),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _build_condition(
|
|
73
|
+
self,
|
|
74
|
+
condition_configuration: ConditionConfiguration,
|
|
75
|
+
) -> Condition:
|
|
76
|
+
"""Build a condition from its configuration.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
condition_configuration: Configuration of the condition.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The executable condition.
|
|
83
|
+
"""
|
|
84
|
+
if condition_configuration.and_conditions is not None:
|
|
85
|
+
return AndCondition(
|
|
86
|
+
conditions=tuple(
|
|
87
|
+
self._build_condition(condition)
|
|
88
|
+
for condition in condition_configuration.and_conditions
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if condition_configuration.or_conditions is not None:
|
|
93
|
+
return OrCondition(
|
|
94
|
+
conditions=tuple(
|
|
95
|
+
self._build_condition(condition)
|
|
96
|
+
for condition in condition_configuration.or_conditions
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if condition_configuration.not_condition is not None:
|
|
101
|
+
return NotCondition(
|
|
102
|
+
condition=self._build_condition(condition_configuration.not_condition)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if condition_configuration.process is not None:
|
|
106
|
+
return self._build_process_condition(condition_configuration.process)
|
|
107
|
+
|
|
108
|
+
if condition_configuration.datetime is not None:
|
|
109
|
+
return self._build_datetime_condition(condition_configuration.datetime)
|
|
110
|
+
|
|
111
|
+
raise ConfigurationError("Condition configuration does not contain a condition")
|
|
112
|
+
|
|
113
|
+
def _build_process_condition(
|
|
114
|
+
self,
|
|
115
|
+
configuration: ProcessConditionConfiguration,
|
|
116
|
+
) -> ProcessCondition:
|
|
117
|
+
"""Build a process condition.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
configuration: Process condition configuration.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The executable process condition.
|
|
124
|
+
"""
|
|
125
|
+
return ProcessCondition(
|
|
126
|
+
process_name=configuration.name,
|
|
127
|
+
expected_running=configuration.running,
|
|
128
|
+
process_provider=self.process_provider,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _build_datetime_condition(
|
|
132
|
+
self,
|
|
133
|
+
configuration: DateTimeConditionConfiguration,
|
|
134
|
+
) -> DateTimeCondition:
|
|
135
|
+
"""Build a datetime condition from its configuration.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
configuration: DateTime condition configuration.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The executable datetime condition.
|
|
142
|
+
"""
|
|
143
|
+
if configuration.between is not None:
|
|
144
|
+
return DateTimeCondition(
|
|
145
|
+
clock_provider=self.clock_provider,
|
|
146
|
+
time_range=TimeRange(
|
|
147
|
+
start=configuration.between.start,
|
|
148
|
+
end=configuration.between.end,
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if configuration.weekday is not None:
|
|
153
|
+
return DateTimeCondition(
|
|
154
|
+
clock_provider=self.clock_provider,
|
|
155
|
+
weekdays=frozenset(configuration.weekday),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
raise RuntimeError("Invalid datetime condition configuration")
|
|
159
|
+
|
|
160
|
+
def _build_action(self, configuration: ActionConfiguration) -> Action:
|
|
161
|
+
"""Build an action from its configuration.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
configuration: Action configuration.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The executable action.
|
|
168
|
+
"""
|
|
169
|
+
match configuration.type:
|
|
170
|
+
case "shutdown":
|
|
171
|
+
return ShutdownAction(self.power_provider)
|
|
172
|
+
case "sleep":
|
|
173
|
+
return SleepAction(self.power_provider)
|
|
174
|
+
case "hibernate":
|
|
175
|
+
return HibernateAction(self.power_provider)
|
|
176
|
+
case "reboot":
|
|
177
|
+
return RebootAction(self.power_provider)
|
|
178
|
+
|
|
179
|
+
raise ConfigurationError(f"Unsupported action type '{configuration.type}'")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
from powerrules.config.models import RuleSetConfiguration
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationLoader:
|
|
9
|
+
"""Load and validate PowerRules configuration files."""
|
|
10
|
+
|
|
11
|
+
def load(self, path: Path) -> RuleSetConfiguration:
|
|
12
|
+
"""Load and validate a PowerRules configuration file.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
path: Path to the YAML configuration file.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Validated PowerRules configuration.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
OSError: If the configuration file cannot be read.
|
|
22
|
+
yaml.YAMLError: If the YAML cannot be parsed.
|
|
23
|
+
ValidationError: If the configuration is invalid.
|
|
24
|
+
"""
|
|
25
|
+
with path.open("r", encoding="utf-8") as file:
|
|
26
|
+
data = yaml.safe_load(file)
|
|
27
|
+
|
|
28
|
+
return RuleSetConfiguration.model_validate(data)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from datetime import time
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import (
|
|
5
|
+
BaseModel,
|
|
6
|
+
ConfigDict,
|
|
7
|
+
Field,
|
|
8
|
+
StrictBool,
|
|
9
|
+
field_validator,
|
|
10
|
+
model_validator,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from powerrules.conditions.datetime import Weekday
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProcessConditionConfiguration(BaseModel):
|
|
17
|
+
"""Configuration for a process condition."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
running: StrictBool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TimeRangeConfiguration(BaseModel):
|
|
26
|
+
"""Configuration for a datetime range."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="forbid")
|
|
29
|
+
|
|
30
|
+
start: time
|
|
31
|
+
end: time
|
|
32
|
+
|
|
33
|
+
@field_validator("start", "end", mode="before")
|
|
34
|
+
@classmethod
|
|
35
|
+
def validate_time(cls, value: object) -> time:
|
|
36
|
+
"""Validate and parse a configured time value.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
value: Value to validate and parse.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Parsed time value.
|
|
43
|
+
"""
|
|
44
|
+
return _parse_time(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DateTimeConditionConfiguration(BaseModel):
|
|
48
|
+
"""Configuration for a datetime condition."""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(extra="forbid")
|
|
51
|
+
|
|
52
|
+
between: TimeRangeConfiguration | None = None
|
|
53
|
+
weekday: list[Weekday] | None = None
|
|
54
|
+
|
|
55
|
+
@model_validator(mode="after")
|
|
56
|
+
def validate_variant(self) -> "DateTimeConditionConfiguration":
|
|
57
|
+
"""Validate that exactly one datetime variant is configured.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The validated configuration.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If zero or multiple variants are configured.
|
|
64
|
+
"""
|
|
65
|
+
configured_variants = sum(
|
|
66
|
+
value is not None
|
|
67
|
+
for value in (
|
|
68
|
+
self.between,
|
|
69
|
+
self.weekday,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if configured_variants != 1:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"A datetime condition must define exactly one of 'between' or 'weekday'"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ConditionConfiguration(BaseModel):
|
|
82
|
+
"""Configuration for a condition tree."""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(
|
|
85
|
+
extra="forbid",
|
|
86
|
+
validate_by_name=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
and_conditions: list["ConditionConfiguration"] | None = Field(
|
|
90
|
+
default=None,
|
|
91
|
+
validation_alias="and",
|
|
92
|
+
serialization_alias="and",
|
|
93
|
+
)
|
|
94
|
+
or_conditions: list["ConditionConfiguration"] | None = Field(
|
|
95
|
+
default=None,
|
|
96
|
+
validation_alias="or",
|
|
97
|
+
serialization_alias="or",
|
|
98
|
+
)
|
|
99
|
+
not_condition: "ConditionConfiguration | None" = Field(
|
|
100
|
+
default=None,
|
|
101
|
+
validation_alias="not",
|
|
102
|
+
serialization_alias="not",
|
|
103
|
+
)
|
|
104
|
+
process: ProcessConditionConfiguration | None = None
|
|
105
|
+
datetime: DateTimeConditionConfiguration | None = None
|
|
106
|
+
|
|
107
|
+
@model_validator(mode="after")
|
|
108
|
+
def validate_variant(self) -> "ConditionConfiguration":
|
|
109
|
+
configured_variants = sum(
|
|
110
|
+
value is not None
|
|
111
|
+
for value in (
|
|
112
|
+
self.and_conditions,
|
|
113
|
+
self.or_conditions,
|
|
114
|
+
self.not_condition,
|
|
115
|
+
self.process,
|
|
116
|
+
self.datetime,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if configured_variants != 1:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"A condition must define exactly one of 'and', 'or', 'not', 'process', or 'datetime'"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if self.and_conditions is not None and len(self.and_conditions) < 2:
|
|
126
|
+
raise ValueError("An 'and' condition must contain at least two conditions")
|
|
127
|
+
|
|
128
|
+
if self.or_conditions is not None and len(self.or_conditions) < 2:
|
|
129
|
+
raise ValueError("An 'or' condition must contain at least two conditions")
|
|
130
|
+
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ActionConfiguration(BaseModel):
|
|
135
|
+
"""Configuration for an action."""
|
|
136
|
+
|
|
137
|
+
model_config = ConfigDict(extra="forbid")
|
|
138
|
+
|
|
139
|
+
type: Literal[
|
|
140
|
+
"shutdown",
|
|
141
|
+
"sleep",
|
|
142
|
+
"hibernate",
|
|
143
|
+
"reboot",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class RuleConfiguration(BaseModel):
|
|
148
|
+
"""Configuration for a single rule."""
|
|
149
|
+
|
|
150
|
+
model_config = ConfigDict(extra="forbid")
|
|
151
|
+
|
|
152
|
+
name: str
|
|
153
|
+
enabled: StrictBool = True
|
|
154
|
+
conditions: ConditionConfiguration
|
|
155
|
+
action: ActionConfiguration
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class RuleSetConfiguration(BaseModel):
|
|
159
|
+
"""PowerRules YAML configuration."""
|
|
160
|
+
|
|
161
|
+
model_config = ConfigDict(extra="forbid")
|
|
162
|
+
|
|
163
|
+
rules: list[RuleConfiguration]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_time(value: object) -> time:
|
|
167
|
+
"""Parse a supported time configuration value.
|
|
168
|
+
|
|
169
|
+
Supported formats are H, HH, H:MM, HH:MM, H:MM:SS, and HH:MM:SS.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
value: Value to parse.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Parsed time value.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If the value is not a supported time format.
|
|
179
|
+
"""
|
|
180
|
+
if isinstance(value, time):
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
if not isinstance(value, str):
|
|
184
|
+
raise ValueError("Time value must be a string")
|
|
185
|
+
|
|
186
|
+
parts = value.split(":")
|
|
187
|
+
|
|
188
|
+
if not 1 <= len(parts) <= 3:
|
|
189
|
+
raise ValueError(f"Invalid time format '{value}', expected H, H:MM, or H:MM:SS")
|
|
190
|
+
|
|
191
|
+
if not all(part.isdigit() for part in parts):
|
|
192
|
+
raise ValueError(f"Invalid time format '{value}', expected H, H:MM, or H:MM:SS")
|
|
193
|
+
|
|
194
|
+
hour = parts[0]
|
|
195
|
+
|
|
196
|
+
if not 1 <= len(hour) <= 2:
|
|
197
|
+
raise ValueError(f"Invalid hour format '{hour}', expected H or HH")
|
|
198
|
+
|
|
199
|
+
if len(parts) > 1 and len(parts[1]) != 2:
|
|
200
|
+
raise ValueError(f"Invalid minute format '{parts[1]}', expected MM")
|
|
201
|
+
|
|
202
|
+
if len(parts) > 2 and len(parts[2]) != 2:
|
|
203
|
+
raise ValueError(f"Invalid second format '{parts[2]}', expected SS")
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
return time(
|
|
207
|
+
hour=int(hour),
|
|
208
|
+
minute=int(parts[1]) if len(parts) > 1 else 0,
|
|
209
|
+
second=int(parts[2]) if len(parts) > 2 else 0,
|
|
210
|
+
)
|
|
211
|
+
except ValueError as e:
|
|
212
|
+
raise ValueError(f"Invalid time value '{value}'") from e
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class PowerRulesError(Exception):
|
|
2
|
+
"""Base exception for PowerRules errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConditionEvaluationError(PowerRulesError):
|
|
6
|
+
"""Raised when a condition cannot be evaluated."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ActionExecutionError(PowerRulesError):
|
|
10
|
+
"""Raised when an action cannot be executed."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigurationError(PowerRulesError):
|
|
14
|
+
"""Raised when a configuration cannot be converted into a rule set."""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from powerrules.actions.base import Action
|
|
4
|
+
from powerrules.conditions.base import Condition
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Rule:
|
|
9
|
+
"""A rule consisting of a condition and an action."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
condition: Condition
|
|
13
|
+
action: Action
|
|
14
|
+
enabled: bool = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RuleSet:
|
|
19
|
+
"""Represent an ordered set of PowerRules rules."""
|
|
20
|
+
|
|
21
|
+
rules: tuple[Rule, ...]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class RuleEvaluationResult:
|
|
26
|
+
"""Represent the result of evaluating the rule set."""
|
|
27
|
+
|
|
28
|
+
matched_rule: Rule | None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
from powerrules.engine.models import Rule, RuleEvaluationResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RuleEngine:
|
|
7
|
+
def __init__(self, rules: Sequence[Rule]):
|
|
8
|
+
self.rules = tuple(rules)
|
|
9
|
+
|
|
10
|
+
def find_match(self) -> Rule | None:
|
|
11
|
+
"""Find the first enabled rule whose condition matches.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
The first matching rule, or None if no rule matches.
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
ConditionEvaluationError: If a condition cannot be evaluated.
|
|
18
|
+
"""
|
|
19
|
+
for rule in self.rules:
|
|
20
|
+
if not rule.enabled:
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
if rule.condition.evaluate():
|
|
24
|
+
return rule
|
|
25
|
+
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def evaluate(self) -> RuleEvaluationResult:
|
|
29
|
+
"""Evaluate rules and execute the first matching action.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The result of the rule evaluation.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ConditionEvaluationError: If a condition cannot be evaluated.
|
|
36
|
+
ActionExecutionError: If a matching action cannot be executed.
|
|
37
|
+
"""
|
|
38
|
+
matched_rule = self.find_match()
|
|
39
|
+
|
|
40
|
+
if matched_rule is None:
|
|
41
|
+
return RuleEvaluationResult(matched_rule=None)
|
|
42
|
+
|
|
43
|
+
# Execute the action, e.g. reboot or shutdown etc.
|
|
44
|
+
matched_rule.action.execute()
|
|
45
|
+
|
|
46
|
+
return RuleEvaluationResult(matched_rule=matched_rule)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WindowsPowerProvider:
|
|
6
|
+
"""Provide power management operations on Windows."""
|
|
7
|
+
|
|
8
|
+
def shutdown(self) -> None:
|
|
9
|
+
"""Shut down the computer."""
|
|
10
|
+
subprocess.run(
|
|
11
|
+
["shutdown.exe", "/s", "/t", "0"],
|
|
12
|
+
check=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def sleep(self) -> None:
|
|
16
|
+
"""Put the computer into sleep mode."""
|
|
17
|
+
self._set_suspend_state(hibernate=False)
|
|
18
|
+
|
|
19
|
+
def hibernate(self) -> None:
|
|
20
|
+
"""Put the computer into hibernation."""
|
|
21
|
+
self._set_suspend_state(hibernate=True)
|
|
22
|
+
|
|
23
|
+
def reboot(self) -> None:
|
|
24
|
+
"""Reboot the computer."""
|
|
25
|
+
subprocess.run(
|
|
26
|
+
["shutdown.exe", "/r", "/t", "0"],
|
|
27
|
+
check=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _set_suspend_state(hibernate: bool) -> None:
|
|
32
|
+
"""Set the Windows suspend state.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
hibernate: True to hibernate, False to enter sleep mode.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
OSError: If Windows cannot change the system power state.
|
|
39
|
+
"""
|
|
40
|
+
result = ctypes.windll.powrprof.SetSuspendState(
|
|
41
|
+
hibernate,
|
|
42
|
+
False,
|
|
43
|
+
False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if not result:
|
|
47
|
+
error_code = ctypes.get_last_error()
|
|
48
|
+
raise OSError(
|
|
49
|
+
error_code,
|
|
50
|
+
f"Failed to change the Windows power state, error code {error_code}",
|
|
51
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import psutil
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WindowsProcessProvider:
|
|
5
|
+
"""Provide process information on Windows."""
|
|
6
|
+
|
|
7
|
+
def is_running(self, process_name: str) -> bool:
|
|
8
|
+
"""Return whether a process with the given name is running.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
process_name: Name of the process to search for.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
True if at least one matching process is running, otherwise False.
|
|
15
|
+
"""
|
|
16
|
+
for process in psutil.process_iter(["name"]):
|
|
17
|
+
if process.info["name"] == process_name:
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
return False
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ClockProvider(Protocol):
|
|
6
|
+
def now(self) -> datetime:
|
|
7
|
+
"""Return the current date and time."""
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SystemClockProvider:
|
|
12
|
+
def now(self) -> datetime:
|
|
13
|
+
"""Return the current system date and time."""
|
|
14
|
+
return datetime.now()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PowerProvider(Protocol):
|
|
5
|
+
def shutdown(self) -> None:
|
|
6
|
+
"""Shut down the computer."""
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
def sleep(self) -> None:
|
|
10
|
+
"""Put the computer into sleep mode."""
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
def hibernate(self) -> None:
|
|
14
|
+
"""Put the computer into hibernation."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
def reboot(self) -> None:
|
|
18
|
+
"""Reboot the computer."""
|
|
19
|
+
...
|