rotorcore 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rotorcore-0.0.1/.gitignore +60 -0
- rotorcore-0.0.1/LICENSE +13 -0
- rotorcore-0.0.1/PKG-INFO +65 -0
- rotorcore-0.0.1/README.md +44 -0
- rotorcore-0.0.1/pyproject.toml +109 -0
- rotorcore-0.0.1/src/rotor/__init__.py +150 -0
- rotorcore-0.0.1/src/rotor/_time.py +114 -0
- rotorcore-0.0.1/src/rotor/backends.py +311 -0
- rotorcore-0.0.1/src/rotor/blob.py +452 -0
- rotorcore-0.0.1/src/rotor/check.py +111 -0
- rotorcore-0.0.1/src/rotor/client.py +475 -0
- rotorcore-0.0.1/src/rotor/context.py +222 -0
- rotorcore-0.0.1/src/rotor/dashboard.py +176 -0
- rotorcore-0.0.1/src/rotor/engine.py +862 -0
- rotorcore-0.0.1/src/rotor/errors.py +98 -0
- rotorcore-0.0.1/src/rotor/gateway.py +553 -0
- rotorcore-0.0.1/src/rotor/journal.py +291 -0
- rotorcore-0.0.1/src/rotor/keeper.py +148 -0
- rotorcore-0.0.1/src/rotor/limits.py +78 -0
- rotorcore-0.0.1/src/rotor/lint.py +147 -0
- rotorcore-0.0.1/src/rotor/live.py +525 -0
- rotorcore-0.0.1/src/rotor/maintenance.py +142 -0
- rotorcore-0.0.1/src/rotor/messages.py +582 -0
- rotorcore-0.0.1/src/rotor/patterns/__init__.py +25 -0
- rotorcore-0.0.1/src/rotor/patterns/fanout.py +68 -0
- rotorcore-0.0.1/src/rotor/patterns/group.py +44 -0
- rotorcore-0.0.1/src/rotor/patterns/hookchain.py +99 -0
- rotorcore-0.0.1/src/rotor/patterns/task.py +131 -0
- rotorcore-0.0.1/src/rotor/platforms/__init__.py +13 -0
- rotorcore-0.0.1/src/rotor/platforms/process.py +39 -0
- rotorcore-0.0.1/src/rotor/platforms/vercel.py +497 -0
- rotorcore-0.0.1/src/rotor/process.py +453 -0
- rotorcore-0.0.1/src/rotor/py.typed +0 -0
- rotorcore-0.0.1/src/rotor/spool.py +201 -0
- rotorcore-0.0.1/src/rotor/stores/__init__.py +28 -0
- rotorcore-0.0.1/src/rotor/stores/base.py +277 -0
- rotorcore-0.0.1/src/rotor/stores/sql.py +2738 -0
- rotorcore-0.0.1/src/rotor/testing.py +73 -0
- rotorcore-0.0.1/src/rotor/worker.py +342 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
|
|
7
|
+
# Packaging and environments
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
node_modules/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
.eggs/
|
|
13
|
+
.hatch/
|
|
14
|
+
.venv/
|
|
15
|
+
.python-version
|
|
16
|
+
|
|
17
|
+
# Tests, coverage, and tool caches
|
|
18
|
+
.cache/
|
|
19
|
+
.coverage
|
|
20
|
+
.coverage.*
|
|
21
|
+
coverage.xml
|
|
22
|
+
htmlcov/
|
|
23
|
+
.hypothesis/
|
|
24
|
+
.mypy_cache/
|
|
25
|
+
.nox/
|
|
26
|
+
.pytest_cache/
|
|
27
|
+
.ruff_cache/
|
|
28
|
+
.tox/
|
|
29
|
+
|
|
30
|
+
# Rotor and local data
|
|
31
|
+
.rotor/
|
|
32
|
+
*.db
|
|
33
|
+
*.db-journal
|
|
34
|
+
*.db-shm
|
|
35
|
+
*.db-wal
|
|
36
|
+
*.sqlite
|
|
37
|
+
*.sqlite3
|
|
38
|
+
|
|
39
|
+
# Generated sites and platform state
|
|
40
|
+
docs/_site/
|
|
41
|
+
.vercel/
|
|
42
|
+
|
|
43
|
+
# Local configuration and editors
|
|
44
|
+
.DS_Store
|
|
45
|
+
.env
|
|
46
|
+
.env.*
|
|
47
|
+
!.env.example
|
|
48
|
+
!.env.*.example
|
|
49
|
+
.idea/
|
|
50
|
+
.vscode/
|
|
51
|
+
.zed/
|
|
52
|
+
*.swp
|
|
53
|
+
*.swo
|
|
54
|
+
notes/
|
|
55
|
+
.vercel
|
|
56
|
+
.claude
|
|
57
|
+
.mcp.json
|
|
58
|
+
.cursor
|
|
59
|
+
.opencode
|
|
60
|
+
.codex
|
rotorcore-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 Vercel, Inc.
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
rotorcore-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rotorcore
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A durable process runtime for Python.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Provides-Extra: postgres
|
|
9
|
+
Requires-Dist: asyncpg>=0.29; extra == 'postgres'
|
|
10
|
+
Provides-Extra: redis
|
|
11
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
12
|
+
Provides-Extra: s3
|
|
13
|
+
Requires-Dist: boto3>=1.34; extra == 's3'
|
|
14
|
+
Provides-Extra: vercel
|
|
15
|
+
Requires-Dist: asyncpg>=0.29; extra == 'vercel'
|
|
16
|
+
Requires-Dist: vercel-queue>=0.8.0; extra == 'vercel'
|
|
17
|
+
Provides-Extra: web
|
|
18
|
+
Requires-Dist: fastapi>=0.110; extra == 'web'
|
|
19
|
+
Requires-Dist: uvicorn>=0.29; extra == 'web'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# rotor
|
|
23
|
+
|
|
24
|
+
**A durable process runtime for Python.**
|
|
25
|
+
|
|
26
|
+
> [!WARNING]
|
|
27
|
+
> Rotor is experimental and unstable. APIs, storage formats, and behavior may change
|
|
28
|
+
> without notice. Do not use it for production workloads.
|
|
29
|
+
|
|
30
|
+
A process is persisted as a checkpoint and mailbox. A worker leases it for one or
|
|
31
|
+
more messages, committing each transition independently.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from dataclasses import field
|
|
35
|
+
from rotor import DurableProcess, Start, message, on
|
|
36
|
+
|
|
37
|
+
@message
|
|
38
|
+
class Ask:
|
|
39
|
+
question: str
|
|
40
|
+
|
|
41
|
+
class State:
|
|
42
|
+
asked: list = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
class Oracle(DurableProcess[State]):
|
|
45
|
+
@on
|
|
46
|
+
async def start(self, msg: Start):
|
|
47
|
+
self.state.asked.append(f"born knowing: {msg.input}")
|
|
48
|
+
|
|
49
|
+
@on
|
|
50
|
+
async def ask(self, msg: Ask):
|
|
51
|
+
self.state.asked.append(msg.question)
|
|
52
|
+
if len(self.state.asked) > 3:
|
|
53
|
+
self.stop(output=self.state.asked)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Timers, child completion, approvals, and limit notifications all arrive as messages.
|
|
57
|
+
Application code defines their meaning in handlers.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install rotorcore # SQLite tier: zero dependencies
|
|
63
|
+
pip install "rotorcore[postgres]" # + asyncpg for production
|
|
64
|
+
pip install "rotorcore[vercel]" # Queue SDK + Postgres + S3 adapter
|
|
65
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# rotor
|
|
2
|
+
|
|
3
|
+
**A durable process runtime for Python.**
|
|
4
|
+
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> Rotor is experimental and unstable. APIs, storage formats, and behavior may change
|
|
7
|
+
> without notice. Do not use it for production workloads.
|
|
8
|
+
|
|
9
|
+
A process is persisted as a checkpoint and mailbox. A worker leases it for one or
|
|
10
|
+
more messages, committing each transition independently.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from dataclasses import field
|
|
14
|
+
from rotor import DurableProcess, Start, message, on
|
|
15
|
+
|
|
16
|
+
@message
|
|
17
|
+
class Ask:
|
|
18
|
+
question: str
|
|
19
|
+
|
|
20
|
+
class State:
|
|
21
|
+
asked: list = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
class Oracle(DurableProcess[State]):
|
|
24
|
+
@on
|
|
25
|
+
async def start(self, msg: Start):
|
|
26
|
+
self.state.asked.append(f"born knowing: {msg.input}")
|
|
27
|
+
|
|
28
|
+
@on
|
|
29
|
+
async def ask(self, msg: Ask):
|
|
30
|
+
self.state.asked.append(msg.question)
|
|
31
|
+
if len(self.state.asked) > 3:
|
|
32
|
+
self.stop(output=self.state.asked)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Timers, child completion, approvals, and limit notifications all arrive as messages.
|
|
36
|
+
Application code defines their meaning in handlers.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install rotorcore # SQLite tier: zero dependencies
|
|
42
|
+
pip install "rotorcore[postgres]" # + asyncpg for production
|
|
43
|
+
pip install "rotorcore[vercel]" # Queue SDK + Postgres + S3 adapter
|
|
44
|
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rotorcore"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "A durable process runtime for Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
postgres = ["asyncpg>=0.29"]
|
|
17
|
+
redis = ["redis>=5"]
|
|
18
|
+
s3 = ["boto3>=1.34"]
|
|
19
|
+
vercel = ["asyncpg>=0.29", "vercel-queue>=0.8.0"]
|
|
20
|
+
web = ["fastapi>=0.110", "uvicorn>=0.29"]
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"ruff>=0.5.0,<1",
|
|
25
|
+
"pytest>=8",
|
|
26
|
+
"pytest-asyncio>=0.23",
|
|
27
|
+
"httpx>=0.27",
|
|
28
|
+
"fastapi>=0.110",
|
|
29
|
+
"uvicorn>=0.29",
|
|
30
|
+
"mypy>=1.20.2,<2",
|
|
31
|
+
"poethepoet>=0.48,<1",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.uv]
|
|
35
|
+
default-groups = ["dev"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/rotor"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
only-include = ["src/rotor"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
addopts = "-q"
|
|
47
|
+
|
|
48
|
+
[tool.mypy]
|
|
49
|
+
ignore_missing_imports = true
|
|
50
|
+
explicit_package_bases = true
|
|
51
|
+
mypy_path = ["src"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 100
|
|
55
|
+
target-version = "py311"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = [
|
|
59
|
+
"E", # pycodestyle errors
|
|
60
|
+
"W", # pycodestyle warnings
|
|
61
|
+
"F", # pyflakes
|
|
62
|
+
"I", # isort
|
|
63
|
+
"B", # flake8-bugbear
|
|
64
|
+
"C4", # flake8-comprehensions
|
|
65
|
+
"UP", # pyupgrade
|
|
66
|
+
]
|
|
67
|
+
isort = { combine-as-imports = true, known-first-party = ["rotor"] }
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.pyupgrade]
|
|
70
|
+
keep-runtime-typing = false
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.per-file-ignores]
|
|
73
|
+
"examples/**/*.py" = ["E501"]
|
|
74
|
+
|
|
75
|
+
[tool.poe.tasks.lint]
|
|
76
|
+
sequence = ["lint-ruff", "lint-format"]
|
|
77
|
+
|
|
78
|
+
[tool.poe.tasks.lint-ruff]
|
|
79
|
+
cmd = "ruff check src tests examples"
|
|
80
|
+
|
|
81
|
+
[tool.poe.tasks.lint-format]
|
|
82
|
+
cmd = "ruff format --check src tests examples"
|
|
83
|
+
|
|
84
|
+
[tool.poe.tasks.typecheck]
|
|
85
|
+
cmd = "mypy src tests"
|
|
86
|
+
|
|
87
|
+
[tool.poe.tasks.test]
|
|
88
|
+
cmd = "pytest"
|
|
89
|
+
|
|
90
|
+
[tool.poe.tasks.schema]
|
|
91
|
+
cmd = "python -m rotor.check rotor.patterns"
|
|
92
|
+
|
|
93
|
+
[tool.poe.tasks.schema-write]
|
|
94
|
+
cmd = "python -m rotor.check rotor.patterns --write"
|
|
95
|
+
|
|
96
|
+
[tool.poe.tasks.docs]
|
|
97
|
+
cmd = "npm run docs:build"
|
|
98
|
+
|
|
99
|
+
[tool.poe.tasks.fix]
|
|
100
|
+
sequence = ["fix-ruff", "fix-format"]
|
|
101
|
+
|
|
102
|
+
[tool.poe.tasks.fix-ruff]
|
|
103
|
+
cmd = "ruff check --fix src tests examples"
|
|
104
|
+
|
|
105
|
+
[tool.poe.tasks.fix-format]
|
|
106
|
+
cmd = "ruff format src tests examples"
|
|
107
|
+
|
|
108
|
+
[tool.poe.tasks.qa]
|
|
109
|
+
sequence = ["lint", "typecheck", "schema", "test", "docs"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Durable process runtime for Python."""
|
|
2
|
+
|
|
3
|
+
from .backends import Backends
|
|
4
|
+
from .blob import Blob, BlobStore, FileBlobStore, MemoryBlobStore, S3BlobStore, VercelBlobStore
|
|
5
|
+
from .client import Client, Handle
|
|
6
|
+
from .context import (
|
|
7
|
+
PendingHook,
|
|
8
|
+
current_context,
|
|
9
|
+
idempotency_key,
|
|
10
|
+
meter,
|
|
11
|
+
record,
|
|
12
|
+
stream,
|
|
13
|
+
)
|
|
14
|
+
from .engine import TickResult
|
|
15
|
+
from .errors import (
|
|
16
|
+
CallConflict,
|
|
17
|
+
CallFailed,
|
|
18
|
+
CallTimeout,
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
ForgedFact,
|
|
21
|
+
HookError,
|
|
22
|
+
HookNotFound,
|
|
23
|
+
HookNotPending,
|
|
24
|
+
MailboxClosed,
|
|
25
|
+
MailboxOverflow,
|
|
26
|
+
NoReply,
|
|
27
|
+
NotAnActivation,
|
|
28
|
+
ProcessConflict,
|
|
29
|
+
ProcessNotFound,
|
|
30
|
+
RotorError,
|
|
31
|
+
StaleDeployment,
|
|
32
|
+
StaleLease,
|
|
33
|
+
UnknownType,
|
|
34
|
+
UnmigratablePayload,
|
|
35
|
+
)
|
|
36
|
+
from .journal import Journal
|
|
37
|
+
from .limits import Ceiling, Limits, per, per_day, per_hour, total
|
|
38
|
+
from .live import (
|
|
39
|
+
Chunk,
|
|
40
|
+
Gap,
|
|
41
|
+
LiveTransport,
|
|
42
|
+
MemoryLiveTransport,
|
|
43
|
+
PostgresLiveTransport,
|
|
44
|
+
RedisLiveTransport,
|
|
45
|
+
Settled,
|
|
46
|
+
)
|
|
47
|
+
from .maintenance import JanitorResult, ReconcileResult
|
|
48
|
+
from .messages import (
|
|
49
|
+
Cancelled,
|
|
50
|
+
ChildDone,
|
|
51
|
+
ChildFailed,
|
|
52
|
+
EmptyState,
|
|
53
|
+
ErrorInfo,
|
|
54
|
+
HandlingFailed,
|
|
55
|
+
HookExpired,
|
|
56
|
+
HookResolved,
|
|
57
|
+
LimitExceeded,
|
|
58
|
+
ProcessRef,
|
|
59
|
+
Resolution,
|
|
60
|
+
Start,
|
|
61
|
+
TerminalFailure,
|
|
62
|
+
message,
|
|
63
|
+
state,
|
|
64
|
+
)
|
|
65
|
+
from .process import DurableProcess, child_id, on, query, singleton_id
|
|
66
|
+
from .worker import Worker
|
|
67
|
+
|
|
68
|
+
__version__ = "0.0.1"
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
# the process
|
|
72
|
+
"DurableProcess",
|
|
73
|
+
"ProcessRef",
|
|
74
|
+
"EmptyState",
|
|
75
|
+
"child_id",
|
|
76
|
+
"singleton_id",
|
|
77
|
+
"on",
|
|
78
|
+
"query",
|
|
79
|
+
# messages & facts
|
|
80
|
+
"message",
|
|
81
|
+
"Resolution",
|
|
82
|
+
"state",
|
|
83
|
+
"Start",
|
|
84
|
+
"ChildDone",
|
|
85
|
+
"ChildFailed",
|
|
86
|
+
"HookResolved",
|
|
87
|
+
"HookExpired",
|
|
88
|
+
"HandlingFailed",
|
|
89
|
+
"LimitExceeded",
|
|
90
|
+
"Cancelled",
|
|
91
|
+
"ErrorInfo",
|
|
92
|
+
"TerminalFailure",
|
|
93
|
+
# ambient context
|
|
94
|
+
"current_context",
|
|
95
|
+
"meter",
|
|
96
|
+
"record",
|
|
97
|
+
"stream",
|
|
98
|
+
"idempotency_key",
|
|
99
|
+
"PendingHook",
|
|
100
|
+
"Chunk",
|
|
101
|
+
"Settled",
|
|
102
|
+
"Gap",
|
|
103
|
+
"LiveTransport",
|
|
104
|
+
"MemoryLiveTransport",
|
|
105
|
+
"PostgresLiveTransport",
|
|
106
|
+
"RedisLiveTransport",
|
|
107
|
+
# limits
|
|
108
|
+
"Limits",
|
|
109
|
+
"Ceiling",
|
|
110
|
+
"per",
|
|
111
|
+
"per_day",
|
|
112
|
+
"per_hour",
|
|
113
|
+
"total",
|
|
114
|
+
# blobs & journals
|
|
115
|
+
"Blob",
|
|
116
|
+
"BlobStore",
|
|
117
|
+
"FileBlobStore",
|
|
118
|
+
"MemoryBlobStore",
|
|
119
|
+
"S3BlobStore",
|
|
120
|
+
"VercelBlobStore",
|
|
121
|
+
"Journal",
|
|
122
|
+
# the two halves
|
|
123
|
+
"Client",
|
|
124
|
+
"Handle",
|
|
125
|
+
"Worker",
|
|
126
|
+
"TickResult",
|
|
127
|
+
"ReconcileResult",
|
|
128
|
+
"JanitorResult",
|
|
129
|
+
"Backends",
|
|
130
|
+
# errors
|
|
131
|
+
"RotorError",
|
|
132
|
+
"ConfigurationError",
|
|
133
|
+
"ProcessNotFound",
|
|
134
|
+
"ProcessConflict",
|
|
135
|
+
"MailboxClosed",
|
|
136
|
+
"MailboxOverflow",
|
|
137
|
+
"NoReply",
|
|
138
|
+
"CallFailed",
|
|
139
|
+
"CallTimeout",
|
|
140
|
+
"CallConflict",
|
|
141
|
+
"ForgedFact",
|
|
142
|
+
"NotAnActivation",
|
|
143
|
+
"HookError",
|
|
144
|
+
"HookNotFound",
|
|
145
|
+
"HookNotPending",
|
|
146
|
+
"StaleLease",
|
|
147
|
+
"StaleDeployment",
|
|
148
|
+
"UnknownType",
|
|
149
|
+
"UnmigratablePayload",
|
|
150
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Durations, cron, and the injectable clock.
|
|
2
|
+
|
|
3
|
+
One duration grammar everywhere: ``"30s"``, ``"5m"``, ``"2h"``, ``"1d"`` —
|
|
4
|
+
shared by timers, limits windows, retention, and lease settings. Cron is the
|
|
5
|
+
standard 5-field grammar with an optional IANA timezone, because a daemon's
|
|
6
|
+
"7am brief" lives in the user's morning, not UTC's.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time as _time
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from zoneinfo import ZoneInfo
|
|
15
|
+
|
|
16
|
+
from .errors import ConfigurationError
|
|
17
|
+
|
|
18
|
+
_DURATION = re.compile(r"^(\d+(?:\.\d+)?)(s|m|h|d)$")
|
|
19
|
+
_UNIT = {"s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_duration(value: str | int | float) -> float:
|
|
23
|
+
"""``"90s"`` → 90.0. Bare numbers are seconds."""
|
|
24
|
+
if isinstance(value, (int, float)):
|
|
25
|
+
return float(value)
|
|
26
|
+
m = _DURATION.match(value.strip())
|
|
27
|
+
if not m:
|
|
28
|
+
raise ConfigurationError(f"bad duration {value!r} — expected e.g. '30s', '5m', '2h', '1d'")
|
|
29
|
+
return float(m.group(1)) * _UNIT[m.group(2)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Clock:
|
|
33
|
+
"""Wall clock. Tests substitute VirtualClock; everything asks the clock."""
|
|
34
|
+
|
|
35
|
+
def now(self) -> float:
|
|
36
|
+
return _time.time()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VirtualClock(Clock):
|
|
40
|
+
"""A clock that only moves when told to. The testing harness's heart."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, start: float = 1_700_000_000.0) -> None:
|
|
43
|
+
self._now = start
|
|
44
|
+
|
|
45
|
+
def now(self) -> float:
|
|
46
|
+
return self._now
|
|
47
|
+
|
|
48
|
+
def advance(self, duration: str | int | float) -> None:
|
|
49
|
+
self._now += parse_duration(duration)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_field(field: str, lo: int, hi: int) -> set[int]:
|
|
53
|
+
values: set[int] = set()
|
|
54
|
+
for part in field.split(","):
|
|
55
|
+
step = 1
|
|
56
|
+
if "/" in part:
|
|
57
|
+
part, step_s = part.split("/", 1)
|
|
58
|
+
step = int(step_s)
|
|
59
|
+
if part == "*":
|
|
60
|
+
lo_, hi_ = lo, hi
|
|
61
|
+
elif "-" in part:
|
|
62
|
+
a, b = part.split("-", 1)
|
|
63
|
+
lo_, hi_ = int(a), int(b)
|
|
64
|
+
else:
|
|
65
|
+
lo_ = hi_ = int(part)
|
|
66
|
+
if not (lo <= lo_ <= hi_ <= hi):
|
|
67
|
+
raise ConfigurationError(f"cron field {field!r} out of range {lo}-{hi}")
|
|
68
|
+
values.update(range(lo_, hi_ + 1, step))
|
|
69
|
+
return values
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Cron:
|
|
73
|
+
"""Five fields: minute hour day-of-month month day-of-week (0=Sunday)."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, expr: str, tz: str | None = None) -> None:
|
|
76
|
+
fields = expr.split()
|
|
77
|
+
if len(fields) != 5:
|
|
78
|
+
raise ConfigurationError(f"bad cron {expr!r} — expected 5 fields")
|
|
79
|
+
self.expr = expr
|
|
80
|
+
self.tz = tz
|
|
81
|
+
self.minute = _parse_field(fields[0], 0, 59)
|
|
82
|
+
self.hour = _parse_field(fields[1], 0, 23)
|
|
83
|
+
self.dom = _parse_field(fields[2], 1, 31)
|
|
84
|
+
self.month = _parse_field(fields[3], 1, 12)
|
|
85
|
+
# accept 7 as Sunday, normalize to 0
|
|
86
|
+
self.dow = {d % 7 for d in _parse_field(fields[4], 0, 7)}
|
|
87
|
+
|
|
88
|
+
def next_after(self, ts: float) -> float:
|
|
89
|
+
"""The first matching minute strictly after ``ts``, as a unix timestamp."""
|
|
90
|
+
zone = ZoneInfo(self.tz) if self.tz else UTC
|
|
91
|
+
start = datetime.fromtimestamp(ts, zone).replace(second=0, microsecond=0)
|
|
92
|
+
start += timedelta(minutes=1)
|
|
93
|
+
day = start.date()
|
|
94
|
+
for _ in range(28 * 366): # leap-day + weekday combinations repeat within 28 years
|
|
95
|
+
if day.day in self.dom and day.month in self.month and day.weekday() in self._py_dow():
|
|
96
|
+
for hour in sorted(self.hour):
|
|
97
|
+
for minute in sorted(self.minute):
|
|
98
|
+
candidate = datetime(
|
|
99
|
+
day.year, day.month, day.day, hour, minute, tzinfo=zone
|
|
100
|
+
)
|
|
101
|
+
if candidate < start or candidate.timestamp() <= ts:
|
|
102
|
+
continue
|
|
103
|
+
# Nonexistent local times during a DST jump normalize
|
|
104
|
+
# to another wall time and therefore are not matches.
|
|
105
|
+
actual = datetime.fromtimestamp(candidate.timestamp(), zone)
|
|
106
|
+
if (actual.date(), actual.hour, actual.minute) != (day, hour, minute):
|
|
107
|
+
continue
|
|
108
|
+
return candidate.timestamp()
|
|
109
|
+
day += timedelta(days=1)
|
|
110
|
+
raise ConfigurationError(f"cron {self.expr!r} never fires")
|
|
111
|
+
|
|
112
|
+
def _py_dow(self) -> set[int]:
|
|
113
|
+
# cron: 0=Sunday..6=Saturday; python weekday(): 0=Monday..6=Sunday
|
|
114
|
+
return {(d - 1) % 7 for d in self.dow}
|