pydocket 0.0.1__py3-none-any.whl

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.

docket/__init__.py ADDED
@@ -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
+ ]
docket/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from docket.cli import app
2
+
3
+ app()
docket/cli.py ADDED
@@ -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__)
docket/dependencies.py ADDED
@@ -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
+ )
docket/docket.py ADDED
@@ -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()
docket/execution.py ADDED
@@ -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
+ )
docket/py.typed ADDED
File without changes
docket/worker.py ADDED
@@ -0,0 +1,244 @@
1
+ import logging
2
+ import sys
3
+ from datetime import datetime, timezone
4
+ from types import TracebackType
5
+ from typing import TYPE_CHECKING, Any, Protocol, Self, Sequence, TypeVar, cast
6
+ from uuid import uuid4
7
+
8
+ from redis import RedisError
9
+
10
+ from .docket import Docket, Execution
11
+
12
+ logger: logging.Logger = logging.getLogger(__name__)
13
+
14
+ RedisStreamID = bytes
15
+ RedisMessageID = bytes
16
+ RedisMessage = dict[bytes, bytes]
17
+ RedisStream = tuple[RedisStreamID, Sequence[tuple[RedisMessageID, RedisMessage]]]
18
+ RedisReadGroupResponse = Sequence[RedisStream]
19
+
20
+ if TYPE_CHECKING: # pragma: no cover
21
+ from .dependencies import Dependency
22
+
23
+ D = TypeVar("D", bound="Dependency")
24
+
25
+
26
+ class _stream_due_tasks(Protocol):
27
+ async def __call__(
28
+ self, keys: list[str], args: list[str | float]
29
+ ) -> tuple[int, int]: ... # pragma: no cover
30
+
31
+
32
+ class Worker:
33
+ name: str
34
+ docket: Docket
35
+
36
+ prefetch_count: int = 10
37
+
38
+ def __init__(self, docket: Docket) -> None:
39
+ self.name = f"worker:{uuid4()}"
40
+ self.docket = docket
41
+
42
+ async def __aenter__(self) -> Self:
43
+ async with self.docket.redis() as redis:
44
+ try:
45
+ await redis.xgroup_create(
46
+ groupname=self.consumer_group_name,
47
+ name=self.docket.stream_key,
48
+ id="0-0",
49
+ mkstream=True,
50
+ )
51
+ except RedisError as e:
52
+ assert "BUSYGROUP" in repr(e)
53
+
54
+ return self
55
+
56
+ async def __aexit__(
57
+ self,
58
+ exc_type: type[BaseException] | None,
59
+ exc_value: BaseException | None,
60
+ traceback: TracebackType | None,
61
+ ) -> None:
62
+ pass
63
+
64
+ @property
65
+ def consumer_group_name(self) -> str:
66
+ return "docket"
67
+
68
+ @property
69
+ def _log_context(self) -> dict[str, str]:
70
+ return {
71
+ "queue_key": self.docket.queue_key,
72
+ "stream_key": self.docket.stream_key,
73
+ }
74
+
75
+ async def run_until_current(self) -> None:
76
+ async with self.docket.redis() as redis:
77
+ stream_due_tasks: _stream_due_tasks = cast(
78
+ _stream_due_tasks,
79
+ redis.register_script(
80
+ # Lua script to atomically move scheduled tasks to the stream
81
+ # KEYS[1]: queue key (sorted set)
82
+ # KEYS[2]: stream key
83
+ # ARGV[1]: current timestamp
84
+ # ARGV[2]: docket name prefix
85
+ """
86
+ local total_work = redis.call('ZCARD', KEYS[1])
87
+ local due_work = 0
88
+ local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1])
89
+
90
+ for i, key in ipairs(tasks) do
91
+ local hash_key = ARGV[2] .. ":" .. key
92
+ local task_data = redis.call('HGETALL', hash_key)
93
+
94
+ if #task_data > 0 then
95
+ local task = {}
96
+ for j = 1, #task_data, 2 do
97
+ task[task_data[j]] = task_data[j+1]
98
+ end
99
+
100
+ redis.call('XADD', KEYS[2], '*',
101
+ 'key', task['key'],
102
+ 'when', task['when'],
103
+ 'function', task['function'],
104
+ 'args', task['args'],
105
+ 'kwargs', task['kwargs'],
106
+ 'attempt', task['attempt']
107
+ )
108
+ redis.call('DEL', hash_key)
109
+ due_work = due_work + 1
110
+ end
111
+ end
112
+
113
+ if due_work > 0 then
114
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
115
+ end
116
+
117
+ return {total_work, due_work}
118
+ """
119
+ ),
120
+ )
121
+
122
+ total_work, due_work = sys.maxsize, 0
123
+ while total_work:
124
+ now = datetime.now(timezone.utc)
125
+ total_work, due_work = await stream_due_tasks(
126
+ keys=[self.docket.queue_key, self.docket.stream_key],
127
+ args=[now.timestamp(), self.docket.name],
128
+ )
129
+ logger.info(
130
+ "Moved %d/%d due tasks from %s to %s",
131
+ due_work,
132
+ total_work,
133
+ self.docket.queue_key,
134
+ self.docket.stream_key,
135
+ extra=self._log_context,
136
+ )
137
+
138
+ response: RedisReadGroupResponse = await redis.xreadgroup(
139
+ groupname=self.consumer_group_name,
140
+ consumername=self.name,
141
+ streams={self.docket.stream_key: ">"},
142
+ count=self.prefetch_count,
143
+ block=10,
144
+ )
145
+ for _, messages in response:
146
+ for message_id, message in messages:
147
+ await self._execute(message)
148
+
149
+ # When executing a task, there's always a chance that it was
150
+ # either retried or it scheduled another task, so let's give
151
+ # ourselves one more iteration of the loop to handle that.
152
+ total_work += 1
153
+
154
+ async with redis.pipeline() as pipe:
155
+ pipe.xack(
156
+ self.docket.stream_key,
157
+ self.consumer_group_name,
158
+ message_id,
159
+ )
160
+ pipe.xdel(
161
+ self.docket.stream_key,
162
+ message_id,
163
+ )
164
+ await pipe.execute()
165
+
166
+ async def _execute(self, message: RedisMessage) -> None:
167
+ execution = Execution.from_message(
168
+ self.docket.tasks[message[b"function"].decode()],
169
+ message,
170
+ )
171
+
172
+ logger.info(
173
+ "Executing task %s with args %s and kwargs %s",
174
+ execution.key,
175
+ execution.args,
176
+ execution.kwargs,
177
+ extra={
178
+ **self._log_context,
179
+ "function": execution.function.__name__,
180
+ },
181
+ )
182
+
183
+ dependencies = self._get_dependencies(execution)
184
+
185
+ try:
186
+ await execution.function(
187
+ *execution.args,
188
+ **{
189
+ **execution.kwargs,
190
+ **dependencies,
191
+ },
192
+ )
193
+ except Exception:
194
+ logger.exception(
195
+ "Error executing task %s",
196
+ execution.key,
197
+ extra=self._log_context,
198
+ )
199
+ await self._retry_if_requested(execution, dependencies)
200
+
201
+ def _get_dependencies(
202
+ self,
203
+ execution: Execution,
204
+ ) -> dict[str, Any]:
205
+ from .dependencies import get_dependency_parameters
206
+
207
+ parameters = get_dependency_parameters(execution.function)
208
+
209
+ dependencies: dict[str, Any] = {}
210
+
211
+ for param_name, dependency in parameters.items():
212
+ # If the argument is already provided, skip it, which allows users to call
213
+ # the function directly with the arguments they want.
214
+ if param_name in execution.kwargs:
215
+ dependencies[param_name] = execution.kwargs[param_name]
216
+ continue
217
+
218
+ dependencies[param_name] = dependency(self.docket, self, execution)
219
+
220
+ return dependencies
221
+
222
+ async def _retry_if_requested(
223
+ self,
224
+ execution: Execution,
225
+ dependencies: dict[str, Any],
226
+ ) -> None:
227
+ from .dependencies import Retry
228
+
229
+ retries = [retry for retry in dependencies.values() if isinstance(retry, Retry)]
230
+ if not retries:
231
+ return
232
+
233
+ retry = retries[0]
234
+
235
+ if execution.attempt < retry.attempts:
236
+ execution.when = datetime.now(timezone.utc) + retry.delay
237
+ execution.attempt += 1
238
+ await self.docket.schedule(execution)
239
+ else:
240
+ logger.error(
241
+ "Task %s failed after %d attempts",
242
+ execution.key,
243
+ retry.attempts,
244
+ )
@@ -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.
@@ -0,0 +1,13 @@
1
+ docket/__init__.py,sha256=0gzwWxJcDqU7nEZ4rHrWYk7WqEgW4Mz77E7rbjpyCHw,526
2
+ docket/__main__.py,sha256=Vkuh7aJ-Bl7QVpVbbkUksAd_hn05FiLmWbc-8kbhZQ4,34
3
+ docket/cli.py,sha256=ty8CirvLDvvOuOs7MHW0SYMjmbQq9rqUgPCq-HqW_6g,434
4
+ docket/dependencies.py,sha256=yd_sIv3y69Czo_DyHh3aNtLJGFOMjg8jjJ701QN04-Q,2124
5
+ docket/docket.py,sha256=sIgSvPUX4HG4EN_OxPSN7xCGu7DZkk5oZsuBVDp5XcU,4942
6
+ docket/execution.py,sha256=3HT1GOeg76RMILiq06bpDZITHrqjVQ_j9FU6fuY4jqw,1375
7
+ docket/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ docket/worker.py,sha256=N-nftCveJELKnICp1lgmzG5-r1QCYkGRxi8FcTjrLSc,8204
9
+ pydocket-0.0.1.dist-info/METADATA,sha256=IYrB8lUbawu5I9Buh6uVJ68kJh47GUfph3F1wxxN78w,2084
10
+ pydocket-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ pydocket-0.0.1.dist-info/entry_points.txt,sha256=4WOk1nUlBsUT5O3RyMci2ImuC5XFswuopElYcLHtD5k,47
12
+ pydocket-0.0.1.dist-info/licenses/LICENSE,sha256=YuVWU_ZXO0K_k2FG8xWKe5RGxV24AhJKTvQmKfqXuyk,1087
13
+ pydocket-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docket = docket.__main__:app
@@ -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.