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.
Files changed (39) hide show
  1. rotorcore-0.0.1/.gitignore +60 -0
  2. rotorcore-0.0.1/LICENSE +13 -0
  3. rotorcore-0.0.1/PKG-INFO +65 -0
  4. rotorcore-0.0.1/README.md +44 -0
  5. rotorcore-0.0.1/pyproject.toml +109 -0
  6. rotorcore-0.0.1/src/rotor/__init__.py +150 -0
  7. rotorcore-0.0.1/src/rotor/_time.py +114 -0
  8. rotorcore-0.0.1/src/rotor/backends.py +311 -0
  9. rotorcore-0.0.1/src/rotor/blob.py +452 -0
  10. rotorcore-0.0.1/src/rotor/check.py +111 -0
  11. rotorcore-0.0.1/src/rotor/client.py +475 -0
  12. rotorcore-0.0.1/src/rotor/context.py +222 -0
  13. rotorcore-0.0.1/src/rotor/dashboard.py +176 -0
  14. rotorcore-0.0.1/src/rotor/engine.py +862 -0
  15. rotorcore-0.0.1/src/rotor/errors.py +98 -0
  16. rotorcore-0.0.1/src/rotor/gateway.py +553 -0
  17. rotorcore-0.0.1/src/rotor/journal.py +291 -0
  18. rotorcore-0.0.1/src/rotor/keeper.py +148 -0
  19. rotorcore-0.0.1/src/rotor/limits.py +78 -0
  20. rotorcore-0.0.1/src/rotor/lint.py +147 -0
  21. rotorcore-0.0.1/src/rotor/live.py +525 -0
  22. rotorcore-0.0.1/src/rotor/maintenance.py +142 -0
  23. rotorcore-0.0.1/src/rotor/messages.py +582 -0
  24. rotorcore-0.0.1/src/rotor/patterns/__init__.py +25 -0
  25. rotorcore-0.0.1/src/rotor/patterns/fanout.py +68 -0
  26. rotorcore-0.0.1/src/rotor/patterns/group.py +44 -0
  27. rotorcore-0.0.1/src/rotor/patterns/hookchain.py +99 -0
  28. rotorcore-0.0.1/src/rotor/patterns/task.py +131 -0
  29. rotorcore-0.0.1/src/rotor/platforms/__init__.py +13 -0
  30. rotorcore-0.0.1/src/rotor/platforms/process.py +39 -0
  31. rotorcore-0.0.1/src/rotor/platforms/vercel.py +497 -0
  32. rotorcore-0.0.1/src/rotor/process.py +453 -0
  33. rotorcore-0.0.1/src/rotor/py.typed +0 -0
  34. rotorcore-0.0.1/src/rotor/spool.py +201 -0
  35. rotorcore-0.0.1/src/rotor/stores/__init__.py +28 -0
  36. rotorcore-0.0.1/src/rotor/stores/base.py +277 -0
  37. rotorcore-0.0.1/src/rotor/stores/sql.py +2738 -0
  38. rotorcore-0.0.1/src/rotor/testing.py +73 -0
  39. 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
@@ -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.
@@ -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}