Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .coveragerc-memory
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Coverage configuration for memory backend testing
# CLI tests are skipped with memory:// URLs, so exclude CLI from coverage

[run]
branch = true
parallel = true
omit =
src/docket/__main__.py
src/docket/cli.py
tests/cli/test_*.py
39 changes: 35 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:

jobs:
Expand All @@ -14,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
backend:
- name: "Redis 6.2, redis-py <5"
redis-version: "6.2"
Expand All @@ -28,6 +27,38 @@ jobs:
- name: "Memory (in-memory backend)"
redis-version: "memory"
redis-py-version: ">=5"
exclude:
# Python 3.10 + Redis 6.2 + redis-py <5 combination is skipped
- python-version: "3.10"
backend:
name: "Redis 6.2, redis-py <5"
redis-version: "6.2"
redis-py-version: ">=4.6,<5"
include:
- python-version: "3.10"
cov-threshold: 100
pytest-args: ""
# Python 3.11 coverage reporting is unstable, so use 98% threshold
- python-version: "3.11"
cov-threshold: 98
pytest-args: ""
- python-version: "3.12"
cov-threshold: 100
pytest-args: ""
- python-version: "3.13"
cov-threshold: 100
pytest-args: ""
- python-version: "3.14"
cov-threshold: 100
pytest-args: ""
# Memory backend: CLI tests are skipped via pytest skip markers because
# CLI rejects memory:// URLs. Use separate coverage config to exclude CLI.
- backend:
name: "Memory (in-memory backend)"
redis-version: "memory"
redis-py-version: ">=5"
cov-threshold: 98 # CLI tests are excluded from coverage and some lines are only covered by CLI tests
pytest-args: "--cov-config=.coveragerc-memory"

steps:
- uses: actions/checkout@v4
Expand All @@ -45,7 +76,7 @@ jobs:
- name: Run tests
env:
REDIS_VERSION: ${{ matrix.backend.redis-version }}
run: uv run pytest --cov-branch --cov-fail-under=100 --cov-report=xml --cov-report=term-missing:skip-covered
run: uv run pytest --cov-branch --cov-fail-under=${{ matrix.cov-threshold }} --cov-report=xml --cov-report=term-missing:skip-covered ${{ matrix.pytest-args }}

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
Expand All @@ -62,7 +93,7 @@ jobs:
- name: Install uv and set Python version
uses: astral-sh/setup-uv@v5
with:
python-version: "3.12"
python-version: "3.10"
enable-cache: true
cache-dependency-glob: "pyproject.toml"

Expand Down
4 changes: 1 addition & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

**Docket** (`pydocket` on PyPI) is a distributed background task system for Python functions with Redis-backed persistence. It enables scheduling both immediate and future work with comprehensive dependency injection, retry mechanisms, and fault tolerance.

**Key Requirements**: Python 3.12+, Redis 6.2+ or Valkey 8.0+
**Key Requirements**: Python 3.10+, Redis 6.2+ or Valkey 8.0+

## Development Commands

Expand Down Expand Up @@ -58,15 +58,13 @@ pre-commit install
### Key Classes

- **`Docket`** (`src/docket/docket.py`): Central task registry and scheduler

- `add()`: Schedule tasks for execution
- `replace()`: Replace existing scheduled tasks
- `cancel()`: Cancel pending tasks
- `strike()`/`restore()`: Conditionally block/unblock tasks
- `snapshot()`: Get current state for observability

- **`Worker`** (`src/docket/worker.py`): Task execution engine

- `run_forever()`/`run_until_finished()`: Main execution loops
- Handles concurrency, retries, and dependency injection
- Maintains heartbeat for liveness tracking
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ reference](https://chrisguidry.github.io/docket/api-reference/).
## Installing `docket`

Docket is [available on PyPI](https://pypi.org/project/pydocket/) under the package name
`pydocket`. It targets Python 3.12 or above.
`pydocket`. It targets Python 3.10 or above.

With [`uv`](https://docs.astral.sh/uv/):

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Installation

Docket is [available on PyPI](https://pypi.org/project/pydocket/) under the package name
`pydocket`. It targets Python 3.12 or above.
`pydocket`. It targets Python 3.10 or above.

With [`uv`](https://docs.astral.sh/uv/):

Expand Down
27 changes: 18 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,32 @@ name = "pydocket"
dynamic = ["version"]
description = "A distributed background task system for Python functions"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.12"
requires-python = ">=3.10"
license = { file = "LICENSE" }
authors = [{ name = "Chris Guidry", email = "guid@omg.lol" }]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Typing :: Typed",
]
dependencies = [
"cloudpickle>=3.1.1",
"exceptiongroup>=1.2.0; python_version < '3.11'",
"opentelemetry-api>=1.30.0",
"opentelemetry-exporter-prometheus>=0.51b0",
"prometheus-client>=0.21.1",
"python-json-logger>=3.2.1",
"redis>=4.6",
"rich>=13.9.4",
"typer>=0.15.1",
"typing_extensions>=4.12.0",
"uuid7>=0.1.0",
]

Expand All @@ -39,7 +44,7 @@ dev = [
# This fixes xpending_range to return all 4 required fields (message_id, consumer,
# time_since_delivered, times_delivered) instead of just 2, matching Redis behavior
"fakeredis[lua] @ git+https://github.com/zzstoatzz/fakeredis-py.git@fix-xpending-range-fields",
"ipython>=9.0.1",
"ipython>=8.0.0",
"mypy>=1.14.1",
"opentelemetry-distro>=0.51b0",
"opentelemetry-exporter-otlp>=1.30.0",
Expand All @@ -50,7 +55,7 @@ dev = [
"pre-commit>=4.1.0",
"pyright>=1.1.398",
"pytest>=8.3.4",
"pytest-aio>=1.9.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
"pytest-xdist>=3.6.1",
"ruff>=0.9.7",
Expand All @@ -62,11 +67,7 @@ docs = [
"mkdocstrings>=0.24.1",
"mkdocstrings-python>=1.8.0",
]
examples = [
"fastapi>=0.120.0",
"pydantic>=2.11.10",
"uvicorn>=0.38.0",
]
examples = ["fastapi>=0.120.0", "pydantic>=2.11.10", "uvicorn>=0.38.0"]

[project.scripts]
docket = "docket.__main__:app"
Expand All @@ -86,7 +87,7 @@ allow-direct-references = true
packages = ["src/docket"]

[tool.ruff]
target-version = "py312"
target-version = "py310"

[tool.pytest.ini_options]
addopts = [
Expand All @@ -97,8 +98,16 @@ addopts = [
"--cov-report=term-missing",
"--cov-branch",
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
asyncio_default_test_loop_scope = "function"
filterwarnings = ["error"]

[tool.coverage.run]
omit = ["src/docket/__main__.py"]
branch = true
parallel = true

[tool.pyright]
include = ["src", "tests"]
typeCheckingMode = "strict"
Expand Down
7 changes: 7 additions & 0 deletions sitecustomize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# This file ensures that we can collect coverage data for the CLI when it's running in a subprocess
import os

if os.getenv("COVERAGE_PROCESS_START"):
import coverage

coverage.process_startup()
4 changes: 3 additions & 1 deletion src/docket/annotations.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import abc
import inspect
from typing import Any, Iterable, Mapping, Self
from typing import Any, Iterable, Mapping

from typing_extensions import Self

from .instrumentation import CACHE_SIZE

Expand Down
43 changes: 35 additions & 8 deletions src/docket/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
)


class LogLevel(enum.StrEnum):
class LogLevel(str, enum.Enum):
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"


class LogFormat(enum.StrEnum):
class LogFormat(str, enum.Enum):
RICH = "rich"
PLAIN = "plain"
JSON = "json"
Expand Down Expand Up @@ -111,7 +111,23 @@ def set_logging_format(format: LogFormat) -> None:


def set_logging_level(level: LogLevel) -> None:
logging.getLogger().setLevel(level)
logging.getLogger().setLevel(level.value)


def validate_url(url: str) -> str:
"""
Validate that the provided URL is compatible with the CLI.

The memory:// backend is not compatible with the CLI as it doesn't persist
across processes.
"""
if url.startswith("memory://"):
raise typer.BadParameter(
"The memory:// URL scheme is not supported by the CLI.\n"
"The memory backend does not persist across processes.\n"
"Please use a persistent backend like Redis or Valkey."
)
return url


def handle_strike_wildcard(value: str) -> str | None:
Expand Down Expand Up @@ -178,6 +194,7 @@ def worker(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
name: Annotated[
Expand Down Expand Up @@ -336,6 +353,7 @@ def strike(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
) -> None:
Expand All @@ -347,7 +365,7 @@ def strike(
value_ = interpret_python_value(value)
if parameter:
function_name = f"{function or '(all tasks)'}"
print(f"Striking {function_name} {parameter} {operator} {value_!r}")
print(f"Striking {function_name} {parameter} {operator.value} {value_!r}")
else:
print(f"Striking {function}")

Expand All @@ -373,6 +391,7 @@ def clear(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
) -> None:
Expand Down Expand Up @@ -425,6 +444,7 @@ def restore(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
) -> None:
Expand All @@ -436,7 +456,7 @@ def restore(
value_ = interpret_python_value(value)
if parameter:
function_name = f"{function or '(all tasks)'}"
print(f"Restoring {function_name} {parameter} {operator} {value_!r}")
print(f"Restoring {function_name} {parameter} {operator.value} {value_!r}")
else:
print(f"Restoring {function}")

Expand Down Expand Up @@ -468,6 +488,7 @@ def trace(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
message: Annotated[
Expand Down Expand Up @@ -511,6 +532,7 @@ def fail(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
message: Annotated[
Expand Down Expand Up @@ -554,6 +576,7 @@ def sleep(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
seconds: Annotated[
Expand Down Expand Up @@ -688,6 +711,7 @@ def snapshot(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
stats: Annotated[
Expand Down Expand Up @@ -746,10 +770,11 @@ async def run() -> DocketSnapshot:

console.print(table)

# Display task statistics if requested
if stats:
# Display task statistics if requested. On Linux the Click runner executes
# this CLI in a subprocess, so coverage cannot observe it. Mark as no cover.
if stats: # pragma: no cover
task_stats = get_task_stats(snapshot)
if task_stats:
if task_stats: # pragma: no cover
console.print() # Add spacing between tables
stats_table = Table(title="Task Count Statistics by Function")
stats_table.add_column("Function", style="cyan")
Expand Down Expand Up @@ -839,6 +864,7 @@ def list_workers(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
) -> None:
Expand Down Expand Up @@ -875,6 +901,7 @@ def workers_for_task(
typer.Option(
help="The URL of the Redis server",
envvar="DOCKET_URL",
callback=validate_url,
),
] = "redis://localhost:6379/0",
) -> None:
Expand Down
Loading
Loading