percival 0.1.0__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.
- percival-0.1.0/.gitignore +30 -0
- percival-0.1.0/PKG-INFO +107 -0
- percival-0.1.0/README.md +78 -0
- percival-0.1.0/percival/__init__.py +67 -0
- percival-0.1.0/percival/beat.py +91 -0
- percival-0.1.0/percival/cli.py +78 -0
- percival-0.1.0/percival/config.py +102 -0
- percival-0.1.0/percival/dedupe.py +183 -0
- percival-0.1.0/percival/events.py +117 -0
- percival-0.1.0/percival/gate.py +150 -0
- percival-0.1.0/percival/heartbeat.py +124 -0
- percival-0.1.0/percival/importer.py +226 -0
- percival-0.1.0/percival/metadata.py +111 -0
- percival-0.1.0/percival/naming.py +55 -0
- percival-0.1.0/percival/py.typed +0 -0
- percival-0.1.0/percival/redaction.py +93 -0
- percival-0.1.0/percival/runtime.py +85 -0
- percival-0.1.0/percival/schedules.py +172 -0
- percival-0.1.0/percival/signals.py +170 -0
- percival-0.1.0/pyproject.toml +70 -0
- percival-0.1.0/tests/conftest.py +22 -0
- percival-0.1.0/tests/test_config.py +33 -0
- percival-0.1.0/tests/test_dedupe.py +150 -0
- percival-0.1.0/tests/test_gate.py +113 -0
- percival-0.1.0/tests/test_heartbeat.py +48 -0
- percival-0.1.0/tests/test_importer.py +128 -0
- percival-0.1.0/tests/test_metadata.py +61 -0
- percival-0.1.0/tests/test_redaction.py +41 -0
- percival-0.1.0/tests/test_runtime.py +22 -0
- percival-0.1.0/tests/test_signals.py +90 -0
- percival-0.1.0/tests/test_timezones.py +94 -0
- percival-0.1.0/tests/test_worker.py +73 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.mypy_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
htmlcov/
|
|
15
|
+
|
|
16
|
+
# node
|
|
17
|
+
node_modules/
|
|
18
|
+
web/dist/
|
|
19
|
+
*.tsbuildinfo
|
|
20
|
+
|
|
21
|
+
# local stuff
|
|
22
|
+
.env
|
|
23
|
+
*.sqlite3
|
|
24
|
+
*.db
|
|
25
|
+
.idea/
|
|
26
|
+
.vscode/
|
|
27
|
+
.DS_Store
|
|
28
|
+
|
|
29
|
+
# built ui, produced by `npm run build` in web/ (and by the docker build)
|
|
30
|
+
server/percival_server/static/
|
percival-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: percival
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client hooks for the Percival Celery control panel: run events, worker versions, stale-worker gate, duplicate guard.
|
|
5
|
+
Project-URL: Homepage, https://github.com/RijinRaju/percival
|
|
6
|
+
Project-URL: Source, https://github.com/RijinRaju/percival
|
|
7
|
+
Project-URL: Issues, https://github.com/RijinRaju/percival/issues
|
|
8
|
+
Author: Rijin Raju
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Celery
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: celery-redbeat>=2.0
|
|
20
|
+
Requires-Dist: celery>=5.3
|
|
21
|
+
Requires-Dist: redis>=4.5
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: fakeredis>=2.20; extra == 'dev'
|
|
24
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
27
|
+
Provides-Extra: import
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# percival
|
|
31
|
+
|
|
32
|
+
The client half of [Percival](https://github.com/RijinRaju/percival), a
|
|
33
|
+
self-hosted control panel for Celery. You install it in your Celery project.
|
|
34
|
+
It lets the panel change schedules without restarting beat or workers, and it
|
|
35
|
+
stops two things a ping monitor can't see: a task running twice, and a stale
|
|
36
|
+
worker running old code.
|
|
37
|
+
|
|
38
|
+
Needs Celery 5.3+ and Redis. The broker can be Redis or RabbitMQ.
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
pip install percival
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
# proj/celery.py, right after app = Celery(...)
|
|
48
|
+
import os, percival
|
|
49
|
+
|
|
50
|
+
percival.init(
|
|
51
|
+
app,
|
|
52
|
+
redis_url="redis://localhost:6379/0",
|
|
53
|
+
code_version=os.environ.get("GIT_SHA", "dev"), # whatever identifies a deploy
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
celery -A proj beat -S percival.beat.Scheduler
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then run the panel, one container:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
docker run -d -p 8080:8080 -v percival:/data \
|
|
65
|
+
-e PERCIVAL_REDIS_URL=redis://host.docker.internal:6379/0 \
|
|
66
|
+
-e PERCIVAL_ADMIN_PASSWORD=pick-a-long-one \
|
|
67
|
+
ghcr.io/rijinraju/percival
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or install it with `pip install percival-server`.
|
|
71
|
+
|
|
72
|
+
## What `init()` sets up
|
|
73
|
+
|
|
74
|
+
- **Run events.** Started, success, failure, retry and revoked go to a Redis
|
|
75
|
+
stream, with timing, worker, code version and redacted args. Anything named
|
|
76
|
+
like `password`, `token` or `secret` is replaced before it leaves the worker.
|
|
77
|
+
- **Worker heartbeat.** Every worker reports its hostname, queues and code
|
|
78
|
+
version every 30 seconds.
|
|
79
|
+
- **Task metadata.** The signature, docstring and source of each task, so the
|
|
80
|
+
panel can show them. Pass `publish_source=False` to keep source private.
|
|
81
|
+
- **Stale worker gate.** A schedule can require a code version. A worker on a
|
|
82
|
+
different version doesn't run the task. The message goes back on the queue
|
|
83
|
+
for an up-to-date worker.
|
|
84
|
+
|
|
85
|
+
## Stopping duplicate runs
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from percival import once_per
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.task
|
|
92
|
+
@once_per(key=lambda user_id, **kw: user_id, window="1d")
|
|
93
|
+
def send_digest(user_id): ...
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A second run for the same key in the same window is skipped and recorded.
|
|
97
|
+
|
|
98
|
+
## Moving an existing `beat_schedule`
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
percival import --app proj.celery:app --redis-url redis://localhost:6379/0 --apply
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Then remove those entries from `beat_schedule`.
|
|
105
|
+
|
|
106
|
+
The [full README](https://github.com/RijinRaju/percival#readme) has every
|
|
107
|
+
option and a `docker compose up` demo.
|
percival-0.1.0/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# percival
|
|
2
|
+
|
|
3
|
+
The client half of [Percival](https://github.com/RijinRaju/percival), a
|
|
4
|
+
self-hosted control panel for Celery. You install it in your Celery project.
|
|
5
|
+
It lets the panel change schedules without restarting beat or workers, and it
|
|
6
|
+
stops two things a ping monitor can't see: a task running twice, and a stale
|
|
7
|
+
worker running old code.
|
|
8
|
+
|
|
9
|
+
Needs Celery 5.3+ and Redis. The broker can be Redis or RabbitMQ.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pip install percival
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
# proj/celery.py, right after app = Celery(...)
|
|
19
|
+
import os, percival
|
|
20
|
+
|
|
21
|
+
percival.init(
|
|
22
|
+
app,
|
|
23
|
+
redis_url="redis://localhost:6379/0",
|
|
24
|
+
code_version=os.environ.get("GIT_SHA", "dev"), # whatever identifies a deploy
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
celery -A proj beat -S percival.beat.Scheduler
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then run the panel, one container:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
docker run -d -p 8080:8080 -v percival:/data \
|
|
36
|
+
-e PERCIVAL_REDIS_URL=redis://host.docker.internal:6379/0 \
|
|
37
|
+
-e PERCIVAL_ADMIN_PASSWORD=pick-a-long-one \
|
|
38
|
+
ghcr.io/rijinraju/percival
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or install it with `pip install percival-server`.
|
|
42
|
+
|
|
43
|
+
## What `init()` sets up
|
|
44
|
+
|
|
45
|
+
- **Run events.** Started, success, failure, retry and revoked go to a Redis
|
|
46
|
+
stream, with timing, worker, code version and redacted args. Anything named
|
|
47
|
+
like `password`, `token` or `secret` is replaced before it leaves the worker.
|
|
48
|
+
- **Worker heartbeat.** Every worker reports its hostname, queues and code
|
|
49
|
+
version every 30 seconds.
|
|
50
|
+
- **Task metadata.** The signature, docstring and source of each task, so the
|
|
51
|
+
panel can show them. Pass `publish_source=False` to keep source private.
|
|
52
|
+
- **Stale worker gate.** A schedule can require a code version. A worker on a
|
|
53
|
+
different version doesn't run the task. The message goes back on the queue
|
|
54
|
+
for an up-to-date worker.
|
|
55
|
+
|
|
56
|
+
## Stopping duplicate runs
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from percival import once_per
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.task
|
|
63
|
+
@once_per(key=lambda user_id, **kw: user_id, window="1d")
|
|
64
|
+
def send_digest(user_id): ...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A second run for the same key in the same window is skipped and recorded.
|
|
68
|
+
|
|
69
|
+
## Moving an existing `beat_schedule`
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
percival import --app proj.celery:app --redis-url redis://localhost:6379/0 --apply
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then remove those entries from `beat_schedule`.
|
|
76
|
+
|
|
77
|
+
The [full README](https://github.com/RijinRaju/percival#readme) has every
|
|
78
|
+
option and a `docker compose up` demo.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Client side of Percival. Installed in the Celery project, not in the panel.
|
|
2
|
+
|
|
3
|
+
import percival
|
|
4
|
+
percival.init(app, redis_url="redis://...", code_version=os.environ["GIT_SHA"])
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from percival.config import UNKNOWN_VERSION, Settings
|
|
15
|
+
from percival.dedupe import once_per
|
|
16
|
+
from percival.runtime import ClientFactory, Runtime
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
__all__ = ["Settings", "__version__", "init", "once_per", "shutdown"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def init(
|
|
24
|
+
app: Any,
|
|
25
|
+
redis_url: str | None = None,
|
|
26
|
+
code_version: str | None = None,
|
|
27
|
+
*,
|
|
28
|
+
client_factory: ClientFactory | None = None,
|
|
29
|
+
**options: Any,
|
|
30
|
+
) -> Runtime:
|
|
31
|
+
"""Wire percival into a celery app. Safe to call more than once.
|
|
32
|
+
|
|
33
|
+
Any field of :class:`percival.Settings` can be passed as a keyword, e.g.
|
|
34
|
+
``redact_kwargs=[...]``, ``stale_action="drop"``, ``capture_args=False``.
|
|
35
|
+
``client_factory`` exists for tests, you shouldn't need it.
|
|
36
|
+
"""
|
|
37
|
+
# imported here so `import percival` stays cheap for things like the CLI
|
|
38
|
+
from percival import gate, heartbeat, metadata, runtime, signals
|
|
39
|
+
|
|
40
|
+
settings = Settings.build(redis_url=redis_url, code_version=code_version, **options)
|
|
41
|
+
if settings.code_version == UNKNOWN_VERSION:
|
|
42
|
+
log.warning(
|
|
43
|
+
"percival: no code_version set. pass code_version= or set PERCIVAL_CODE_VERSION, "
|
|
44
|
+
"otherwise schedules that require a version will be blocked on this worker"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
rt = runtime.configure(settings, client_factory=client_factory)
|
|
48
|
+
signals.connect()
|
|
49
|
+
heartbeat.connect()
|
|
50
|
+
metadata.connect()
|
|
51
|
+
gate.connect()
|
|
52
|
+
|
|
53
|
+
# if the worker is already up (init called late), gate the tasks now
|
|
54
|
+
if getattr(app, "finalized", False):
|
|
55
|
+
gate.install(app)
|
|
56
|
+
return rt
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def shutdown() -> None:
|
|
60
|
+
"""Undo init(). Mostly useful in tests."""
|
|
61
|
+
from percival import gate, heartbeat, metadata, runtime, signals
|
|
62
|
+
|
|
63
|
+
signals.disconnect()
|
|
64
|
+
heartbeat.disconnect()
|
|
65
|
+
metadata.disconnect()
|
|
66
|
+
gate.disconnect()
|
|
67
|
+
runtime.reset()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""RedBeat scheduler with per-schedule timezones and a heartbeat.
|
|
2
|
+
|
|
3
|
+
celery -A proj beat -S percival.beat.Scheduler
|
|
4
|
+
|
|
5
|
+
It's stock RedBeat underneath. Entries stay in RedBeat's format, so plain
|
|
6
|
+
``redbeat.RedBeatScheduler`` still reads them (crontabs just fall back to the
|
|
7
|
+
app timezone). What this adds:
|
|
8
|
+
|
|
9
|
+
- crontabs with a ``percival_timezone`` header are evaluated in that zone
|
|
10
|
+
- a heartbeat key so the panel knows beat is alive, when it last ticked and
|
|
11
|
+
who holds the lock
|
|
12
|
+
- a shorter default loop interval, so a newly created schedule is picked up
|
|
13
|
+
in seconds instead of up to 5 minutes
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import socket
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from celery.schedules import crontab
|
|
26
|
+
from redbeat.schedulers import RedBeatScheduler, RedBeatSchedulerEntry
|
|
27
|
+
|
|
28
|
+
from percival import __version__, runtime
|
|
29
|
+
from percival.naming import Headers
|
|
30
|
+
from percival.schedules import zoned_crontab
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
DEFAULT_MAX_INTERVAL = 15
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def apply_zone(sched: Any, options: dict[str, Any] | None, app: Any = None) -> Any:
|
|
38
|
+
headers = (options or {}).get("headers") or {}
|
|
39
|
+
zone = headers.get(Headers.TIMEZONE)
|
|
40
|
+
if not zone or not isinstance(sched, crontab) or isinstance(sched, zoned_crontab):
|
|
41
|
+
return sched
|
|
42
|
+
try:
|
|
43
|
+
return zoned_crontab.from_crontab(sched, zone, app=app)
|
|
44
|
+
except Exception:
|
|
45
|
+
log.warning("percival: unknown timezone %r, using the app timezone", zone)
|
|
46
|
+
return sched
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Entry(RedBeatSchedulerEntry): # type: ignore[misc]
|
|
50
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
51
|
+
super().__init__(*args, **kwargs)
|
|
52
|
+
self.schedule = apply_zone(self.schedule, self.options, self.app) # type: ignore[has-type]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Scheduler(RedBeatScheduler): # type: ignore[misc]
|
|
56
|
+
Entry = Entry
|
|
57
|
+
|
|
58
|
+
def __init__(self, app: Any, *args: Any, **kwargs: Any) -> None:
|
|
59
|
+
if kwargs.get("max_interval") is None and not app.conf.beat_max_loop_interval:
|
|
60
|
+
kwargs["max_interval"] = DEFAULT_MAX_INTERVAL
|
|
61
|
+
super().__init__(app, *args, **kwargs)
|
|
62
|
+
self._started_at = time.time()
|
|
63
|
+
|
|
64
|
+
def tick(self, *args: Any, **kwargs: Any) -> Any:
|
|
65
|
+
result = super().tick(*args, **kwargs)
|
|
66
|
+
self._heartbeat()
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
def _heartbeat(self) -> None:
|
|
70
|
+
rt = runtime.get_optional()
|
|
71
|
+
if rt is None:
|
|
72
|
+
return
|
|
73
|
+
payload = {
|
|
74
|
+
"hostname": socket.gethostname(),
|
|
75
|
+
"pid": os.getpid(),
|
|
76
|
+
"scheduler": f"{type(self).__module__}.{type(self).__name__}",
|
|
77
|
+
"started_at": self._started_at,
|
|
78
|
+
"last_tick": time.time(),
|
|
79
|
+
"max_interval": self.max_interval,
|
|
80
|
+
"lock_key": self.lock_key,
|
|
81
|
+
"code_version": rt.settings.code_version,
|
|
82
|
+
"client_version": __version__,
|
|
83
|
+
}
|
|
84
|
+
try:
|
|
85
|
+
rt.redis.get().set(
|
|
86
|
+
rt.keys.beat,
|
|
87
|
+
json.dumps(payload),
|
|
88
|
+
ex=int(max(self.max_interval * 3, 60)),
|
|
89
|
+
)
|
|
90
|
+
except Exception:
|
|
91
|
+
log.debug("percival: beat heartbeat failed", exc_info=True)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""``percival`` command line. Only ``import`` for now."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from percival.naming import NAME
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_app(spec: str) -> Any:
|
|
15
|
+
"""``proj.celery:app``, or just ``proj.celery`` if the app is called app/celery."""
|
|
16
|
+
# same as celery -A: make the current directory importable
|
|
17
|
+
if os.getcwd() not in sys.path:
|
|
18
|
+
sys.path.insert(0, os.getcwd())
|
|
19
|
+
|
|
20
|
+
module_name, _, attr = spec.partition(":")
|
|
21
|
+
module = importlib.import_module(module_name)
|
|
22
|
+
if attr:
|
|
23
|
+
return getattr(module, attr)
|
|
24
|
+
for candidate in ("app", "celery"):
|
|
25
|
+
if hasattr(module, candidate):
|
|
26
|
+
return getattr(module, candidate)
|
|
27
|
+
raise SystemExit(f"couldn't find a celery app in {module_name}, try {module_name}:app")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cmd_import(args: argparse.Namespace) -> int:
|
|
31
|
+
from percival import importer
|
|
32
|
+
|
|
33
|
+
app = load_app(args.app)
|
|
34
|
+
importer.configure_redbeat(app, args.redis_url, args.key_prefix)
|
|
35
|
+
planned = importer.plan(app, require_version=args.require_version, only=args.only or None)
|
|
36
|
+
print(importer.render(planned))
|
|
37
|
+
|
|
38
|
+
if not args.apply:
|
|
39
|
+
if any(p.action in (importer.CREATE, importer.UPDATE) for p in planned):
|
|
40
|
+
print("\ndry run, nothing written. run again with --apply to write these.")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
written = importer.apply(app, planned)
|
|
44
|
+
print(f"\nwrote {written} entries to redbeat.")
|
|
45
|
+
if written:
|
|
46
|
+
print(
|
|
47
|
+
"note: beat re-syncs beat_schedule from config on every start, so remove the\n"
|
|
48
|
+
"imported entries from your config or they will overwrite changes made in the panel."
|
|
49
|
+
)
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
54
|
+
parser = argparse.ArgumentParser(prog=NAME)
|
|
55
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
56
|
+
|
|
57
|
+
imp = sub.add_parser("import", help="copy beat_schedule from your app into redbeat")
|
|
58
|
+
imp.add_argument("--app", required=True, help="celery app, e.g. proj.celery:app")
|
|
59
|
+
imp.add_argument("--redis-url", help="redis used by redbeat (default: redbeat_redis_url)")
|
|
60
|
+
imp.add_argument("--key-prefix", help="redbeat_key_prefix if you changed it")
|
|
61
|
+
imp.add_argument(
|
|
62
|
+
"--require-version",
|
|
63
|
+
help="set the required code version on every imported entry",
|
|
64
|
+
)
|
|
65
|
+
imp.add_argument("--only", nargs="+", metavar="NAME", help="import just these entries")
|
|
66
|
+
imp.add_argument("--apply", action="store_true", help="actually write (default is dry run)")
|
|
67
|
+
imp.set_defaults(func=cmd_import)
|
|
68
|
+
return parser
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main(argv: list[str] | None = None) -> int:
|
|
72
|
+
args = build_parser().parse_args(argv)
|
|
73
|
+
result: int = args.func(args)
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
sys.exit(main())
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from percival.naming import ENV_PREFIX, NAME
|
|
9
|
+
|
|
10
|
+
DEFAULT_REDACT = (
|
|
11
|
+
"password",
|
|
12
|
+
"passwd",
|
|
13
|
+
"secret",
|
|
14
|
+
"token",
|
|
15
|
+
"api_key",
|
|
16
|
+
"apikey",
|
|
17
|
+
"authorization",
|
|
18
|
+
"cookie",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
UNKNOWN_VERSION = "unknown"
|
|
22
|
+
|
|
23
|
+
StaleAction = Literal["requeue", "drop"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _env(name: str) -> str | None:
|
|
27
|
+
value = os.environ.get(ENV_PREFIX + name)
|
|
28
|
+
return value if value else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _env_bool(name: str) -> bool | None:
|
|
32
|
+
value = _env(name)
|
|
33
|
+
if value is None:
|
|
34
|
+
return None
|
|
35
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Settings:
|
|
40
|
+
redis_url: str = "redis://localhost:6379/0"
|
|
41
|
+
code_version: str = UNKNOWN_VERSION
|
|
42
|
+
key_prefix: str = NAME
|
|
43
|
+
|
|
44
|
+
# what goes into run events
|
|
45
|
+
capture_args: bool = True
|
|
46
|
+
redact_kwargs: tuple[str, ...] = DEFAULT_REDACT
|
|
47
|
+
max_value_chars: int = 2_000
|
|
48
|
+
max_traceback_chars: int = 20_000
|
|
49
|
+
|
|
50
|
+
# what the stale-worker gate does with a blocked message
|
|
51
|
+
stale_action: StaleAction = "requeue"
|
|
52
|
+
requeue_delay: float = 5.0
|
|
53
|
+
max_requeues: int = 50
|
|
54
|
+
|
|
55
|
+
heartbeat_interval: float = 30.0
|
|
56
|
+
heartbeat_ttl: int = 90
|
|
57
|
+
|
|
58
|
+
events_maxlen: int = 100_000
|
|
59
|
+
publish_source: bool = True
|
|
60
|
+
source_max_lines: int = 300
|
|
61
|
+
|
|
62
|
+
# once_per behaviour when redis is down; False means skip the run
|
|
63
|
+
dedupe_fail_open: bool = False
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def build(cls, **overrides: Any) -> Settings:
|
|
67
|
+
"""Explicit arguments win, then PERCIVAL_* env vars, then defaults."""
|
|
68
|
+
values: dict[str, Any] = {}
|
|
69
|
+
|
|
70
|
+
env_map: dict[str, Any] = {
|
|
71
|
+
"redis_url": _env("REDIS_URL"),
|
|
72
|
+
"code_version": _env("CODE_VERSION"),
|
|
73
|
+
"key_prefix": _env("KEY_PREFIX"),
|
|
74
|
+
"capture_args": _env_bool("CAPTURE_ARGS"),
|
|
75
|
+
"dedupe_fail_open": _env_bool("DEDUPE_FAIL_OPEN"),
|
|
76
|
+
"stale_action": _env("STALE_ACTION"),
|
|
77
|
+
}
|
|
78
|
+
values.update({k: v for k, v in env_map.items() if v is not None})
|
|
79
|
+
values.update({k: v for k, v in overrides.items() if v is not None})
|
|
80
|
+
|
|
81
|
+
redact = values.get("redact_kwargs")
|
|
82
|
+
if redact is not None and not isinstance(redact, tuple):
|
|
83
|
+
values["redact_kwargs"] = _as_tuple(redact)
|
|
84
|
+
|
|
85
|
+
settings = cls(**values)
|
|
86
|
+
settings.validate()
|
|
87
|
+
return settings
|
|
88
|
+
|
|
89
|
+
def validate(self) -> None:
|
|
90
|
+
if self.stale_action not in ("requeue", "drop"):
|
|
91
|
+
raise ValueError(f"stale_action must be 'requeue' or 'drop', got {self.stale_action!r}")
|
|
92
|
+
if self.heartbeat_ttl <= self.heartbeat_interval:
|
|
93
|
+
raise ValueError("heartbeat_ttl has to be larger than heartbeat_interval")
|
|
94
|
+
|
|
95
|
+
def with_(self, **changes: Any) -> Settings:
|
|
96
|
+
return replace(self, **changes)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _as_tuple(items: Iterable[str] | str) -> tuple[str, ...]:
|
|
100
|
+
if isinstance(items, str):
|
|
101
|
+
return tuple(s.strip() for s in items.split(",") if s.strip())
|
|
102
|
+
return tuple(items)
|