powerrules 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
powerrules/__main__.py ADDED
@@ -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)
powerrules/cli/app.py ADDED
@@ -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