labtasker-client 2.0.0__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.
labtasker/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """Public package for the Labtasker v2 client and Worker runtime."""
2
+
3
+ from labtasker.api import (
4
+ cancel_task,
5
+ count_tasks,
6
+ create_queue,
7
+ delete_queue,
8
+ delete_task,
9
+ get_task,
10
+ list_queues,
11
+ list_tasks,
12
+ requeue_task,
13
+ submit_task,
14
+ update_task,
15
+ update_tasks,
16
+ )
17
+ from labtasker.binding import TaskArg
18
+ from labtasker.client import Client
19
+ from labtasker.errors import (
20
+ APIError,
21
+ ConfigError,
22
+ FatalWorkerError,
23
+ LabtaskerError,
24
+ TaskError,
25
+ TransientError,
26
+ TransportError,
27
+ )
28
+ from labtasker.execution import (
29
+ cancellation_requested,
30
+ finish,
31
+ set_force_stop_timeout,
32
+ task_info,
33
+ )
34
+ from labtasker.models import BulkUpdateResult, LastError, Queue, Task, TaskInfo, TaskPage
35
+ from labtasker.types import JSONValue, TaskOrderField, TaskStatus, TaskUpdate
36
+ from labtasker.worker import loop
37
+
38
+ __version__ = "2.0.0"
39
+
40
+ __all__ = [
41
+ "APIError",
42
+ "BulkUpdateResult",
43
+ "Client",
44
+ "ConfigError",
45
+ "FatalWorkerError",
46
+ "JSONValue",
47
+ "LabtaskerError",
48
+ "LastError",
49
+ "Queue",
50
+ "Task",
51
+ "TaskArg",
52
+ "TaskError",
53
+ "TaskInfo",
54
+ "TaskOrderField",
55
+ "TaskPage",
56
+ "TaskStatus",
57
+ "TaskUpdate",
58
+ "TransientError",
59
+ "TransportError",
60
+ "cancel_task",
61
+ "cancellation_requested",
62
+ "count_tasks",
63
+ "create_queue",
64
+ "delete_queue",
65
+ "delete_task",
66
+ "finish",
67
+ "get_task",
68
+ "list_queues",
69
+ "list_tasks",
70
+ "loop",
71
+ "requeue_task",
72
+ "set_force_stop_timeout",
73
+ "submit_task",
74
+ "task_info",
75
+ "update_task",
76
+ "update_tasks",
77
+ ]
labtasker/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from labtasker.cli import app
2
+
3
+ app()
labtasker/api.py ADDED
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from threading import Lock
4
+
5
+ from labtasker.client import Client
6
+ from labtasker.models import BulkUpdateResult, Queue, Task, TaskPage
7
+ from labtasker.types import JSONValue, TaskOrderField, TaskStatus, TaskUpdate
8
+
9
+ _default_client: Client | None = None
10
+ _default_client_lock = Lock()
11
+
12
+
13
+ def submit_task(
14
+ args: dict[str, JSONValue] | None = None,
15
+ *,
16
+ name: str | None = None,
17
+ metadata: dict[str, JSONValue] | None = None,
18
+ priority: int = 0,
19
+ max_attempts: int = 3,
20
+ routes: list[str] | None = None,
21
+ task_id: str | None = None,
22
+ queue: str | None = None,
23
+ ) -> Task:
24
+ return _client().submit_task(
25
+ args,
26
+ name=name,
27
+ metadata=metadata,
28
+ priority=priority,
29
+ max_attempts=max_attempts,
30
+ routes=routes,
31
+ task_id=task_id,
32
+ queue=queue,
33
+ )
34
+
35
+
36
+ def get_task(task_id: str, *, queue: str | None = None) -> Task:
37
+ return _client().get_task(task_id, queue=queue)
38
+
39
+
40
+ def list_tasks(
41
+ *,
42
+ status: TaskStatus | None = None,
43
+ name: str | None = None,
44
+ filter: str | None = None,
45
+ order_by: TaskOrderField = "created_at",
46
+ descending: bool = True,
47
+ limit: int = 100,
48
+ cursor: str | None = None,
49
+ queue: str | None = None,
50
+ ) -> TaskPage:
51
+ return _client().list_tasks(
52
+ status=status,
53
+ name=name,
54
+ filter=filter,
55
+ order_by=order_by,
56
+ descending=descending,
57
+ limit=limit,
58
+ cursor=cursor,
59
+ queue=queue,
60
+ )
61
+
62
+
63
+ def count_tasks(
64
+ *,
65
+ status: TaskStatus | None = None,
66
+ name: str | None = None,
67
+ filter: str | None = None,
68
+ queue: str | None = None,
69
+ ) -> int:
70
+ return _client().count_tasks(status=status, name=name, filter=filter, queue=queue)
71
+
72
+
73
+ def update_task(
74
+ task_id: str,
75
+ changes: TaskUpdate,
76
+ *,
77
+ queue: str | None = None,
78
+ ) -> Task:
79
+ return _client().update_task(task_id, changes, queue=queue)
80
+
81
+
82
+ def update_tasks(
83
+ *,
84
+ filter: str,
85
+ changes: TaskUpdate,
86
+ queue: str | None = None,
87
+ ) -> BulkUpdateResult:
88
+ return _client().update_tasks(filter=filter, changes=changes, queue=queue)
89
+
90
+
91
+ def cancel_task(task_id: str, *, queue: str | None = None) -> Task:
92
+ return _client().cancel_task(task_id, queue=queue)
93
+
94
+
95
+ def requeue_task(task_id: str, *, queue: str | None = None) -> Task:
96
+ return _client().requeue_task(task_id, queue=queue)
97
+
98
+
99
+ def delete_task(task_id: str, *, queue: str | None = None) -> None:
100
+ _client().delete_task(task_id, queue=queue)
101
+
102
+
103
+ def create_queue(name: str) -> Queue:
104
+ return _client().create_queue(name)
105
+
106
+
107
+ def list_queues() -> list[Queue]:
108
+ return _client().list_queues()
109
+
110
+
111
+ def delete_queue(name: str, *, cascade: bool = False) -> None:
112
+ _client().delete_queue(name, cascade=cascade)
113
+
114
+
115
+ def _client() -> Client:
116
+ global _default_client
117
+ if _default_client is not None:
118
+ return _default_client
119
+ with _default_client_lock:
120
+ if _default_client is None:
121
+ _default_client = Client()
122
+ return _default_client
labtasker/binding.py ADDED
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any, TypeVar, get_type_hints, overload
7
+
8
+ from pydantic import TypeAdapter, ValidationError
9
+
10
+ from labtasker.paths import PathError, parse_path, select_path
11
+ from labtasker.types import JSONValue
12
+
13
+ T = TypeVar("T")
14
+ _MISSING = object()
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class _TaskArgMarker:
19
+ default: object
20
+ path: str | None
21
+ resolver: Callable[[Any], Any] | None
22
+
23
+
24
+ @overload
25
+ def TaskArg(
26
+ *,
27
+ default: T,
28
+ path: str | None = None,
29
+ resolver: None = None,
30
+ ) -> T: ...
31
+
32
+
33
+ @overload
34
+ def TaskArg(
35
+ *,
36
+ default: object = _MISSING,
37
+ path: str | None = None,
38
+ resolver: Callable[[Any], T],
39
+ ) -> T: ...
40
+
41
+
42
+ @overload
43
+ def TaskArg(
44
+ *,
45
+ default: object = _MISSING,
46
+ path: str | None = None,
47
+ resolver: None = None,
48
+ ) -> Any: ...
49
+
50
+
51
+ def TaskArg(
52
+ *,
53
+ default: object = _MISSING,
54
+ path: str | None = None,
55
+ resolver: Callable[[Any], Any] | None = None,
56
+ ) -> Any:
57
+ """Mark one decorated Worker parameter as coming from Task args."""
58
+ return _TaskArgMarker(default=default, path=path, resolver=resolver)
59
+
60
+
61
+ class BindingError(ValueError):
62
+ pass
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class _Injection:
67
+ name: str
68
+ path: tuple[str, ...]
69
+ default: object
70
+ resolver: Callable[[Any], Any] | None
71
+ adapter: TypeAdapter[Any] | None
72
+
73
+ def resolve(self, args: dict[str, JSONValue]) -> Any:
74
+ try:
75
+ value = select_path(args, self.path)
76
+ except PathError as error:
77
+ if self.default is _MISSING:
78
+ raise BindingError(f"TaskArg {self.name!r}: {error}") from error
79
+ value = self.default
80
+ if self.resolver is not None:
81
+ try:
82
+ value = self.resolver(value)
83
+ except Exception as error:
84
+ raise BindingError(f"TaskArg {self.name!r} resolver failed: {error}") from error
85
+ if self.adapter is not None:
86
+ try:
87
+ value = self.adapter.validate_python(value, strict=True)
88
+ except ValidationError as error:
89
+ message = f"TaskArg {self.name!r} failed strict validation: {error}"
90
+ raise BindingError(message) from error
91
+ return value
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class CompiledBinding:
96
+ function: Callable[..., Any]
97
+ signature: inspect.Signature
98
+ injections: tuple[_Injection, ...]
99
+
100
+ def validate_invocation(self, args: tuple[object, ...], kwargs: dict[str, object]) -> None:
101
+ bound = self.signature.bind(*args, **kwargs)
102
+ supplied = {injection.name for injection in self.injections} & bound.arguments.keys()
103
+ if supplied:
104
+ names = ", ".join(sorted(supplied))
105
+ raise TypeError(
106
+ f"TaskArg parameters are supplied by Labtasker and cannot be passed: {names}"
107
+ )
108
+
109
+ def invoke(
110
+ self,
111
+ task_args: dict[str, JSONValue],
112
+ startup_args: tuple[object, ...],
113
+ startup_kwargs: dict[str, object],
114
+ ) -> Any:
115
+ bound = self.signature.bind(*startup_args, **startup_kwargs)
116
+ for injection in self.injections:
117
+ bound.arguments[injection.name] = injection.resolve(task_args)
118
+ return self.function(*bound.args, **bound.kwargs)
119
+
120
+
121
+ def compile_binding(function: Callable[..., Any]) -> CompiledBinding:
122
+ if inspect.iscoroutinefunction(function):
123
+ raise TypeError("Labtasker v2 Worker handlers must be synchronous functions.")
124
+ signature = inspect.signature(function)
125
+ try:
126
+ hints = get_type_hints(function, include_extras=True)
127
+ except Exception as error:
128
+ raise TypeError(f"Could not resolve Worker annotations: {error}") from error
129
+ injections: list[_Injection] = []
130
+ for parameter in signature.parameters.values():
131
+ marker = parameter.default
132
+ if not isinstance(marker, _TaskArgMarker):
133
+ continue
134
+ if parameter.kind in {
135
+ inspect.Parameter.POSITIONAL_ONLY,
136
+ inspect.Parameter.VAR_POSITIONAL,
137
+ inspect.Parameter.VAR_KEYWORD,
138
+ }:
139
+ raise TypeError(f"TaskArg parameter {parameter.name!r} must accept a keyword value.")
140
+ if marker.path is not None and not isinstance(marker.path, str):
141
+ raise TypeError(f"TaskArg path for {parameter.name!r} must be a string or None.")
142
+ try:
143
+ path = parse_path(parameter.name if marker.path is None else marker.path)
144
+ except PathError as error:
145
+ raise TypeError(f"Invalid TaskArg path for {parameter.name!r}: {error}") from error
146
+ resolver = marker.resolver
147
+ if resolver is not None:
148
+ _validate_resolver(resolver, parameter.name)
149
+ annotation = hints.get(parameter.name, parameter.annotation)
150
+ adapter: TypeAdapter[Any] | None = None
151
+ if annotation is not inspect.Parameter.empty:
152
+ try:
153
+ adapter = TypeAdapter(annotation)
154
+ except Exception as error:
155
+ raise TypeError(
156
+ f"Could not compile annotation for TaskArg {parameter.name!r}: {error}"
157
+ ) from error
158
+ injections.append(
159
+ _Injection(
160
+ name=parameter.name,
161
+ path=path,
162
+ default=marker.default,
163
+ resolver=resolver,
164
+ adapter=adapter,
165
+ )
166
+ )
167
+ return CompiledBinding(function=function, signature=signature, injections=tuple(injections))
168
+
169
+
170
+ def _validate_resolver(resolver: object, name: str) -> None:
171
+ if not callable(resolver):
172
+ raise TypeError(f"TaskArg resolver for {name!r} must be callable.")
173
+ if inspect.iscoroutinefunction(resolver):
174
+ raise TypeError(f"TaskArg resolver for {name!r} must be synchronous.")
175
+ try:
176
+ signature = inspect.signature(resolver)
177
+ except (TypeError, ValueError) as error:
178
+ raise TypeError(f"Could not inspect TaskArg resolver for {name!r}.") from error
179
+ parameters = list(signature.parameters.values())
180
+ if len(parameters) != 1 or parameters[0].kind not in {
181
+ inspect.Parameter.POSITIONAL_ONLY,
182
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
183
+ }:
184
+ raise TypeError(f"TaskArg resolver for {name!r} must accept exactly one value.")