pydocket 0.0.1__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.
Potentially problematic release.
This version of pydocket might be problematic. Click here for more details.
- pydocket-0.0.1/.cursorrules +41 -0
- pydocket-0.0.1/.github/workflows/ci.yml +70 -0
- pydocket-0.0.1/.github/workflows/publish.yml +40 -0
- pydocket-0.0.1/.gitignore +13 -0
- pydocket-0.0.1/.pre-commit-config.yaml +30 -0
- pydocket-0.0.1/LICENSE +9 -0
- pydocket-0.0.1/PKG-INFO +31 -0
- pydocket-0.0.1/README.md +2 -0
- pydocket-0.0.1/pyproject.toml +59 -0
- pydocket-0.0.1/src/docket/__init__.py +24 -0
- pydocket-0.0.1/src/docket/__main__.py +3 -0
- pydocket-0.0.1/src/docket/cli.py +23 -0
- pydocket-0.0.1/src/docket/dependencies.py +77 -0
- pydocket-0.0.1/src/docket/docket.py +178 -0
- pydocket-0.0.1/src/docket/execution.py +47 -0
- pydocket-0.0.1/src/docket/py.typed +0 -0
- pydocket-0.0.1/src/docket/worker.py +244 -0
- pydocket-0.0.1/tests/__init__.py +0 -0
- pydocket-0.0.1/tests/cli/__init__.py +0 -0
- pydocket-0.0.1/tests/cli/conftest.py +8 -0
- pydocket-0.0.1/tests/cli/test_module.py +14 -0
- pydocket-0.0.1/tests/cli/test_version.py +21 -0
- pydocket-0.0.1/tests/cli/test_worker.py +10 -0
- pydocket-0.0.1/tests/conftest.py +41 -0
- pydocket-0.0.1/tests/test_dependencies.py +93 -0
- pydocket-0.0.1/tests/test_fundamentals.py +304 -0
- pydocket-0.0.1/tests/test_worker.py +67 -0
- pydocket-0.0.1/uv.lock +652 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
docket is a distributed background task system for Python functions with a focus
|
|
2
|
+
on the scheduling of future work as seamlessly and efficiency as immediate work.
|
|
3
|
+
|
|
4
|
+
docket is built in Python and uses Redis as the message broker and storage system.
|
|
5
|
+
|
|
6
|
+
docket integrates two modes of task execution:
|
|
7
|
+
|
|
8
|
+
1. Immediate tasks are pushed onto a Redis stream and are available to be
|
|
9
|
+
picked up by any worker.
|
|
10
|
+
2. Scheduled tasks are pushed onto a Redis sorted set with a schedule time.
|
|
11
|
+
A loop within each worker moves scheduled tasks onto the stream when their
|
|
12
|
+
schedule time has arrived. This move is performed as a Lua script to ensure
|
|
13
|
+
atomicity. Once a scheduled task is moved onto the stream, it is now an
|
|
14
|
+
immediate task and can't be rescheduled.
|
|
15
|
+
|
|
16
|
+
docket inherently understands self-perpetuating chains of tasks, where a task
|
|
17
|
+
will repeatedly reschedule itself until it is no longer needed. This is supported
|
|
18
|
+
directly in the developer API so that devs don't need to worry about the mechanics.
|
|
19
|
+
|
|
20
|
+
Tasks have unique identifiers that may be set by the caller in order to guarantee
|
|
21
|
+
idempotency of an execution.
|
|
22
|
+
|
|
23
|
+
A docket worker should be as easily usable in code as it is from the command line,
|
|
24
|
+
and should be a breeze to use with test suites.
|
|
25
|
+
|
|
26
|
+
# Code style
|
|
27
|
+
|
|
28
|
+
When generating production code, always use full parameter and return type hints
|
|
29
|
+
for every function. Never generate useless inline comments that just reiterate
|
|
30
|
+
what the code is doing. It's okay to include comments in the rare case there is
|
|
31
|
+
something tricky going on.
|
|
32
|
+
|
|
33
|
+
When generating tests, always use parameter type hints, but never include the
|
|
34
|
+
`-> None` return type hint for a test function. For `pytest` fixtures, always
|
|
35
|
+
generate both the parameter and return type hints.
|
|
36
|
+
|
|
37
|
+
When generating tests, favor smaller, focused tests that use fixtures for reuse.
|
|
38
|
+
Don't include extraneous comments in the test code unless something needs more
|
|
39
|
+
clarity. Always generate a docstring using "should" language to describe the
|
|
40
|
+
aspect of the system the test is checking. Use simple direct language and avoid
|
|
41
|
+
sounding stuffy, but make these complete sentences.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
name: Docket CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
workflow_call:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
name: Test Python ${{ matrix.python-version }}
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.12", "3.13"]
|
|
17
|
+
|
|
18
|
+
services:
|
|
19
|
+
redis:
|
|
20
|
+
image: redis
|
|
21
|
+
ports:
|
|
22
|
+
- 6379:6379
|
|
23
|
+
options: >-
|
|
24
|
+
--health-cmd "redis-cli ping"
|
|
25
|
+
--health-interval 10s
|
|
26
|
+
--health-timeout 5s
|
|
27
|
+
--health-retries 5
|
|
28
|
+
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
|
|
32
|
+
- name: Install uv and set Python version
|
|
33
|
+
uses: astral-sh/setup-uv@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: ${{ matrix.python-version }}
|
|
36
|
+
enable-cache: true
|
|
37
|
+
cache-dependency-glob: "pyproject.toml"
|
|
38
|
+
|
|
39
|
+
- name: Install dependencies
|
|
40
|
+
run: uv sync --dev
|
|
41
|
+
|
|
42
|
+
- name: Run tests
|
|
43
|
+
run: uv run pytest --cov-branch --cov-report=xml
|
|
44
|
+
|
|
45
|
+
- name: Upload coverage reports to Codecov
|
|
46
|
+
uses: codecov/codecov-action@v5
|
|
47
|
+
with:
|
|
48
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
49
|
+
|
|
50
|
+
pre-commit:
|
|
51
|
+
name: Pre-commit checks
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
steps:
|
|
54
|
+
- uses: actions/checkout@v4
|
|
55
|
+
|
|
56
|
+
- name: Install uv and set Python version
|
|
57
|
+
uses: astral-sh/setup-uv@v5
|
|
58
|
+
with:
|
|
59
|
+
python-version: "3.12"
|
|
60
|
+
enable-cache: true
|
|
61
|
+
cache-dependency-glob: "pyproject.toml"
|
|
62
|
+
|
|
63
|
+
- name: Install dependencies
|
|
64
|
+
run: |
|
|
65
|
+
uv sync --dev
|
|
66
|
+
uv pip install pip
|
|
67
|
+
|
|
68
|
+
- uses: pre-commit/action@v3.0.1
|
|
69
|
+
with:
|
|
70
|
+
extra_args: --all-files
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [created]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
ci:
|
|
9
|
+
name: Run CI
|
|
10
|
+
uses: ./.github/workflows/ci.yml
|
|
11
|
+
|
|
12
|
+
publish:
|
|
13
|
+
name: Build and publish to PyPI
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
needs: ci
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
with:
|
|
23
|
+
# Need full history for proper versioning with hatch-vcs
|
|
24
|
+
fetch-depth: 0
|
|
25
|
+
|
|
26
|
+
- name: Install uv and set Python version
|
|
27
|
+
uses: astral-sh/setup-uv@v5
|
|
28
|
+
with:
|
|
29
|
+
python-version: "3.12"
|
|
30
|
+
enable-cache: true
|
|
31
|
+
cache-dependency-glob: "pyproject.toml"
|
|
32
|
+
|
|
33
|
+
- name: Install build dependencies
|
|
34
|
+
run: uv pip install build hatchling hatch-vcs
|
|
35
|
+
|
|
36
|
+
- name: Build package
|
|
37
|
+
run: uv build
|
|
38
|
+
|
|
39
|
+
- name: Publish to PyPI
|
|
40
|
+
run: uv publish
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v4.5.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: trailing-whitespace
|
|
6
|
+
- id: end-of-file-fixer
|
|
7
|
+
- id: check-yaml
|
|
8
|
+
- id: check-toml
|
|
9
|
+
- id: check-added-large-files
|
|
10
|
+
|
|
11
|
+
- repo: https://github.com/codespell-project/codespell
|
|
12
|
+
rev: v2.2.6
|
|
13
|
+
hooks:
|
|
14
|
+
- id: codespell
|
|
15
|
+
|
|
16
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
17
|
+
rev: v0.9.7
|
|
18
|
+
hooks:
|
|
19
|
+
- id: ruff
|
|
20
|
+
args: [--fix, --exit-non-zero-on-fix, --show-fixes]
|
|
21
|
+
- id: ruff-format
|
|
22
|
+
|
|
23
|
+
- repo: local
|
|
24
|
+
hooks:
|
|
25
|
+
- id: pyright
|
|
26
|
+
name: pyright
|
|
27
|
+
entry: uv run pyright --verifytypes docket --ignoreexternal
|
|
28
|
+
language: system
|
|
29
|
+
types: [python]
|
|
30
|
+
pass_filenames: false
|
pydocket-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Released under MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Chris Guidry.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
pydocket-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydocket
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A distributed background task system for Python functions
|
|
5
|
+
Project-URL: Homepage, https://github.com/chrisguidry/docket
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/chrisguidry/docket/issues
|
|
7
|
+
Author-email: Chris Guidry <guid@omg.lol>
|
|
8
|
+
License: # Released under MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 Chris Guidry.
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Classifier: Development Status :: 4 - Beta
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Requires-Dist: cloudpickle>=3.1.1
|
|
26
|
+
Requires-Dist: redis>=5.2.1
|
|
27
|
+
Requires-Dist: typer>=0.15.1
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
docket is a distributed background task system for Python functions with a focus
|
|
31
|
+
on the scheduling of future work as seamlessly and efficiency as immediate work.
|
pydocket-0.0.1/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pydocket"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "A distributed background task system for Python functions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Chris Guidry", email = "guid@omg.lol" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["cloudpickle>=3.1.1", "redis>=5.2.1", "typer>=0.15.1"]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"codespell>=2.4.1",
|
|
26
|
+
"mypy>=1.14.1",
|
|
27
|
+
"pre-commit>=4.1.0",
|
|
28
|
+
"pyright>=1.1.394",
|
|
29
|
+
"pytest>=8.3.4",
|
|
30
|
+
"pytest-asyncio>=0.25.3",
|
|
31
|
+
"pytest-cov>=6.0.0",
|
|
32
|
+
"pytest-xdist>=3.6.1",
|
|
33
|
+
"ruff>=0.9.7",
|
|
34
|
+
"testcontainers>=4.9.1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
docket = "docket.__main__:app"
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
"Homepage" = "https://github.com/chrisguidry/docket"
|
|
42
|
+
"Bug Tracker" = "https://github.com/chrisguidry/docket/issues"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.version]
|
|
45
|
+
source = "vcs"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/docket"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
addopts = "--cov=src/docket --cov=tests --cov-report=term-missing --cov-branch"
|
|
52
|
+
asyncio_mode = "auto"
|
|
53
|
+
asyncio_default_fixture_loop_scope = "session"
|
|
54
|
+
|
|
55
|
+
[tool.pyright]
|
|
56
|
+
include = ["src", "tests"]
|
|
57
|
+
typeCheckingMode = "strict"
|
|
58
|
+
venvPath = "."
|
|
59
|
+
venv = ".venv"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
docket - A distributed background task system for Python functions.
|
|
3
|
+
|
|
4
|
+
docket focuses on scheduling future work as seamlessly and efficiently as immediate work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
|
|
9
|
+
__version__ = version("pydocket")
|
|
10
|
+
|
|
11
|
+
from .dependencies import CurrentDocket, CurrentWorker, Retry
|
|
12
|
+
from .docket import Docket
|
|
13
|
+
from .execution import Execution
|
|
14
|
+
from .worker import Worker
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Docket",
|
|
18
|
+
"Worker",
|
|
19
|
+
"Execution",
|
|
20
|
+
"CurrentDocket",
|
|
21
|
+
"CurrentWorker",
|
|
22
|
+
"Retry",
|
|
23
|
+
"__version__",
|
|
24
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from docket import __version__
|
|
4
|
+
|
|
5
|
+
app: typer.Typer = typer.Typer(
|
|
6
|
+
help="Docket - A distributed background task system for Python functions",
|
|
7
|
+
add_completion=True,
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command(
|
|
13
|
+
help="Start a worker to process tasks",
|
|
14
|
+
)
|
|
15
|
+
def worker() -> None:
|
|
16
|
+
print("TODO: start the worker")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command(
|
|
20
|
+
help="Print the version of Docket",
|
|
21
|
+
)
|
|
22
|
+
def version() -> None:
|
|
23
|
+
print(__version__)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import inspect
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Any, Awaitable, Callable, Counter, cast
|
|
5
|
+
|
|
6
|
+
from .docket import Docket
|
|
7
|
+
from .execution import Execution
|
|
8
|
+
from .worker import Worker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Dependency(abc.ABC):
|
|
12
|
+
single: bool = False
|
|
13
|
+
|
|
14
|
+
@abc.abstractmethod
|
|
15
|
+
def __call__(
|
|
16
|
+
self, docket: Docket, worker: Worker, execution: Execution
|
|
17
|
+
) -> Any: ... # pragma: no cover
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _CurrentWorker(Dependency):
|
|
21
|
+
def __call__(self, docket: Docket, worker: Worker, execution: Execution) -> Worker:
|
|
22
|
+
return worker
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def CurrentWorker() -> Worker:
|
|
26
|
+
return cast(Worker, _CurrentWorker())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _CurrentDocket(Dependency):
|
|
30
|
+
def __call__(self, docket: Docket, worker: Worker, execution: Execution) -> Docket:
|
|
31
|
+
return docket
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def CurrentDocket() -> Docket:
|
|
35
|
+
return cast(Docket, _CurrentDocket())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Retry(Dependency):
|
|
39
|
+
single: bool = True
|
|
40
|
+
|
|
41
|
+
def __init__(self, attempts: int = 1, delay: timedelta = timedelta(0)) -> None:
|
|
42
|
+
self.attempts = attempts
|
|
43
|
+
self.delay = delay
|
|
44
|
+
self.attempt = 1
|
|
45
|
+
|
|
46
|
+
def __call__(self, docket: Docket, worker: Worker, execution: Execution) -> "Retry":
|
|
47
|
+
retry = Retry(attempts=self.attempts, delay=self.delay)
|
|
48
|
+
retry.attempt = execution.attempt
|
|
49
|
+
return retry
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_dependency_parameters(
|
|
53
|
+
function: Callable[..., Awaitable[Any]],
|
|
54
|
+
) -> dict[str, Dependency]:
|
|
55
|
+
dependencies: dict[str, Any] = {}
|
|
56
|
+
|
|
57
|
+
signature = inspect.signature(function)
|
|
58
|
+
|
|
59
|
+
for param_name, param in signature.parameters.items():
|
|
60
|
+
if not isinstance(param.default, Dependency):
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
dependencies[param_name] = param.default
|
|
64
|
+
|
|
65
|
+
return dependencies
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_dependencies(function: Callable[..., Awaitable[Any]]) -> None:
|
|
69
|
+
parameters = get_dependency_parameters(function)
|
|
70
|
+
|
|
71
|
+
counts = Counter(type(dependency) for dependency in parameters.values())
|
|
72
|
+
|
|
73
|
+
for dependency_type, count in counts.items():
|
|
74
|
+
if dependency_type.single and count > 1:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Only one {dependency_type.__name__} dependency is allowed per task"
|
|
77
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import (
|
|
5
|
+
Any,
|
|
6
|
+
AsyncGenerator,
|
|
7
|
+
Awaitable,
|
|
8
|
+
Callable,
|
|
9
|
+
ParamSpec,
|
|
10
|
+
Self,
|
|
11
|
+
TypeVar,
|
|
12
|
+
overload,
|
|
13
|
+
)
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from redis.asyncio import Redis
|
|
17
|
+
|
|
18
|
+
from .execution import Execution
|
|
19
|
+
|
|
20
|
+
P = ParamSpec("P")
|
|
21
|
+
R = TypeVar("R")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Docket:
|
|
25
|
+
tasks: dict[str, Callable[..., Awaitable[Any]]]
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
name: str = "docket",
|
|
30
|
+
host: str = "localhost",
|
|
31
|
+
port: int = 6379,
|
|
32
|
+
db: int = 0,
|
|
33
|
+
password: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.name = name
|
|
36
|
+
self.host = host
|
|
37
|
+
self.port = port
|
|
38
|
+
self.db = db
|
|
39
|
+
self.password = password
|
|
40
|
+
|
|
41
|
+
async def __aenter__(self) -> Self:
|
|
42
|
+
self.tasks = {}
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
async def __aexit__(
|
|
46
|
+
self,
|
|
47
|
+
exc_type: type[BaseException] | None,
|
|
48
|
+
exc_value: BaseException | None,
|
|
49
|
+
traceback: TracebackType | None,
|
|
50
|
+
) -> None:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@asynccontextmanager
|
|
54
|
+
async def redis(self) -> AsyncGenerator[Redis, None]:
|
|
55
|
+
async with Redis(
|
|
56
|
+
host=self.host,
|
|
57
|
+
port=self.port,
|
|
58
|
+
db=self.db,
|
|
59
|
+
password=self.password,
|
|
60
|
+
single_connection_client=True,
|
|
61
|
+
) as redis:
|
|
62
|
+
yield redis
|
|
63
|
+
|
|
64
|
+
def register(self, function: Callable[..., Awaitable[Any]]) -> None:
|
|
65
|
+
from .dependencies import validate_dependencies
|
|
66
|
+
|
|
67
|
+
validate_dependencies(function)
|
|
68
|
+
|
|
69
|
+
self.tasks[function.__name__] = function
|
|
70
|
+
|
|
71
|
+
@overload
|
|
72
|
+
def add(
|
|
73
|
+
self,
|
|
74
|
+
function: Callable[P, Awaitable[R]],
|
|
75
|
+
when: datetime | None = None,
|
|
76
|
+
key: str | None = None,
|
|
77
|
+
) -> Callable[P, Awaitable[Execution]]: ... # pragma: no cover
|
|
78
|
+
|
|
79
|
+
@overload
|
|
80
|
+
def add(
|
|
81
|
+
self,
|
|
82
|
+
function: str,
|
|
83
|
+
when: datetime | None = None,
|
|
84
|
+
key: str | None = None,
|
|
85
|
+
) -> Callable[..., Awaitable[Execution]]: ... # pragma: no cover
|
|
86
|
+
|
|
87
|
+
def add(
|
|
88
|
+
self,
|
|
89
|
+
function: Callable[P, Awaitable[R]] | str,
|
|
90
|
+
when: datetime | None = None,
|
|
91
|
+
key: str | None = None,
|
|
92
|
+
) -> Callable[..., Awaitable[Execution]]:
|
|
93
|
+
if isinstance(function, str):
|
|
94
|
+
function = self.tasks[function]
|
|
95
|
+
else:
|
|
96
|
+
self.register(function)
|
|
97
|
+
|
|
98
|
+
if when is None:
|
|
99
|
+
when = datetime.now(timezone.utc)
|
|
100
|
+
|
|
101
|
+
if key is None:
|
|
102
|
+
key = f"{function.__name__}:{uuid4()}"
|
|
103
|
+
|
|
104
|
+
async def scheduler(*args: P.args, **kwargs: P.kwargs) -> Execution:
|
|
105
|
+
execution = Execution(function, args, kwargs, when, key, attempt=1)
|
|
106
|
+
await self.schedule(execution)
|
|
107
|
+
return execution
|
|
108
|
+
|
|
109
|
+
return scheduler
|
|
110
|
+
|
|
111
|
+
@overload
|
|
112
|
+
def replace(
|
|
113
|
+
self,
|
|
114
|
+
function: Callable[P, Awaitable[R]],
|
|
115
|
+
when: datetime,
|
|
116
|
+
key: str,
|
|
117
|
+
) -> Callable[P, Awaitable[Execution]]: ... # pragma: no cover
|
|
118
|
+
|
|
119
|
+
@overload
|
|
120
|
+
def replace(
|
|
121
|
+
self,
|
|
122
|
+
function: str,
|
|
123
|
+
when: datetime,
|
|
124
|
+
key: str,
|
|
125
|
+
) -> Callable[..., Awaitable[Execution]]: ... # pragma: no cover
|
|
126
|
+
|
|
127
|
+
def replace(
|
|
128
|
+
self,
|
|
129
|
+
function: Callable[P, Awaitable[R]] | str,
|
|
130
|
+
when: datetime,
|
|
131
|
+
key: str,
|
|
132
|
+
) -> Callable[..., Awaitable[Execution]]:
|
|
133
|
+
if isinstance(function, str):
|
|
134
|
+
function = self.tasks[function]
|
|
135
|
+
|
|
136
|
+
async def scheduler(*args: P.args, **kwargs: P.kwargs) -> Execution:
|
|
137
|
+
execution = Execution(function, args, kwargs, when, key, attempt=1)
|
|
138
|
+
await self.cancel(key)
|
|
139
|
+
await self.schedule(execution)
|
|
140
|
+
return execution
|
|
141
|
+
|
|
142
|
+
return scheduler
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def queue_key(self) -> str:
|
|
146
|
+
return f"{self.name}:queue"
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def stream_key(self) -> str:
|
|
150
|
+
return f"{self.name}:stream"
|
|
151
|
+
|
|
152
|
+
def parked_task_key(self, key: str) -> str:
|
|
153
|
+
return f"{self.name}:{key}"
|
|
154
|
+
|
|
155
|
+
async def schedule(self, execution: Execution) -> None:
|
|
156
|
+
message: dict[bytes, bytes] = execution.as_message()
|
|
157
|
+
key = execution.key
|
|
158
|
+
when = execution.when
|
|
159
|
+
|
|
160
|
+
async with self.redis() as redis:
|
|
161
|
+
# if the task is already in the queue, retain it
|
|
162
|
+
if await redis.zscore(self.queue_key, key) is not None:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if when <= datetime.now(timezone.utc):
|
|
166
|
+
await redis.xadd(self.stream_key, message)
|
|
167
|
+
else:
|
|
168
|
+
async with redis.pipeline() as pipe:
|
|
169
|
+
pipe.hset(self.parked_task_key(key), mapping=message)
|
|
170
|
+
pipe.zadd(self.queue_key, {key: when.timestamp()})
|
|
171
|
+
await pipe.execute()
|
|
172
|
+
|
|
173
|
+
async def cancel(self, key: str) -> None:
|
|
174
|
+
async with self.redis() as redis:
|
|
175
|
+
async with redis.pipeline() as pipe:
|
|
176
|
+
pipe.delete(self.parked_task_key(key))
|
|
177
|
+
pipe.zrem(self.queue_key, key)
|
|
178
|
+
await pipe.execute()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Awaitable, Callable, Self
|
|
3
|
+
|
|
4
|
+
import cloudpickle
|
|
5
|
+
|
|
6
|
+
Message = dict[bytes, bytes]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Execution:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
function: Callable[..., Awaitable[Any]],
|
|
13
|
+
args: tuple[Any, ...],
|
|
14
|
+
kwargs: dict[str, Any],
|
|
15
|
+
when: datetime,
|
|
16
|
+
key: str,
|
|
17
|
+
attempt: int,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.function = function
|
|
20
|
+
self.args = args
|
|
21
|
+
self.kwargs = kwargs
|
|
22
|
+
self.when = when
|
|
23
|
+
self.key = key
|
|
24
|
+
self.attempt = attempt
|
|
25
|
+
|
|
26
|
+
def as_message(self) -> Message:
|
|
27
|
+
return {
|
|
28
|
+
b"key": self.key.encode(),
|
|
29
|
+
b"when": self.when.isoformat().encode(),
|
|
30
|
+
b"function": self.function.__name__.encode(),
|
|
31
|
+
b"args": cloudpickle.dumps(self.args),
|
|
32
|
+
b"kwargs": cloudpickle.dumps(self.kwargs),
|
|
33
|
+
b"attempt": str(self.attempt).encode(),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_message(
|
|
38
|
+
cls, function: Callable[..., Awaitable[Any]], message: Message
|
|
39
|
+
) -> Self:
|
|
40
|
+
return cls(
|
|
41
|
+
function=function,
|
|
42
|
+
args=cloudpickle.loads(message[b"args"]),
|
|
43
|
+
kwargs=cloudpickle.loads(message[b"kwargs"]),
|
|
44
|
+
when=datetime.fromisoformat(message[b"when"].decode()),
|
|
45
|
+
key=message[b"key"].decode(),
|
|
46
|
+
attempt=int(message[b"attempt"].decode()),
|
|
47
|
+
)
|
|
File without changes
|