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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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
|
+
)
|