{"id":51544982,"url":"https://github.com/robertolima-dev/rust-py-scheduler","last_synced_at":"2026-07-09T17:01:53.577Z","repository":{"id":365975735,"uuid":"1274564057","full_name":"robertolima-dev/rust-py-scheduler","owner":"robertolima-dev","description":null,"archived":false,"fork":false,"pushed_at":"2026-06-19T17:14:48.000Z","size":51,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-19T18:29:27.909Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/robertolima-dev.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-19T16:39:43.000Z","updated_at":"2026-06-19T17:14:50.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/robertolima-dev/rust-py-scheduler","commit_stats":null,"previous_names":["robertolima-dev/rust-py-scheduler"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/robertolima-dev/rust-py-scheduler","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/robertolima-dev%2Frust-py-scheduler","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/robertolima-dev%2Frust-py-scheduler/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/robertolima-dev%2Frust-py-scheduler/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/robertolima-dev%2Frust-py-scheduler/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/robertolima-dev","download_url":"https://codeload.github.com/robertolima-dev/rust-py-scheduler/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/robertolima-dev%2Frust-py-scheduler/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35306717,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-09T02:00:07.329Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-09T17:01:52.746Z","updated_at":"2026-07-09T17:01:53.571Z","avatar_url":"https://github.com/robertolima-dev.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# rust-py-scheduler\n\nLightweight, performant task scheduler for Python applications, with a Rust core.\n\n**[Website](https://rust-py-scheduler.vercel.app/)** · **[PyPI](https://pypi.org/project/rust-py-scheduler/)** · **[GitHub](https://github.com/robertolima-dev/rust-py-scheduler)**\n\nRegister interval-based jobs (`\"10s\"`, `\"5m\"`, `\"1h\"`) or cron jobs (`\"0 9 * * 1-5\"`) with a plain function call or a decorator, run them in-process or on a background thread, and inspect their state (run/error counts, last error, next run time) at any point — without a database or an external broker. First-class FastAPI, Django, and Celery integrations are included.\n\n---\n\n## Features\n\n- **`Scheduler`** — simple API: `every()`, `cron()`, `list_jobs()`, `remove_job()`, `run()`, `start_background()`, `shutdown()`\n- **Two ways to register a job** — a direct call (`scheduler.every(\"5s\", fn)`) or a decorator (`@scheduler.every(\"5s\")`); both return the same registration\n- **Interval scheduling** — `\"10s\"`, `\"5m\"`, `\"1h\"` (seconds/minutes/hours)\n- **Cron scheduling** — standard 5-field Unix expressions, e.g. `scheduler.cron(\"0 9 * * 1-5\", fn)` for weekdays at 9am\n- **Framework integrations** — start/stop with the app lifecycle on **FastAPI** and **Django**, and trigger **Celery** tasks on a schedule\n- **Run in-process or in the background** — block the calling thread with `run()`, or call `start_background()` to schedule on a dedicated OS thread and keep going\n- **Per-job retries** — `max_retries=N` retries a failing job up to `N` extra times, immediately, before counting it as an error\n- **Jobs never crash the loop** — an exception in one job is caught, printed, and tracked (`error_count`, `last_error`); every other job keeps running on schedule\n- **Rust core** — scheduling, timing, retries, and thread management run in Rust via PyO3; the Python API stays small and easy to read\n\n---\n\n## Requirements\n\n- Python 3.10+\n- No required runtime dependencies (the extension is a self-contained native module, built for the stable ABI — one wheel works across 3.10–3.13+)\n\n---\n\n## Installation\n\n```bash\npip install rust-py-scheduler\n```\n\nWith framework integrations:\n\n```bash\npip install \"rust-py-scheduler[fastapi]\"   # FastAPI + uvicorn\npip install \"rust-py-scheduler[django]\"    # Django 4.2+\npip install \"rust-py-scheduler[celery]\"    # Celery\n```\n\nFor running the test suite:\n\n```bash\npip install \"rust-py-scheduler[tests]\"\n```\n\n---\n\n## Quick Start\n\n```python\nimport time\n\nfrom rust_py_scheduler import Scheduler\n\nscheduler = Scheduler()\n\n# Direct call: registers immediately and returns the job id.\njob_id = scheduler.every(\"2s\", lambda: print(\"tick (direct call)\"))\n\n\n# Decorator form: same registration, but `report` stays a normal,\n# directly-callable function afterwards.\n@scheduler.every(\"3s\", max_retries=2)\ndef report():\n    print(\"tick (decorator, with retry budget)\")\n\n\nscheduler.start_background()  # returns immediately, runs on its own OS thread\ntime.sleep(7)\n\nfor job in scheduler.list_jobs():\n    print(job)\n\nscheduler.remove_job(job_id)\nscheduler.shutdown()\n```\n\nSee [`examples/basic_usage.py`](examples/basic_usage.py) for the full, runnable version of this script.\n\n---\n\n## Registering Jobs\n\n```python\nscheduler.every(\"5s\", my_function)                 # direct call\nscheduler.every(\"5s\", my_function, max_retries=3)   # with a retry budget\n\n@scheduler.every(\"5s\")                              # decorator\ndef my_function():\n    ...\n\n@scheduler.every(\"5s\", max_retries=3)               # decorator, with retries\ndef my_function():\n    ...\n```\n\n- `interval` accepts an integer amount followed by `s` (seconds), `m` (minutes), or `h` (hours) — e.g. `\"30s\"`, `\"5m\"`, `\"2h\"`. Anything else (empty, missing unit, zero, negative, non-numeric) raises `ValueError` immediately, when `every()` is called — not later, when the job would have run.\n- The decorator form always returns the original function unchanged (`__name__`, behavior, everything) — it's safe to keep calling it directly elsewhere in your code.\n- `max_retries` (default `0`) works identically in both forms — see [Error Handling \u0026 Retries](#error-handling--retries).\n\n---\n\n## Cron Scheduling\n\nFor calendar-based schedules (\"every weekday at 9am\", \"top of every hour\"), use `cron()` with a standard 5-field Unix expression. It has the exact same dual call/decorator API as `every()`, including `max_retries`.\n\n```python\nscheduler.cron(\"0 * * * *\", my_function)          # every hour, on the hour\nscheduler.cron(\"*/15 * * * *\", my_function)        # every 15 minutes\n\n@scheduler.cron(\"0 9 * * 1-5\")                     # weekdays at 9am\ndef morning_report():\n    ...\n\n@scheduler.cron(\"30 2 * * *\", max_retries=2)       # daily at 02:30, with retries\ndef nightly_cleanup():\n    ...\n```\n\nThe five fields are `minute hour day-of-month month day-of-week`:\n\n| Field | Range | Notes |\n|---|---|---|\n| minute | `0–59` | |\n| hour | `0–23` | |\n| day of month | `1–31` | |\n| month | `1–12` | |\n| day of week | `0–7` | `0` and `7` both mean Sunday |\n\nEach field supports `*`, a single number (`5`), a range (`9-17`), a step (`*/15`, `9-17/2`), and comma-separated lists of those (`0,30`, `9-11,17`). When **both** day-of-month and day-of-week are restricted (neither is `*`), a time matches if **either** field matches — the same rule as Vixie cron.\n\n- **Timezone:** expressions are evaluated in the **system's local timezone**.\n- **Resolution:** cron has minute resolution; the smallest meaningful interval is one minute. For sub-minute schedules, use `every(\"30s\", ...)`.\n- An invalid expression (wrong field count, out-of-range value, bad syntax) raises `ValueError` immediately at registration, just like `every()`.\n\n---\n\n## Framework Integrations\n\n### FastAPI\n\nStart the scheduler with the app and stop it on shutdown, via the modern `lifespan` API:\n\n```python\nfrom fastapi import FastAPI\nfrom rust_py_scheduler import Scheduler\nfrom rust_py_scheduler.fastapi import scheduler_lifespan\n\nscheduler = Scheduler()\n\n@scheduler.every(\"30s\")\ndef heartbeat():\n    ...\n\napp = FastAPI(lifespan=scheduler_lifespan(scheduler))\n```\n\nAlready have a lifespan? Compose with it: `scheduler_lifespan(scheduler, your_lifespan)` — your startup runs after the scheduler is up, your shutdown before it stops. See [`examples/fastapi_example.py`](examples/fastapi_example.py).\n\n### Django\n\nStart the scheduler from your app's `AppConfig.ready()`:\n\n```python\n# apps.py\nfrom django.apps import AppConfig\nfrom rust_py_scheduler import Scheduler\nfrom rust_py_scheduler.django import start_in_background\n\nscheduler = Scheduler()\n\n@scheduler.every(\"5m\")\ndef refresh_cache():\n    ...\n\nclass MyAppConfig(AppConfig):\n    name = \"myapp\"\n\n    def ready(self):\n        start_in_background(scheduler)\n```\n\n`start_in_background()` is idempotent per process (calling `ready()` twice won't start a second thread) and registers a best-effort `atexit` shutdown for normal process exit.\n\n\u003e **Multi-worker note:** under gunicorn/uwsgi with `workers \u003e 1`, every worker process runs `ready()` and therefore its own scheduler — jobs run once *per worker*, with no cross-process coordination. For exactly-once cluster-wide scheduling, run the scheduler in a single dedicated process (e.g. a management command calling `scheduler.run()`). See [`examples/django_apps.py`](examples/django_apps.py).\n\n### Celery\n\nThere's no `rust_py_scheduler.celery` module — and that's deliberate. A Celery task's `.delay` / `.apply_async` is just a callable, so the existing API schedules it with nothing new to learn:\n\n```python\n@app.task\ndef send_report(name):\n    ...\n\nscheduler.every(\"5m\", lambda: send_report.delay(\"daily-metrics\"))\nscheduler.cron(\"0 8 * * 1-5\", lambda: send_report.delay(\"weekday-digest\"))\n\n# countdown/eta/routing? use apply_async:\nscheduler.every(\"1h\", lambda: send_report.apply_async(args=[\"hourly\"], countdown=10))\n```\n\nThe scheduler runs in your process and only *enqueues* messages; the Celery worker (a separate process) does the work — so Celery keeps owning retries, routing, and concurrency. See [`examples/celery_example.py`](examples/celery_example.py).\n\n---\n\n## Background Execution\n\n```python\nscheduler.run()  # blocks the calling thread until shutdown() is called\n```\n\n```python\nscheduler.start_background()  # returns immediately; scheduling continues on a new OS thread\n...\nscheduler.shutdown()  # stops the loop and waits for the background thread to finish\n```\n\n- `run()` releases the GIL while idle, so other Python threads (e.g. one calling `shutdown()`) keep running normally.\n- `start_background()` raises `RuntimeError` if called again while already running.\n- `shutdown()` is safe to call even if the scheduler was never started, and safe to call from inside a job callback. It is **one-way**: once stopped, a `Scheduler` can't be resumed — start a new one instead. This matches typical usage (start once at application startup, shut down once at teardown).\n\n---\n\n## Inspecting and Removing Jobs\n\n```python\nfor job in scheduler.list_jobs():\n    print(job)\n# {'id': '...', 'name': 'report', 'schedule': 'every 3s', 'enabled': True,\n#  'run_count': 4, 'error_count': 0, 'last_run_at': '1718721000',\n#  'next_run_at': '1718721003', 'max_retries': 2, 'last_error': None}\n\nscheduler.remove_job(job_id)  # raises KeyError if job_id doesn't exist\n```\n\n`last_run_at`/`next_run_at` are Unix timestamps (seconds since the epoch) as strings, or `None` if the job hasn't run yet.\n\n---\n\n## Error Handling \u0026 Retries\n\nA job that raises an exception never stops the scheduler or any other job — the traceback is printed to stderr, and the job's own `error_count` is incremented.\n\n```python\n@scheduler.every(\"10s\", max_retries=2)\ndef flaky():\n    ...\n```\n\n`max_retries=2` means up to 3 total attempts (the initial one + 2 retries) happen back-to-back, with no delay, before that scheduling tick is counted as a failure. `run_count`/`error_count` reflect *ticks*, not individual attempts: if any attempt within a tick succeeds, the whole tick counts as a success and `last_error` is cleared. Every failed attempt is still printed to stderr, even if a later attempt in the same tick succeeds.\n\n| Situation | Exception |\n|---|---|\n| Invalid `interval` passed to `every()`, or invalid cron `expression` passed to `cron()` | `ValueError` |\n| `start_background()` called while already running | `RuntimeError` |\n| `remove_job()` called with an unknown id | `KeyError` |\n| Exception raised inside a job callback | Caught internally — never raised to your code |\n\n---\n\n## API Reference\n\n### `Scheduler()`\n\nCreates a new, empty scheduler.\n\n### `scheduler.every(interval, callback=None, max_retries=0)`\n\nRegisters an interval job. Called with `callback`, registers immediately and returns the job id (`str`); called without it (as `@scheduler.every(interval)`), returns a decorator that registers the function it's applied to and hands it back unchanged. Raises `ValueError` on an invalid `interval`.\n\n### `scheduler.cron(expression, callback=None, max_retries=0)`\n\nRegisters a cron job from a 5-field Unix expression (`minute hour day-of-month month day-of-week`), evaluated in local time. Same dual call/decorator API and return values as `every()`. Raises `ValueError` on an invalid expression. See [Cron Scheduling](#cron-scheduling).\n\n### `scheduler.list_jobs() -\u003e list[dict]`\n\nSnapshot of every registered job.\n\n| Key | Type | Description |\n|---|---|---|\n| `id` | `str` | UUID v4 |\n| `name` | `str` | The callback's `__name__` (or `\"job\"` if it has none) |\n| `schedule` | `str` | Human-readable, e.g. `\"every 300s\"` or `\"cron 0 9 * * 1-5\"` |\n| `enabled` | `bool` | Always `True` for now (toggling is planned) |\n| `run_count` | `int` | Successful ticks |\n| `error_count` | `int` | Failed ticks (after exhausting retries) |\n| `last_run_at` | `str \\| None` | Unix timestamp of the last execution |\n| `next_run_at` | `str \\| None` | Unix timestamp of the next scheduled execution |\n| `max_retries` | `int` | Configured retry budget |\n| `last_error` | `str \\| None` | Message from the most recent failed attempt; cleared on success |\n\n### `scheduler.remove_job(job_id)`\n\nUnregisters a job. Raises `KeyError` if `job_id` doesn't exist.\n\n### `scheduler.run()`\n\nBlocks the calling thread, executing due jobs until `shutdown()` is called.\n\n### `scheduler.start_background()`\n\nRuns the same loop as `run()` on a dedicated OS thread and returns immediately. Raises `RuntimeError` if already running.\n\n### `scheduler.shutdown()`\n\nStops the loop (background or not) and waits for the background thread to finish, if any. Safe to call multiple times, or when nothing was ever started.\n\n---\n\n## Building from Source\n\nRequires Rust and [maturin](https://github.com/PyO3/maturin).\n\n```bash\npython3 -m venv .venv\nsource .venv/bin/activate\npip install maturin\n\n# Development build (installs into the current Python environment)\nmaturin develop\n\n# Release wheel\nmaturin build --release\n```\n\n### Running tests\n\n```bash\n# Rust unit tests\nPYO3_PYTHON=\"$(pwd)/.venv/bin/python3\" cargo test --no-default-features --lib\n\n# Python integration tests\npip install -e \".[tests]\"\npytest\n```\n\n---\n\n## Architecture\n\n```\nPython API (rust_py_scheduler)\n    ├── Scheduler(...)               ──► src/scheduler.rs (PyO3 #[pyclass])\n    │       ├── every()               ──► src/job.rs       (Job, Schedule model)\n    │       │                         ──► src/interval.rs  (parses \"10s\"/\"5m\"/\"1h\")\n    │       ├── cron()                ──► src/cron.rs       (parses \"0 9 * * 1-5\", next run)\n    │       ├── list_jobs()           ──► src/registry.rs  (JobRegistry snapshot)\n    │       ├── remove_job()          ──► src/registry.rs  (JobRegistry.remove)\n    │       ├── run()                 ──► src/executor.rs  (run_loop, StopSignal)\n    │       ├── start_background()    ──► run_loop() spawned on its own OS thread\n    │       └── shutdown()            ──► StopSignal.stop() + thread join\n    ├── rust_py_scheduler.fastapi    ──► scheduler_lifespan() (pure Python)\n    └── rust_py_scheduler.django     ──► start_in_background() (pure Python)\n\nsrc/registry.rs    ──► thread-safe job storage (Arc\u003cMutex\u003cHashMap\u003c...\u003e\u003e\u003e); calls each\n                       callback under the GIL, applies the retry loop, tracks counts\nsrc/cron.rs        ──► 5-field cron parser; computes the next wall-clock occurrence and\n                       converts the gap into a monotonic Instant deadline\nsrc/time_utils.rs  ──► wall-clock timestamps for last_run_at/next_run_at (display only;\n                       scheduling itself uses a monotonic std::time::Instant)\nsrc/errors.rs      ──► SchedulerError -\u003e PyErr (ValueError / RuntimeError / KeyError)\n```\n\nThe core is compiled into a native extension (`.so`/`.pyd`) by [maturin](https://github.com/PyO3/maturin) and [PyO3](https://pyo3.rs), built against Python's stable ABI (`abi3-py310`) so a single wheel covers Python 3.10 through 3.13+. The Python layer (`python/rust_py_scheduler/__init__.py`) just re-exports the compiled module.\n\n---\n\n## Roadmap\n\nDone: cron expressions, FastAPI / Django / Celery integrations, and a CI suite that runs the Rust + Python tests on every push. Still planned:\n\n- Per-job `enabled` toggling (pause/resume without removing)\n- Configurable cron timezone (today: system local time)\n- Publish to TestPyPI, then PyPI\n\n---\n\n## License\n\nMIT.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frobertolima-dev%2Frust-py-scheduler","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frobertolima-dev%2Frust-py-scheduler","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frobertolima-dev%2Frust-py-scheduler/lists"}