arthexis 0.1.13__py3-none-any.whl → 0.1.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
  2. arthexis-0.1.15.dist-info/RECORD +110 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3795 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +149 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3637 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +840 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +952 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2168 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2201 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1764 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3830 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +769 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +209 -153
  99. pages/models.py +643 -426
  100. pages/tasks.py +74 -0
  101. pages/tests.py +3025 -2200
  102. pages/urls.py +26 -25
  103. pages/utils.py +23 -12
  104. pages/views.py +1176 -1128
  105. arthexis-0.1.13.dist-info/RECORD +0 -105
  106. nodes/actions.py +0 -70
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
  108. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
nodes/dns.py CHANGED
@@ -1,203 +1,203 @@
1
- from __future__ import annotations
2
-
3
- from collections import defaultdict
4
- from dataclasses import dataclass
5
- from typing import Iterable, Mapping, MutableMapping, TYPE_CHECKING
6
-
7
- import requests
8
- from django.utils import timezone
9
- from dns import exception as dns_exception
10
- from dns import resolver as dns_resolver
11
- from requests import Response
12
-
13
- if TYPE_CHECKING: # pragma: no cover - imported for type checking only
14
- from .models import DNSRecord, NodeManager
15
-
16
-
17
- @dataclass
18
- class DeploymentResult:
19
- deployed: list["DNSRecord"]
20
- failures: MutableMapping["DNSRecord", str]
21
- skipped: MutableMapping["DNSRecord", str]
22
-
23
-
24
- def _error_from_response(response: Response) -> str:
25
- try:
26
- payload = response.json()
27
- except ValueError:
28
- payload = None
29
-
30
- if isinstance(payload, dict):
31
- for key in ("message", "detail", "error"):
32
- message = payload.get(key)
33
- if message:
34
- return str(message)
35
- elif isinstance(payload, list) and payload:
36
- first = payload[0]
37
- if isinstance(first, Mapping) and "message" in first:
38
- return str(first["message"])
39
- return str(first)
40
-
41
- reason = response.reason or ""
42
- return f"{response.status_code} {reason}".strip()
43
-
44
-
45
- def deploy_records(manager: "NodeManager", records: Iterable["DNSRecord"]) -> DeploymentResult:
46
- filtered: list["DNSRecord"] = []
47
- skipped: MutableMapping["DNSRecord", str] = {}
48
- for record in records:
49
- if record.provider != record.Provider.GODADDY:
50
- skipped[record] = "Unsupported DNS provider"
51
- continue
52
- domain = record.get_domain(manager)
53
- if not domain:
54
- skipped[record] = "Domain is required for deployment"
55
- continue
56
- filtered.append(record)
57
-
58
- if not filtered:
59
- return DeploymentResult([], {}, skipped)
60
-
61
- session = requests.Session()
62
- session.headers.update(
63
- {
64
- "Authorization": manager.get_auth_header(),
65
- "Accept": "application/json",
66
- "Content-Type": "application/json",
67
- }
68
- )
69
- customer_id = manager.get_customer_id()
70
- if customer_id:
71
- session.headers["X-Shopper-Id"] = customer_id
72
-
73
- grouped: MutableMapping[tuple[str, str, str], list["DNSRecord"]] = defaultdict(list)
74
- for record in filtered:
75
- key = (
76
- record.get_domain(manager),
77
- record.record_type,
78
- record.get_name(),
79
- )
80
- grouped[key].append(record)
81
-
82
- deployed: list["DNSRecord"] = []
83
- failures: MutableMapping["DNSRecord", str] = {}
84
- now = timezone.now()
85
-
86
- base_url = manager.get_base_url()
87
- for (domain, record_type, name), grouped_records in grouped.items():
88
- payload = [record.to_godaddy_payload() for record in grouped_records]
89
- url = f"{base_url}/v1/domains/{domain}/records/{record_type}/{name or '@'}"
90
- try:
91
- response = session.put(url, json=payload, timeout=30)
92
- except requests.RequestException as exc:
93
- message = str(exc)
94
- for record in grouped_records:
95
- record.mark_error(message, manager=manager)
96
- failures[record] = message
97
- continue
98
-
99
- if response.status_code >= 400:
100
- message = _error_from_response(response)
101
- for record in grouped_records:
102
- record.mark_error(message, manager=manager)
103
- failures[record] = message
104
- continue
105
-
106
- for record in grouped_records:
107
- record.mark_deployed(manager=manager, timestamp=now)
108
- deployed.append(record)
109
-
110
- return DeploymentResult(deployed, failures, skipped)
111
-
112
-
113
- def create_resolver() -> dns_resolver.Resolver:
114
- return dns_resolver.Resolver()
115
-
116
-
117
- def _normalize_hostname(value: str) -> str:
118
- return value.rstrip(".").lower()
119
-
120
-
121
- def _extract_txt(rdata) -> str:
122
- strings = getattr(rdata, "strings", None)
123
- if strings:
124
- parts = []
125
- for segment in strings:
126
- if isinstance(segment, bytes):
127
- parts.append(segment.decode("utf-8", "ignore"))
128
- else:
129
- parts.append(str(segment))
130
- return "".join(parts)
131
- return str(rdata).strip('"')
132
-
133
-
134
- def _matches_record(record: "DNSRecord", rdata) -> bool:
135
- expected = (record.resolve_sigils("data") or "").strip()
136
- rtype = record.record_type
137
-
138
- if rtype == record.Type.A:
139
- return getattr(rdata, "address", str(rdata)) == expected
140
- if rtype == record.Type.AAAA:
141
- return getattr(rdata, "address", str(rdata)) == expected
142
- if rtype == record.Type.CNAME:
143
- actual = getattr(rdata, "target", None)
144
- if actual is None:
145
- return False
146
- return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
147
- if rtype == record.Type.NS:
148
- actual = getattr(rdata, "target", None)
149
- if actual is None:
150
- return False
151
- return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
152
- if rtype == record.Type.MX:
153
- host = getattr(rdata, "exchange", None)
154
- if host is None:
155
- return False
156
- host_match = _normalize_hostname(host.to_text()) == _normalize_hostname(expected)
157
- priority = getattr(rdata, "preference", None)
158
- priority_match = record.priority is None or priority == record.priority
159
- return host_match and priority_match
160
- if rtype == record.Type.SRV:
161
- target = getattr(rdata, "target", None)
162
- if target is None:
163
- return False
164
- target_match = _normalize_hostname(target.to_text()) == _normalize_hostname(expected)
165
- priority_match = record.priority is None or getattr(rdata, "priority", None) == record.priority
166
- weight_match = record.weight is None or getattr(rdata, "weight", None) == record.weight
167
- port_match = record.port is None or getattr(rdata, "port", None) == record.port
168
- return target_match and priority_match and weight_match and port_match
169
- if rtype == record.Type.TXT:
170
- actual = _extract_txt(rdata)
171
- return actual == expected
172
-
173
- return str(rdata).strip() == expected
174
-
175
-
176
- def validate_record(
177
- record: "DNSRecord", resolver: dns_resolver.Resolver | None = None
178
- ) -> tuple[bool, str]:
179
- resolver = resolver or create_resolver()
180
- fqdn = record.fqdn()
181
- if not fqdn:
182
- message = "Domain is required for validation"
183
- record.mark_error(message)
184
- return False, message
185
-
186
- try:
187
- answers = resolver.resolve(fqdn, record.record_type)
188
- except (dns_exception.DNSException, OSError) as exc:
189
- message = str(exc)
190
- record.mark_error(message)
191
- return False, message
192
-
193
- for rdata in answers:
194
- if _matches_record(record, rdata):
195
- record.last_verified_at = timezone.now()
196
- record.last_error = ""
197
- record.save(update_fields=["last_verified_at", "last_error"])
198
- return True, ""
199
-
200
- message = "DNS record does not match expected value"
201
- record.mark_error(message)
202
- return False, message
203
-
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass
5
+ from typing import Iterable, Mapping, MutableMapping, TYPE_CHECKING
6
+
7
+ import requests
8
+ from django.utils import timezone
9
+ from dns import exception as dns_exception
10
+ from dns import resolver as dns_resolver
11
+ from requests import Response
12
+
13
+ if TYPE_CHECKING: # pragma: no cover - imported for type checking only
14
+ from .models import DNSRecord, NodeManager
15
+
16
+
17
+ @dataclass
18
+ class DeploymentResult:
19
+ deployed: list["DNSRecord"]
20
+ failures: MutableMapping["DNSRecord", str]
21
+ skipped: MutableMapping["DNSRecord", str]
22
+
23
+
24
+ def _error_from_response(response: Response) -> str:
25
+ try:
26
+ payload = response.json()
27
+ except ValueError:
28
+ payload = None
29
+
30
+ if isinstance(payload, dict):
31
+ for key in ("message", "detail", "error"):
32
+ message = payload.get(key)
33
+ if message:
34
+ return str(message)
35
+ elif isinstance(payload, list) and payload:
36
+ first = payload[0]
37
+ if isinstance(first, Mapping) and "message" in first:
38
+ return str(first["message"])
39
+ return str(first)
40
+
41
+ reason = response.reason or ""
42
+ return f"{response.status_code} {reason}".strip()
43
+
44
+
45
+ def deploy_records(manager: "NodeManager", records: Iterable["DNSRecord"]) -> DeploymentResult:
46
+ filtered: list["DNSRecord"] = []
47
+ skipped: MutableMapping["DNSRecord", str] = {}
48
+ for record in records:
49
+ if record.provider != record.Provider.GODADDY:
50
+ skipped[record] = "Unsupported DNS provider"
51
+ continue
52
+ domain = record.get_domain(manager)
53
+ if not domain:
54
+ skipped[record] = "Domain is required for deployment"
55
+ continue
56
+ filtered.append(record)
57
+
58
+ if not filtered:
59
+ return DeploymentResult([], {}, skipped)
60
+
61
+ session = requests.Session()
62
+ session.headers.update(
63
+ {
64
+ "Authorization": manager.get_auth_header(),
65
+ "Accept": "application/json",
66
+ "Content-Type": "application/json",
67
+ }
68
+ )
69
+ customer_id = manager.get_customer_id()
70
+ if customer_id:
71
+ session.headers["X-Shopper-Id"] = customer_id
72
+
73
+ grouped: MutableMapping[tuple[str, str, str], list["DNSRecord"]] = defaultdict(list)
74
+ for record in filtered:
75
+ key = (
76
+ record.get_domain(manager),
77
+ record.record_type,
78
+ record.get_name(),
79
+ )
80
+ grouped[key].append(record)
81
+
82
+ deployed: list["DNSRecord"] = []
83
+ failures: MutableMapping["DNSRecord", str] = {}
84
+ now = timezone.now()
85
+
86
+ base_url = manager.get_base_url()
87
+ for (domain, record_type, name), grouped_records in grouped.items():
88
+ payload = [record.to_godaddy_payload() for record in grouped_records]
89
+ url = f"{base_url}/v1/domains/{domain}/records/{record_type}/{name or '@'}"
90
+ try:
91
+ response = session.put(url, json=payload, timeout=30)
92
+ except requests.RequestException as exc:
93
+ message = str(exc)
94
+ for record in grouped_records:
95
+ record.mark_error(message, manager=manager)
96
+ failures[record] = message
97
+ continue
98
+
99
+ if response.status_code >= 400:
100
+ message = _error_from_response(response)
101
+ for record in grouped_records:
102
+ record.mark_error(message, manager=manager)
103
+ failures[record] = message
104
+ continue
105
+
106
+ for record in grouped_records:
107
+ record.mark_deployed(manager=manager, timestamp=now)
108
+ deployed.append(record)
109
+
110
+ return DeploymentResult(deployed, failures, skipped)
111
+
112
+
113
+ def create_resolver() -> dns_resolver.Resolver:
114
+ return dns_resolver.Resolver()
115
+
116
+
117
+ def _normalize_hostname(value: str) -> str:
118
+ return value.rstrip(".").lower()
119
+
120
+
121
+ def _extract_txt(rdata) -> str:
122
+ strings = getattr(rdata, "strings", None)
123
+ if strings:
124
+ parts = []
125
+ for segment in strings:
126
+ if isinstance(segment, bytes):
127
+ parts.append(segment.decode("utf-8", "ignore"))
128
+ else:
129
+ parts.append(str(segment))
130
+ return "".join(parts)
131
+ return str(rdata).strip('"')
132
+
133
+
134
+ def _matches_record(record: "DNSRecord", rdata) -> bool:
135
+ expected = (record.resolve_sigils("data") or "").strip()
136
+ rtype = record.record_type
137
+
138
+ if rtype == record.Type.A:
139
+ return getattr(rdata, "address", str(rdata)) == expected
140
+ if rtype == record.Type.AAAA:
141
+ return getattr(rdata, "address", str(rdata)) == expected
142
+ if rtype == record.Type.CNAME:
143
+ actual = getattr(rdata, "target", None)
144
+ if actual is None:
145
+ return False
146
+ return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
147
+ if rtype == record.Type.NS:
148
+ actual = getattr(rdata, "target", None)
149
+ if actual is None:
150
+ return False
151
+ return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
152
+ if rtype == record.Type.MX:
153
+ host = getattr(rdata, "exchange", None)
154
+ if host is None:
155
+ return False
156
+ host_match = _normalize_hostname(host.to_text()) == _normalize_hostname(expected)
157
+ priority = getattr(rdata, "preference", None)
158
+ priority_match = record.priority is None or priority == record.priority
159
+ return host_match and priority_match
160
+ if rtype == record.Type.SRV:
161
+ target = getattr(rdata, "target", None)
162
+ if target is None:
163
+ return False
164
+ target_match = _normalize_hostname(target.to_text()) == _normalize_hostname(expected)
165
+ priority_match = record.priority is None or getattr(rdata, "priority", None) == record.priority
166
+ weight_match = record.weight is None or getattr(rdata, "weight", None) == record.weight
167
+ port_match = record.port is None or getattr(rdata, "port", None) == record.port
168
+ return target_match and priority_match and weight_match and port_match
169
+ if rtype == record.Type.TXT:
170
+ actual = _extract_txt(rdata)
171
+ return actual == expected
172
+
173
+ return str(rdata).strip() == expected
174
+
175
+
176
+ def validate_record(
177
+ record: "DNSRecord", resolver: dns_resolver.Resolver | None = None
178
+ ) -> tuple[bool, str]:
179
+ resolver = resolver or create_resolver()
180
+ fqdn = record.fqdn()
181
+ if not fqdn:
182
+ message = "Domain is required for validation"
183
+ record.mark_error(message)
184
+ return False, message
185
+
186
+ try:
187
+ answers = resolver.resolve(fqdn, record.record_type)
188
+ except (dns_exception.DNSException, OSError) as exc:
189
+ message = str(exc)
190
+ record.mark_error(message)
191
+ return False, message
192
+
193
+ for rdata in answers:
194
+ if _matches_record(record, rdata):
195
+ record.last_verified_at = timezone.now()
196
+ record.last_error = ""
197
+ record.save(update_fields=["last_verified_at", "last_error"])
198
+ return True, ""
199
+
200
+ message = "DNS record does not match expected value"
201
+ record.mark_error(message)
202
+ return False, message
203
+
nodes/feature_checks.py CHANGED
@@ -1,133 +1,133 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
- from typing import Any, Callable, Dict, Iterable, Optional
5
-
6
- from django.contrib import messages
7
-
8
- if False: # pragma: no cover - typing imports only
9
- from .models import Node, NodeFeature
10
-
11
-
12
- @dataclass(frozen=True)
13
- class FeatureCheckResult:
14
- """Outcome of a feature validation."""
15
-
16
- success: bool
17
- message: str
18
- level: int = messages.INFO
19
-
20
-
21
- FeatureCheck = Callable[["NodeFeature", Optional["Node"]], Any]
22
-
23
-
24
- class FeatureCheckRegistry:
25
- """Registry for feature validation callbacks."""
26
-
27
- def __init__(self) -> None:
28
- self._checks: Dict[str, FeatureCheck] = {}
29
- self._default_check: Optional[FeatureCheck] = None
30
-
31
- def register(self, slug: str) -> Callable[[FeatureCheck], FeatureCheck]:
32
- """Register ``func`` as the validator for ``slug``."""
33
-
34
- def decorator(func: FeatureCheck) -> FeatureCheck:
35
- self._checks[slug] = func
36
- return func
37
-
38
- return decorator
39
-
40
- def register_default(self, func: FeatureCheck) -> FeatureCheck:
41
- """Register ``func`` as the fallback validator."""
42
-
43
- self._default_check = func
44
- return func
45
-
46
- def get(self, slug: str) -> Optional[FeatureCheck]:
47
- return self._checks.get(slug)
48
-
49
- def items(self) -> Iterable[tuple[str, FeatureCheck]]:
50
- return self._checks.items()
51
-
52
- def run(
53
- self, feature: "NodeFeature", *, node: Optional["Node"] = None
54
- ) -> Optional[FeatureCheckResult]:
55
- check = self._checks.get(feature.slug)
56
- if check is None:
57
- check = self._default_check
58
- if check is None:
59
- return None
60
- result = check(feature, node)
61
- return self._normalize_result(feature, result)
62
-
63
- def _normalize_result(
64
- self, feature: "NodeFeature", result: Any
65
- ) -> FeatureCheckResult:
66
- if isinstance(result, FeatureCheckResult):
67
- return result
68
- if result is None:
69
- return FeatureCheckResult(
70
- True,
71
- f"{feature.display} check completed successfully.",
72
- messages.SUCCESS,
73
- )
74
- if isinstance(result, tuple) and len(result) >= 2:
75
- success, message, *rest = result
76
- level = rest[0] if rest else (
77
- messages.SUCCESS if success else messages.ERROR
78
- )
79
- return FeatureCheckResult(bool(success), str(message), int(level))
80
- if isinstance(result, bool):
81
- message = (
82
- f"{feature.display} check {'passed' if result else 'failed'}."
83
- )
84
- level = messages.SUCCESS if result else messages.ERROR
85
- return FeatureCheckResult(result, message, level)
86
- raise TypeError(
87
- f"Unsupported feature check result type: {type(result)!r}"
88
- )
89
-
90
-
91
- feature_checks = FeatureCheckRegistry()
92
-
93
-
94
- @feature_checks.register_default
95
- def _default_feature_check(
96
- feature: "NodeFeature", node: Optional["Node"]
97
- ) -> FeatureCheckResult:
98
- from .models import Node
99
-
100
- target: Optional["Node"] = node or Node.get_local()
101
- if target is None:
102
- return FeatureCheckResult(
103
- False,
104
- f"No local node is registered; cannot verify {feature.display}.",
105
- messages.WARNING,
106
- )
107
- try:
108
- enabled = feature.is_enabled
109
- except Exception as exc: # pragma: no cover - defensive
110
- return FeatureCheckResult(
111
- False,
112
- f"{feature.display} check failed: {exc}",
113
- messages.ERROR,
114
- )
115
- if enabled:
116
- return FeatureCheckResult(
117
- True,
118
- f"{feature.display} is enabled on {target.hostname}.",
119
- messages.SUCCESS,
120
- )
121
- return FeatureCheckResult(
122
- False,
123
- f"{feature.display} is not enabled on {target.hostname}.",
124
- messages.WARNING,
125
- )
126
-
127
-
128
- __all__ = [
129
- "FeatureCheck",
130
- "FeatureCheckRegistry",
131
- "FeatureCheckResult",
132
- "feature_checks",
133
- ]
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Dict, Iterable, Optional
5
+
6
+ from django.contrib import messages
7
+
8
+ if False: # pragma: no cover - typing imports only
9
+ from .models import Node, NodeFeature
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class FeatureCheckResult:
14
+ """Outcome of a feature validation."""
15
+
16
+ success: bool
17
+ message: str
18
+ level: int = messages.INFO
19
+
20
+
21
+ FeatureCheck = Callable[["NodeFeature", Optional["Node"]], Any]
22
+
23
+
24
+ class FeatureCheckRegistry:
25
+ """Registry for feature validation callbacks."""
26
+
27
+ def __init__(self) -> None:
28
+ self._checks: Dict[str, FeatureCheck] = {}
29
+ self._default_check: Optional[FeatureCheck] = None
30
+
31
+ def register(self, slug: str) -> Callable[[FeatureCheck], FeatureCheck]:
32
+ """Register ``func`` as the validator for ``slug``."""
33
+
34
+ def decorator(func: FeatureCheck) -> FeatureCheck:
35
+ self._checks[slug] = func
36
+ return func
37
+
38
+ return decorator
39
+
40
+ def register_default(self, func: FeatureCheck) -> FeatureCheck:
41
+ """Register ``func`` as the fallback validator."""
42
+
43
+ self._default_check = func
44
+ return func
45
+
46
+ def get(self, slug: str) -> Optional[FeatureCheck]:
47
+ return self._checks.get(slug)
48
+
49
+ def items(self) -> Iterable[tuple[str, FeatureCheck]]:
50
+ return self._checks.items()
51
+
52
+ def run(
53
+ self, feature: "NodeFeature", *, node: Optional["Node"] = None
54
+ ) -> Optional[FeatureCheckResult]:
55
+ check = self._checks.get(feature.slug)
56
+ if check is None:
57
+ check = self._default_check
58
+ if check is None:
59
+ return None
60
+ result = check(feature, node)
61
+ return self._normalize_result(feature, result)
62
+
63
+ def _normalize_result(
64
+ self, feature: "NodeFeature", result: Any
65
+ ) -> FeatureCheckResult:
66
+ if isinstance(result, FeatureCheckResult):
67
+ return result
68
+ if result is None:
69
+ return FeatureCheckResult(
70
+ True,
71
+ f"{feature.display} check completed successfully.",
72
+ messages.SUCCESS,
73
+ )
74
+ if isinstance(result, tuple) and len(result) >= 2:
75
+ success, message, *rest = result
76
+ level = rest[0] if rest else (
77
+ messages.SUCCESS if success else messages.ERROR
78
+ )
79
+ return FeatureCheckResult(bool(success), str(message), int(level))
80
+ if isinstance(result, bool):
81
+ message = (
82
+ f"{feature.display} check {'passed' if result else 'failed'}."
83
+ )
84
+ level = messages.SUCCESS if result else messages.ERROR
85
+ return FeatureCheckResult(result, message, level)
86
+ raise TypeError(
87
+ f"Unsupported feature check result type: {type(result)!r}"
88
+ )
89
+
90
+
91
+ feature_checks = FeatureCheckRegistry()
92
+
93
+
94
+ @feature_checks.register_default
95
+ def _default_feature_check(
96
+ feature: "NodeFeature", node: Optional["Node"]
97
+ ) -> FeatureCheckResult:
98
+ from .models import Node
99
+
100
+ target: Optional["Node"] = node or Node.get_local()
101
+ if target is None:
102
+ return FeatureCheckResult(
103
+ False,
104
+ f"No local node is registered; cannot verify {feature.display}.",
105
+ messages.WARNING,
106
+ )
107
+ try:
108
+ enabled = feature.is_enabled
109
+ except Exception as exc: # pragma: no cover - defensive
110
+ return FeatureCheckResult(
111
+ False,
112
+ f"{feature.display} check failed: {exc}",
113
+ messages.ERROR,
114
+ )
115
+ if enabled:
116
+ return FeatureCheckResult(
117
+ True,
118
+ f"{feature.display} is enabled on {target.hostname}.",
119
+ messages.SUCCESS,
120
+ )
121
+ return FeatureCheckResult(
122
+ False,
123
+ f"{feature.display} is not enabled on {target.hostname}.",
124
+ messages.WARNING,
125
+ )
126
+
127
+
128
+ __all__ = [
129
+ "FeatureCheck",
130
+ "FeatureCheckRegistry",
131
+ "FeatureCheckResult",
132
+ "feature_checks",
133
+ ]