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.
@@ -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/
@@ -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.
@@ -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)