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.
@@ -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,2 @@
1
+ # PowerRules
2
+ A rule-based computer power state management tool
@@ -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,4 @@
1
+ from powerrules.cli.app import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,11 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class Action(Protocol):
5
+ def execute(self) -> None:
6
+ """Execute the action.
7
+
8
+ Raises:
9
+ ActionExecutionError: If the action cannot be executed.
10
+ """
11
+ ...
@@ -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
+ ...
@@ -0,0 +1,7 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class ProcessProvider(Protocol):
5
+ def is_running(self, process_name: str) -> bool:
6
+ """Return whether a process with the given name is running."""
7
+ ...