action0-celery-sched 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.
Files changed (47) hide show
  1. action0_celery_sched-0.1.0/.github/workflows/ci.yml +99 -0
  2. action0_celery_sched-0.1.0/.github/workflows/release.yml +62 -0
  3. action0_celery_sched-0.1.0/.gitignore +20 -0
  4. action0_celery_sched-0.1.0/.python-version +1 -0
  5. action0_celery_sched-0.1.0/CLAUDE.md +83 -0
  6. action0_celery_sched-0.1.0/LICENSE +21 -0
  7. action0_celery_sched-0.1.0/PKG-INFO +218 -0
  8. action0_celery_sched-0.1.0/README.md +185 -0
  9. action0_celery_sched-0.1.0/docs/api.md +120 -0
  10. action0_celery_sched-0.1.0/docs/conf.py +48 -0
  11. action0_celery_sched-0.1.0/docs/index.md +112 -0
  12. action0_celery_sched-0.1.0/docs/usage.md +858 -0
  13. action0_celery_sched-0.1.0/pyproject.toml +154 -0
  14. action0_celery_sched-0.1.0/src/action0/celery_sched/__init__.py +45 -0
  15. action0_celery_sched-0.1.0/src/action0/celery_sched/crontabs.py +119 -0
  16. action0_celery_sched-0.1.0/src/action0/celery_sched/durations.py +117 -0
  17. action0_celery_sched-0.1.0/src/action0/celery_sched/entries.py +160 -0
  18. action0_celery_sched-0.1.0/src/action0/celery_sched/envvars.py +45 -0
  19. action0_celery_sched-0.1.0/src/action0/celery_sched/errors.py +114 -0
  20. action0_celery_sched-0.1.0/src/action0/celery_sched/formats.py +126 -0
  21. action0_celery_sched-0.1.0/src/action0/celery_sched/loader.py +122 -0
  22. action0_celery_sched-0.1.0/src/action0/celery_sched/py.typed +0 -0
  23. action0_celery_sched-0.1.0/src/action0/celery_sched/schedules.py +95 -0
  24. action0_celery_sched-0.1.0/src/action0/celery_sched/solar.py +94 -0
  25. action0_celery_sched-0.1.0/src/action0/celery_sched/sources.py +67 -0
  26. action0_celery_sched-0.1.0/src/action0/celery_sched/tasks.py +58 -0
  27. action0_celery_sched-0.1.0/src/action0/celery_sched/toml_loader.py +77 -0
  28. action0_celery_sched-0.1.0/src/action0/celery_sched/values.py +131 -0
  29. action0_celery_sched-0.1.0/src/action0/celery_sched/yaml_loader.py +112 -0
  30. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_crontabs.py +111 -0
  31. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_durations.py +148 -0
  32. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_entries.py +164 -0
  33. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_envvars.py +48 -0
  34. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_errors.py +84 -0
  35. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_format_parity.py +136 -0
  36. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_formats.py +129 -0
  37. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_init.py +23 -0
  38. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_loader.py +354 -0
  39. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_schedules.py +96 -0
  40. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_solar.py +80 -0
  41. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_sources.py +64 -0
  42. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_tasks.py +60 -0
  43. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_toml_loader.py +127 -0
  44. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_values.py +119 -0
  45. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_without_pyyaml.py +46 -0
  46. action0_celery_sched-0.1.0/tests/action0/celery_sched/test_yaml_loader.py +174 -0
  47. action0_celery_sched-0.1.0/uv.lock +1599 -0
@@ -0,0 +1,99 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ # cancel superseded runs of the same branch / PR
10
+ concurrency:
11
+ group: ${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ jobs:
15
+ checks:
16
+ name: checks (python ${{ matrix.python-version }})
17
+ runs-on: ubuntu-latest
18
+ continue-on-error: ${{ matrix.experimental }}
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
23
+ experimental: [false]
24
+ include:
25
+ # 3.15 is still a pre-release: run it, but don't fail the whole
26
+ # workflow while parts of the toolchain don't support it yet
27
+ - python-version: "3.15"
28
+ experimental: true
29
+
30
+ steps:
31
+ - uses: actions/checkout@v7
32
+
33
+ - name: Install uv
34
+ uses: astral-sh/setup-uv@v10.0.1
35
+ with:
36
+ python-version: ${{ matrix.python-version }}
37
+
38
+ - name: Install dependencies
39
+ run: uv sync --locked
40
+
41
+ - name: ruff format
42
+ run: uv run ruff format --check
43
+
44
+ - name: ruff check
45
+ run: uv run ruff check
46
+
47
+ - name: mypy
48
+ run: uv run mypy
49
+
50
+ - name: pyright
51
+ run: uv run pyright
52
+
53
+ - name: ty
54
+ run: uv run ty check
55
+
56
+ - name: pytest
57
+ run: uv run pytest
58
+
59
+ docs:
60
+ name: docs build
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - uses: actions/checkout@v7
64
+
65
+ - name: Install uv
66
+ uses: astral-sh/setup-uv@v10.0.1
67
+
68
+ - name: Install dependencies
69
+ run: uv sync --locked --group docs
70
+
71
+ - name: Build documentation (warnings are errors)
72
+ run: uv run --group docs sphinx-build -W --keep-going -b html docs docs/_build/html
73
+
74
+ - name: Upload pages artifact
75
+ uses: actions/upload-pages-artifact@v5
76
+ with:
77
+ path: docs/_build/html
78
+
79
+ deploy-docs:
80
+ name: deploy docs
81
+ # publish only what landed on main
82
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
83
+ needs: docs
84
+ runs-on: ubuntu-latest
85
+ permissions:
86
+ contents: read
87
+ pages: write
88
+ id-token: write
89
+ environment:
90
+ name: github-pages
91
+ url: ${{ steps.deployment.outputs.page_url }}
92
+ # the Pages site itself was enabled once via
93
+ # `gh api repos/<owner>/<repo>/pages -X POST -f build_type=workflow`;
94
+ # creating it from a workflow is impossible (GITHUB_TOKEN cannot get
95
+ # repo-administration rights)
96
+ steps:
97
+ - name: Deploy to GitHub Pages
98
+ id: deployment
99
+ uses: actions/deploy-pages@v5
@@ -0,0 +1,62 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ name: check and build
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v10.0.1
16
+
17
+ - name: Install dependencies
18
+ run: uv sync --locked
19
+
20
+ - name: Run all checks
21
+ run: |
22
+ uv run ruff format --check
23
+ uv run ruff check
24
+ uv run mypy
25
+ uv run pyright
26
+ uv run ty check
27
+ uv run pytest
28
+
29
+ - name: Check that the tag matches __version__
30
+ run: |
31
+ version=$(uv run python -c "from action0.celery_sched import __version__; print(__version__)")
32
+ if [ "v$version" != "$GITHUB_REF_NAME" ]; then
33
+ echo "tag $GITHUB_REF_NAME does not match __version__ $version" >&2
34
+ exit 1
35
+ fi
36
+
37
+ - name: Build sdist and wheel
38
+ run: uv build
39
+
40
+ - uses: actions/upload-artifact@v7
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+
45
+ publish:
46
+ name: publish to PyPI
47
+ needs: build
48
+ runs-on: ubuntu-latest
49
+ environment:
50
+ name: pypi
51
+ url: https://pypi.org/p/action0-celery-sched
52
+ permissions:
53
+ # OIDC token for PyPI trusted publishing
54
+ id-token: write
55
+ steps:
56
+ - uses: actions/download-artifact@v8
57
+ with:
58
+ name: dist
59
+ path: dist/
60
+
61
+ - name: Publish to PyPI
62
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,20 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Editor files
13
+ .idea
14
+
15
+ # Claude Code local state
16
+ .claude/settings.local.json
17
+ .claude/worktrees/
18
+
19
+ # Sphinx build output
20
+ docs/_build/
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,83 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project
6
+
7
+ `action0-celery-sched` is a Python library that reads Celery beat schedules from YAML or TOML files (or already-parsed mappings) and turns them into the value of Celery's `beat_schedule` setting (`app.conf.beat_schedule = load_beat_schedule("beat.yaml")`). It ships the `action0.celery_sched` package (`action0` is a PEP 420 namespace package) from a `src/` layout, is built with hatchling, and uses `uv` for environment/dependency management. The only runtime dependency is `celery`: TOML uses the stdlib `tomllib`, YAML needs the optional `yaml` extra (PyYAML), and the optional `solar` extra adds `ephem`, which Celery needs to build `solar` schedules. Importing the package must never import PyYAML — `test_without_pyyaml.py` guards that.
8
+
9
+ ## Rules
10
+
11
+ - **Never commit without asking.** Also never push, tag, or publish on your own.
12
+ - **Branches + PRs.** All changes go through feature branches and GitHub pull requests that Simon reviews and merges — never commit to `main` directly. (Only the initial implementation is built directly on `main`; once that phase is over, this applies without exception.)
13
+ - **Discuss first.** Always present the plan and the intended edits and get agreement before changing files.
14
+ - Every code change comes with: tests, docstrings, inline comments where the code isn't self-explanatory, and updated usage examples in `README.md` and the Sphinx docs (`docs/usage.md`).
15
+ - Before considering work done, run ruff, mypy, pyright, ty and pytest (commands below) and fix what they report.
16
+ - Supported Python versions: 3.11 up to the latest release. Don't use syntax or stdlib features introduced after 3.11 (no PEP 695 `type` aliases or generics — use `TypeAlias`), and don't rely on behavior removed in newer versions.
17
+ - Prefer many small modules and short functions over large ones.
18
+
19
+ ## Commands
20
+
21
+ `uv run` syncs the environment automatically (the dev dependency group is installed by default), so no separate install step is needed.
22
+
23
+ ```sh
24
+ uv run pytest # all tests
25
+ uv run pytest tests/action0/celery_sched/test_loader.py # one file
26
+ uv run pytest tests/action0/celery_sched/test_loader.py::MergeTestCase::test_replace # one test
27
+
28
+ uv run ruff check # lint (add --fix to autofix)
29
+ uv run ruff format # format
30
+ uv run mypy # type-check (strict; files are configured in pyproject.toml)
31
+ uv run pyright # type-check
32
+ uv run ty check # type-check
33
+
34
+ uv run --group docs sphinx-build -W --keep-going -b html docs docs/_build/html # build docs
35
+
36
+ uv build # build sdist + wheel into dist/
37
+ ```
38
+
39
+ `pytest` also runs the `>>>` examples in the docstrings as doctests (`--doctest-modules` over `src/`), so docstring examples must produce their shown output exactly.
40
+
41
+ ## Architecture
42
+
43
+ The file format is the same in YAML and TOML — Simon's requirement: an entry is identical regardless of format. A top-level mapping of entry name → entry; entry keys `task` (required), `schedule` (required), `params` (positional args), `kw` (keyword args), `options` (passed to `apply_async`), `enabled`. The `schedule` has exactly one of `every` / `crontab` / `solar` (plus `relative`, only next to `every`). The names `kw`/`params` are the user-facing names chosen by Simon; they map to Celery's `kwargs`/`args`.
44
+
45
+ Data flow: source (path / stream / mapping) → `sources.read_source` → `formats.detect_format` + `formats.parse_text` → `yaml_loader.load_yaml` or `toml_loader.load_toml` → plain Python data → `loader._parse_document` → `entries.parse_entry` (and below it the format-agnostic parsers) → `Entry` → `to_celery()`. Everything from `_parse_document` down never knows which format the data came from; keep it that way.
46
+
47
+ Modules under `src/action0/celery_sched/`, one concern each, from the leaves up:
48
+
49
+ - `errors.py` — `ScheduleError` base; `DefinitionError` carries `reason` plus a location (`source` file, `entry` name, key `path`) rendered as `beat.yaml: entry 'X': schedule.crontab.hour: reason`; `DuplicateEntryError`; `UnknownTaskError` (with `missing`). `located(*keys, entry=, source=)` is the context manager that fills the location in on the way up — parsers only ever raise with a reason and wrap each descent in `with located("key"):`.
50
+ - `values.py` — shared validators: `expect_mapping`, `check_keys` (unknown keys are errors — the typo guard), `as_number`, `as_bool`, `describe`. Number/boolean validators accept string spellings because `!ENV` always yields strings.
51
+ - `durations.py` — `parse_duration`: seconds (number or numeric string), duration strings (`1h30m`, units `w d h m s ms`, lowercase only), or a `timedelta`-kwargs mapping; must be positive and finite.
52
+ - `crontabs.py` — `parse_crontab`: five-field cron string (cron order; own whitespace split because Celery's `crontab.from_string` splits on single spaces only), `@daily`-style nicknames, or a mapping of Celery's field names. Each field is validated by constructing a one-field `crontab` so the error is pinned to its key.
53
+ - `solar.py` — `SolarEvent` (StrEnum of Celery's events) and `parse_solar`; range checks happen before construction, and a missing `ephem` becomes an `ImportError` naming the `solar` extra.
54
+ - `schedules.py` — `parse_schedule` dispatches on the one kind key. Intervals become `celery.schedules.schedule(timedelta, relative=...)` objects, so every kind comes out as a `BaseSchedule` that beat takes as-is.
55
+ - `entries.py` — `Entry` (frozen dataclass; `source` excluded from equality), `BeatEntry` (TypedDict of one `beat_schedule` value), `parse_entry`. Argument containers are deep-copied so YAML-anchor-shared mappings are never shared at runtime.
56
+ - `envvars.py` — `substitute_env` for `${VAR}` / `${VAR:-fallback}`; textual, never re-parsed (same semantics as action0-service's `!ENV`).
57
+ - `yaml_loader.py` — `load_yaml` (wraps `yaml.YAMLError` into `DefinitionError`) and `ScheduleLoader`, a `SafeLoader` with the `!ENV` tag (unset variables are reported with their line — entry/key aren't known while parsing) and duplicate-key detection. Duplicates are checked in `flatten_mapping` (the one hook that sees a mapping's own pairs before merge keys are spliced in), once per node — a node can be flattened again as a merge source after it was rewritten, which would otherwise produce false duplicates (there are tests for exactly that). A duplicate at the root is a `DuplicateEntryError`.
58
+ - `toml_loader.py` — `load_toml`: `tomllib.loads` (syntax errors, which include TOML's own duplicate-key errors, become `DefinitionError`), then a walk substituting strings that start with `!ENV` + whitespace — TOML has no tags, so the tag is spelled as a string prefix (precedent: dynaconf's `"@int ..."`). The walk runs under `located()`, so unset variables report entry and key path. Keys are never substituted.
59
+ - `formats.py` — `Format` (StrEnum YAML/TOML), `FormatLike` (`Format | Literal[...]`, like action0-pipeline's `ErrorPolicyLike`), `SUFFIXES`, `detect_format` (explicit `format` wins, else suffix — case-insensitive `.yaml`/`.yml`/`.toml`; anything else raises `ValueError`, deliberately no default), `parser_for` (imports `yaml_loader` lazily; an `ImportError` whose `name` is `"yaml"` becomes one naming the `yaml` extra, any other `ImportError` propagates untouched) and `parse_text` (dispatch through `parser_for`). Nothing may import `yaml_loader` at module level.
60
+ - `sources.py` — `Source` (path | `IO[str]` | `IO[bytes]` | `Mapping`) and `read_source`: mappings pass through unnamed (no `!ENV`, no format); paths and streams get their format detected and their parser obtained *before* any I/O (so an unknown format or a missing PyYAML fails without touching the file), binary streams are decoded as UTF-8 (tomllib users open files `"rb"`).
61
+ - `loader.py` — `load_entries(*sources, replace=False, format=None)` (all entries incl. disabled, merged in order across formats; cross-source duplicates raise unless `replace`, which keeps the first position) and `load_beat_schedule` (enabled entries → `to_celery()`). `_parse_document` is the format-agnostic document layer: `None` → empty, top level must be a mapping, top-level keys starting with `.` are templates (anchor carriers in YAML, merely skipped in TOML).
62
+ - `tasks.py` — `check_tasks(app, schedule=None)` compares task names with `app.tasks` (default: the app's own `beat_schedule`). Documented to run in a `beat_init` handler, because beat imports task modules right before sending that signal.
63
+
64
+ Deliberate decisions worth keeping:
65
+
66
+ - **Strict over lenient**: unknown keys, duplicate keys and invalid crontab fields fail at load time — a silently dropped schedule is the failure mode this library exists to prevent.
67
+ - **Explicit schedule kinds** (`every` / `crontab` / `solar`), no shape-inferred scalar shorthand — chosen by Simon.
68
+ - `replace=True` replaces whole entries; there is no field-level patching across files.
69
+ - Format differences that are inherent and documented, not papered over: anchors/templates are YAML-only; a duplicate entry within one TOML file is a TOML syntax error (a `DefinitionError`, not a `DuplicateEntryError` — parsing tomllib's message would be fragile); TOML has no null, so empty `kw:`-style values are simply omitted.
70
+ - `tests/action0/celery_sched/test_format_parity.py` loads one feature-complete schedule written in both formats and requires identical entries (including `schedule.relative`, which Celery's `schedule.__eq__` ignores). Any new entry feature must be added to both spellings there.
71
+
72
+ Conventions:
73
+
74
+ - The version is single-sourced as `__version__` in `src/action0/celery_sched/__init__.py`; hatch extracts it with the regex in `[tool.hatch.version]`. Bump it only there.
75
+ - Releases: pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which re-runs all checks, verifies the tag matches `__version__`, builds, and publishes to PyPI via trusted publishing (environment `pypi`). Never bump the version, tag, or publish on your own — releasing is the user's call.
76
+ - Tests mirror the `src/` layout under `tests/action0/celery_sched/` and are `unittest.TestCase` classes, executed via pytest. `test_loader.py` includes an integration test that feeds the result (from both formats) into Celery's own `beat.Scheduler`.
77
+ - `test_without_pyyaml.py` runs a subprocess with `sys.modules["yaml"] = None` (stricter than uninstalling: it also catches indirect imports) and checks that the package imports, TOML and mapping sources load, and a YAML source raises the extra's `ImportError` before any I/O.
78
+ - Celery ships no type information; the dev group includes `celery-types` (stubs), `pyyaml` (the `yaml` extra, for tests and docs) and `types-pyyaml`. Where the stubs lack something (e.g. `solar._all_events`), use `getattr` with a comment rather than an ignore. When an ignore is unavoidable, silence each checker with its own syntax (`# type: ignore[code] # ty: ignore[code]`).
79
+ - Ruff enforces one import per line (isort `force-single-line`), line length 99, `action0` as first-party. Ruff only honours `.gitignore` inside a git repository.
80
+ - Docs live in `docs/` (Sphinx + Furo, MyST Markdown pages, sphinx-design tabs, autodoc for the API reference, intersphinx to Python and Celery). Docstrings are Sphinx-reST (`:param:`, `:py:func:` roles). CI builds them with `-W` on every run and deploys to GitHub Pages on pushes to `main`. Examples in `docs/usage.md` and `README.md` show real outputs and error messages — keep them truthful, and keep every fenced `yaml`/`toml` block valid.
81
+ - **Both formats, equally (Simon's rule):** every example is given in YAML *and* TOML — in the guide, the index, the README and docstring examples. YAML comes first, TOML second. In Sphinx pages, use a sphinx-design tab-set with `:sync-group: format` and tab items `YAML` (`:sync: yaml`) then `TOML` (`:sync: toml`); a shown error message goes inside the tab next to the input that causes it. On GitHub-rendered files (README) and in docstrings, a YAML block followed by the equivalent TOML block. Each pair must mean the same (identical entries). Prose stays format-neutral: write `schedule`, not `schedule:` or `schedule =`. Only features that exist in one format alone (YAML anchors/templates) are shown in one format, next to how the other format does without.
82
+ - The GitHub Pages site must be enabled once per repo before `deploy-docs` can run: `gh api repos/LaughInJar/action0-celery-sched/pages -X POST -f build_type=workflow`.
83
+ - The README carries an AI-usage disclosure section — keep it accurate when the development workflow changes.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LaughInJar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.5
2
+ Name: action0-celery-sched
3
+ Version: 0.1.0
4
+ Summary: Celery beat schedules defined in YAML or TOML files
5
+ Project-URL: Homepage, https://github.com/LaughInJar/action0-celery-sched
6
+ Project-URL: Documentation, https://laughinjar.github.io/action0-celery-sched/
7
+ Project-URL: Source, https://github.com/LaughInJar/action0-celery-sched
8
+ Project-URL: Issues, https://github.com/LaughInJar/action0-celery-sched/issues
9
+ Author: Simon Lachinger
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: beat,celery,celery-beat,crontab,periodic-tasks,schedule,toml,yaml
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: Celery
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Topic :: System :: Distributed Computing
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.11
27
+ Requires-Dist: celery>=5.3
28
+ Provides-Extra: solar
29
+ Requires-Dist: ephem>=4.1; extra == 'solar'
30
+ Provides-Extra: yaml
31
+ Requires-Dist: pyyaml>=6.0; extra == 'yaml'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # Action0-Celery-Sched
35
+
36
+ [![CI](https://github.com/LaughInJar/action0-celery-sched/actions/workflows/ci.yml/badge.svg)](https://github.com/LaughInJar/action0-celery-sched/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/action0-celery-sched)](https://pypi.org/project/action0-celery-sched/)
38
+
39
+ Celery beat schedules defined in YAML or TOML files: which task runs when,
40
+ with which arguments, kept out of the code and validated when the app starts.
41
+
42
+ Requires Python 3.11 or newer and Celery 5.3 or newer.
43
+
44
+ Full documentation including the API reference:
45
+ <https://laughinjar.github.io/action0-celery-sched/>
46
+
47
+ **Status:** early. The file format in YAML and TOML, all three kinds of Celery
48
+ schedule, the validation and the task check work and are covered by tests; the
49
+ API may still move.
50
+
51
+ ## Installation
52
+
53
+ ```shell
54
+ pip install action0-celery-sched # TOML schedules
55
+ pip install "action0-celery-sched[yaml]" # YAML schedules too (PyYAML)
56
+ pip install "action0-celery-sched[solar]" # solar schedules (ephem)
57
+ pip install "action0-celery-sched[yaml,solar]" # all of it
58
+ ```
59
+
60
+ TOML needs nothing beyond the standard library's `tomllib`; YAML needs PyYAML,
61
+ which the `yaml` extra brings along. (`uv add` works the same way.)
62
+
63
+ ## Usage
64
+
65
+ Write the schedule down, in YAML or in TOML — the entries are the same in
66
+ both:
67
+
68
+ ```yaml
69
+ # beat.yaml
70
+ "Poll feed":
71
+ task: myapp.feeds.tasks.poll
72
+ schedule:
73
+ every: 5m # or 300, 90s, 1h30m, {minutes: 5}
74
+
75
+ "Nightly report":
76
+ task: myapp.reports.tasks.nightly
77
+ kw: # keyword arguments
78
+ recipients: [ops@example.com]
79
+ params: # positional arguments
80
+ - daily
81
+ schedule:
82
+ crontab: "0 3 * * *" # or {minute: 0, hour: 3}, or @daily
83
+ options: # passed on to apply_async()
84
+ queue: reports
85
+
86
+ "Sunset lights":
87
+ task: myapp.home.tasks.lights_on
88
+ schedule:
89
+ solar: {event: sunset, lat: 48.21, lon: 16.37}
90
+ ```
91
+
92
+ ```toml
93
+ # beat.toml
94
+ ["Poll feed"]
95
+ task = "myapp.feeds.tasks.poll"
96
+ schedule = { every = "5m" } # or 300, "90s", "1h30m", { minutes = 5 }
97
+
98
+ ["Nightly report"]
99
+ task = "myapp.reports.tasks.nightly"
100
+ kw = { recipients = ["ops@example.com"] } # keyword arguments
101
+ params = ["daily"] # positional arguments
102
+ schedule = { crontab = "0 3 * * *" } # or { minute = 0, hour = 3 }, or "@daily"
103
+ options = { queue = "reports" } # passed on to apply_async()
104
+
105
+ ["Sunset lights"]
106
+ task = "myapp.home.tasks.lights_on"
107
+ schedule = { solar = { event = "sunset", lat = 48.21, lon = 16.37 } }
108
+ ```
109
+
110
+ And hand it to Celery:
111
+
112
+ ```python
113
+ from celery import Celery
114
+ from action0.celery_sched import load_beat_schedule
115
+
116
+ app = Celery("myapp")
117
+ app.conf.beat_schedule = load_beat_schedule("beat.yaml")
118
+ # or
119
+ app.conf.beat_schedule = load_beat_schedule("beat.toml")
120
+ ```
121
+
122
+ The suffix decides the format (`format="yaml"` or `format="toml"` for anything
123
+ else). A mapping works too, e.g. the `beat` part of a larger YAML or TOML
124
+ config: `load_beat_schedule(settings["beat"])`.
125
+
126
+ The result is plain Celery, a `beat_schedule` dict of `schedule`, `crontab`
127
+ and `solar` objects:
128
+
129
+ ```python
130
+ {'Poll feed': {'task': 'myapp.feeds.tasks.poll',
131
+ 'schedule': <freq: 5.00 minutes>,
132
+ 'args': (),
133
+ 'kwargs': {},
134
+ 'options': {}},
135
+ ...}
136
+ ```
137
+
138
+ Everything is validated while loading. An unknown key such as a misspelled
139
+ `shedule`, an impossible crontab field, or an entry name used twice is an
140
+ error naming the file, the entry and the key — the same message for both
141
+ formats, here for the crontab `"0 25 * * *"`:
142
+
143
+ ```text
144
+ beat.yaml: entry 'Nightly report': schedule.crontab.hour: invalid value '25': Invalid end range: 25 > 23.
145
+ beat.toml: entry 'Nightly report': schedule.crontab.hour: invalid value '25': Invalid end range: 25 > 23.
146
+ ```
147
+
148
+ Values that differ per environment can come from environment variables, and
149
+ entries can be switched off without deleting them. In YAML `!ENV` is a tag;
150
+ TOML has no tags, so there it is a prefix of the string:
151
+
152
+ ```yaml
153
+ "Nightly report":
154
+ task: myapp.reports.tasks.nightly
155
+ schedule:
156
+ crontab: !ENV ${REPORT_CRON:-0 3 * * *}
157
+ enabled: !ENV ${REPORTS_ENABLED:-true}
158
+ ```
159
+
160
+ ```toml
161
+ ["Nightly report"]
162
+ task = "myapp.reports.tasks.nightly"
163
+ schedule = { crontab = "!ENV ${REPORT_CRON:-0 3 * * *}" }
164
+ enabled = "!ENV ${REPORTS_ENABLED:-true}"
165
+ ```
166
+
167
+ Several files merge in order, whatever their format. With `replace=True` a
168
+ later file may override or disable entries of an earlier one:
169
+
170
+ ```python
171
+ app.conf.beat_schedule = load_beat_schedule(
172
+ "beat/common.yaml", f"beat/{environment}.toml", replace=True
173
+ )
174
+ ```
175
+
176
+ A misspelled `task` name would only surface when the task is first due. Catch
177
+ it when beat starts instead:
178
+
179
+ ```python
180
+ from celery.signals import beat_init
181
+ from action0.celery_sched import check_tasks
182
+
183
+
184
+ @beat_init.connect
185
+ def check_schedule(sender, **kwargs):
186
+ check_tasks(sender.app) # raises UnknownTaskError listing every unknown name
187
+ ```
188
+
189
+ See the [usage guide](https://laughinjar.github.io/action0-celery-sched/usage.html)
190
+ for the full file format, every example in both YAML and TOML: every interval
191
+ and crontab spelling, `relative` intervals, how the two formats differ,
192
+ templates and anchors, and the error types.
193
+
194
+ The `action0` namespace is simply the one the author likes to use for
195
+ personal projects.
196
+
197
+ ## Development
198
+
199
+ ```shell
200
+ uv run pytest # tests (incl. doctests in src/)
201
+ uv run ruff check # lint
202
+ uv run ruff format # format
203
+ uv run mypy # type-check (strict)
204
+ uv run pyright # type-check
205
+ uv run ty check # type-check
206
+ ```
207
+
208
+ ## AI disclosure
209
+
210
+ This library is developed with heavy use of AI coding tools: the code,
211
+ tests, and documentation are largely written by
212
+ [Claude Code](https://claude.com/claude-code), working from the author's
213
+ design brief and reviewed by the author. If that changes how much you want
214
+ to rely on this package, that's a fair call — read the source, it's small.
215
+
216
+ ## License
217
+
218
+ MIT — see [LICENSE](https://github.com/LaughInJar/action0-celery-sched/blob/main/LICENSE).