nat-engine 1__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 (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,57 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+
5
+ """Scheduled scan infrastructure for NAT.
6
+
7
+ Provides recurring scan scheduling (cron-based) for Pro tier and above.
8
+
9
+ Usage::
10
+
11
+ from mannf.product.scheduling import ScanSchedule, ScheduleStore, SchedulerEngine
12
+ from mannf.product.scheduling.cron_utils import next_run_after, validate_cron
13
+
14
+ # Validate a cron expression
15
+ validate_cron("0 2 * * *") # 2am daily
16
+
17
+ # Create a schedule store
18
+ store = ScheduleStore()
19
+
20
+ # Start the engine
21
+ engine = SchedulerEngine(store=store)
22
+ await engine.start()
23
+ """
24
+
25
+ from mannf.product.scheduling.cron_utils import next_run_after, validate_cron
26
+ from mannf.product.scheduling.engine import SchedulerEngine
27
+ from mannf.product.scheduling.models import ScanSchedule
28
+ from mannf.product.scheduling.queue import (
29
+ InMemoryJobQueue,
30
+ Job,
31
+ JobQueue,
32
+ QueueStats,
33
+ RedisJobQueue,
34
+ SQLiteJobQueue,
35
+ create_job_queue,
36
+ get_default_queue,
37
+ set_default_queue,
38
+ )
39
+ from mannf.product.scheduling.store import ScheduleStore
40
+
41
+ __all__ = [
42
+ "ScanSchedule",
43
+ "ScheduleStore",
44
+ "SchedulerEngine",
45
+ "next_run_after",
46
+ "validate_cron",
47
+ # Job queue
48
+ "Job",
49
+ "JobQueue",
50
+ "QueueStats",
51
+ "InMemoryJobQueue",
52
+ "SQLiteJobQueue",
53
+ "RedisJobQueue",
54
+ "create_job_queue",
55
+ "get_default_queue",
56
+ "set_default_queue",
57
+ ]
@@ -0,0 +1,251 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+
5
+ """Cron expression parser, validator, and next-run calculator.
6
+
7
+ Supports standard 5-field cron expressions::
8
+
9
+ ┌─────── minute (0-59)
10
+ │ ┌───── hour (0-23)
11
+ │ │ ┌─── day-of-month (1-31)
12
+ │ │ │ ┌─ month (1-12)
13
+ │ │ │ │ ┌ day-of-week (0-6, Sun=0)
14
+ │ │ │ │ │
15
+ * * * * *
16
+
17
+ Supported field syntax:
18
+
19
+ - ``*`` — every value
20
+ - ``5`` — exact value
21
+ - ``1-5`` — inclusive range
22
+ - ``*/15`` — step (every N)
23
+ - ``1,3,5`` — list of values
24
+
25
+ Usage::
26
+
27
+ from mannf.product.scheduling.cron_utils import validate_cron, next_run_after
28
+ from datetime import datetime, timezone
29
+
30
+ validate_cron("0 2 * * *") # raises ValueError on bad input
31
+ nxt = next_run_after("0 2 * * *", datetime.now(timezone.utc))
32
+ print(nxt.isoformat())
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from datetime import datetime, timedelta, timezone
39
+ from typing import Iterable
40
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Field constraints
44
+ # ---------------------------------------------------------------------------
45
+
46
+ _FIELD_RANGES: tuple[tuple[int, int], ...] = (
47
+ (0, 59), # minute
48
+ (0, 23), # hour
49
+ (1, 31), # day-of-month
50
+ (1, 12), # month
51
+ (0, 6), # day-of-week (0=Sun, 6=Sat)
52
+ )
53
+
54
+ _FIELD_NAMES: tuple[str, ...] = (
55
+ "minute",
56
+ "hour",
57
+ "day-of-month",
58
+ "month",
59
+ "day-of-week",
60
+ )
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Low-level field parser
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def _parse_field(token: str, lo: int, hi: int, field_name: str) -> frozenset[int]:
68
+ """Parse a single cron field token into a frozenset of matching integers.
69
+
70
+ Args:
71
+ token: The cron field token (e.g. ``"*"``, ``"0-5"``, ``"*/15"``).
72
+ lo: Minimum allowed value (inclusive).
73
+ hi: Maximum allowed value (inclusive).
74
+ field_name: Human-readable name used in error messages.
75
+
76
+ Returns:
77
+ A :class:`frozenset` of integers representing all matching values.
78
+
79
+ Raises:
80
+ ValueError: When the token is syntactically invalid or out-of-range.
81
+ """
82
+ def _check(v: int) -> int:
83
+ if not (lo <= v <= hi):
84
+ raise ValueError(
85
+ f"Cron field '{field_name}': value {v} out of range [{lo}, {hi}]"
86
+ )
87
+ return v
88
+
89
+ def _expand(token_part: str) -> Iterable[int]:
90
+ if "-" in token_part:
91
+ parts = token_part.split("-", 1)
92
+ if len(parts) != 2:
93
+ raise ValueError(
94
+ f"Cron field '{field_name}': invalid range '{token_part}'"
95
+ )
96
+ try:
97
+ start, end = int(parts[0]), int(parts[1])
98
+ except ValueError:
99
+ raise ValueError(
100
+ f"Cron field '{field_name}': non-integer range '{token_part}'"
101
+ )
102
+ _check(start)
103
+ _check(end)
104
+ if start > end:
105
+ raise ValueError(
106
+ f"Cron field '{field_name}': range start {start} > end {end}"
107
+ )
108
+ return range(start, end + 1)
109
+ else:
110
+ try:
111
+ return [_check(int(token_part))]
112
+ except ValueError as exc:
113
+ raise ValueError(
114
+ f"Cron field '{field_name}': non-integer value '{token_part}'"
115
+ ) from exc
116
+
117
+ values: set[int] = set()
118
+
119
+ # Step syntax: */N or base/N (base can be a range or single value)
120
+ if "/" in token:
121
+ parts = token.split("/", 1)
122
+ if len(parts) != 2:
123
+ raise ValueError(f"Cron field '{field_name}': invalid step syntax '{token}'")
124
+ base_part, step_part = parts
125
+ try:
126
+ step = int(step_part)
127
+ except ValueError:
128
+ raise ValueError(
129
+ f"Cron field '{field_name}': non-integer step '{step_part}'"
130
+ )
131
+ if step <= 0:
132
+ raise ValueError(
133
+ f"Cron field '{field_name}': step must be positive, got {step}"
134
+ )
135
+ if base_part == "*":
136
+ base_values = range(lo, hi + 1)
137
+ else:
138
+ base_values = sorted(_expand(base_part))
139
+ for i, v in enumerate(base_values):
140
+ if i % step == 0:
141
+ values.add(v)
142
+ return frozenset(values)
143
+
144
+ # List syntax: a,b,c (each element may itself be a range)
145
+ for part in token.split(","):
146
+ part = part.strip()
147
+ if not part:
148
+ raise ValueError(
149
+ f"Cron field '{field_name}': empty element in list '{token}'"
150
+ )
151
+ if part == "*":
152
+ values.update(range(lo, hi + 1))
153
+ else:
154
+ values.update(_expand(part))
155
+
156
+ return frozenset(values)
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Public API
161
+ # ---------------------------------------------------------------------------
162
+
163
+ def validate_cron(expression: str) -> None:
164
+ """Validate a 5-field cron expression.
165
+
166
+ Args:
167
+ expression: The cron string to validate (exactly 5 whitespace-separated
168
+ fields).
169
+
170
+ Raises:
171
+ ValueError: When the expression has the wrong number of fields, contains
172
+ invalid syntax, or has out-of-range values.
173
+ """
174
+ _parse_cron(expression) # raises on error; result is discarded
175
+
176
+
177
+ def _parse_cron(expression: str) -> tuple[frozenset[int], ...]:
178
+ """Parse all 5 fields and return them as a tuple of frozensets."""
179
+ if not isinstance(expression, str):
180
+ raise ValueError("Cron expression must be a string")
181
+ fields = expression.strip().split()
182
+ if len(fields) != 5:
183
+ raise ValueError(
184
+ f"Cron expression must have exactly 5 fields, got {len(fields)}: {expression!r}"
185
+ )
186
+ result = []
187
+ for token, (lo, hi), name in zip(fields, _FIELD_RANGES, _FIELD_NAMES):
188
+ result.append(_parse_field(token, lo, hi, name))
189
+ return tuple(result)
190
+
191
+
192
+ def next_run_after(
193
+ expression: str,
194
+ after: datetime,
195
+ tz: str = "UTC",
196
+ ) -> datetime:
197
+ """Return the next datetime when *expression* would fire after *after*.
198
+
199
+ The algorithm advances minute-by-minute from ``after + 1 min`` until a
200
+ matching point is found (capped at 4 years to guard against impossible
201
+ expressions like ``"31 2 30 2 *"``).
202
+
203
+ Args:
204
+ expression: A valid 5-field cron string.
205
+ after: The reference :class:`~datetime.datetime`. If it is
206
+ timezone-naive it is treated as UTC.
207
+ tz: IANA timezone name used to evaluate the cron fields. Defaults to
208
+ ``"UTC"``.
209
+
210
+ Returns:
211
+ A timezone-aware :class:`~datetime.datetime` (in *tz*) of the next
212
+ matching instant. The seconds / microseconds components are always 0.
213
+
214
+ Raises:
215
+ ValueError: When *expression* is invalid or *tz* is an unknown IANA
216
+ timezone name.
217
+ RuntimeError: When no matching time is found within ~4 years (most
218
+ likely an impossible expression).
219
+ """
220
+ try:
221
+ zone = ZoneInfo(tz)
222
+ except (ZoneInfoNotFoundError, KeyError):
223
+ raise ValueError(f"Unknown timezone: {tz!r}")
224
+
225
+ minutes, hours, doms, months, dows = _parse_cron(expression)
226
+
227
+ # Normalise *after* to the target timezone, then strip sub-minute precision.
228
+ if after.tzinfo is None:
229
+ after = after.replace(tzinfo=timezone.utc)
230
+ dt = after.astimezone(zone).replace(second=0, microsecond=0)
231
+
232
+ # Advance by one minute so we never return the same instant as *after*.
233
+ dt += timedelta(minutes=1)
234
+
235
+ max_iterations = 60 * 24 * 366 * 4 # ~4 years in minutes
236
+ for _ in range(max_iterations):
237
+ # day-of-week: 0=Sun … 6=Sat (Python isoweekday: Mon=1 … Sun=7)
238
+ python_dow = dt.isoweekday() % 7 # Sun=0, Mon=1, …, Sat=6
239
+ if (
240
+ dt.month in months
241
+ and dt.day in doms
242
+ and python_dow in dows
243
+ and dt.hour in hours
244
+ and dt.minute in minutes
245
+ ):
246
+ return dt
247
+ dt += timedelta(minutes=1)
248
+
249
+ raise RuntimeError(
250
+ f"No matching time found within 4 years for cron expression {expression!r}"
251
+ )