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 +77 -0
- labtasker/__main__.py +3 -0
- labtasker/api.py +122 -0
- labtasker/binding.py +184 -0
- labtasker/cli.py +506 -0
- labtasker/client.py +725 -0
- labtasker/command_template.py +173 -0
- labtasker/command_worker.py +492 -0
- labtasker/config.py +193 -0
- labtasker/errors.py +56 -0
- labtasker/execution.py +380 -0
- labtasker/journal.py +314 -0
- labtasker/local.py +190 -0
- labtasker/models.py +218 -0
- labtasker/paths.py +34 -0
- labtasker/py.typed +1 -0
- labtasker/tee.py +128 -0
- labtasker/types.py +31 -0
- labtasker/validation.py +207 -0
- labtasker/worker.py +473 -0
- labtasker_client-2.0.0.dist-info/METADATA +13 -0
- labtasker_client-2.0.0.dist-info/RECORD +25 -0
- labtasker_client-2.0.0.dist-info/WHEEL +4 -0
- labtasker_client-2.0.0.dist-info/entry_points.txt +2 -0
- labtasker_client-2.0.0.dist-info/licenses/LICENSE +201 -0
labtasker/config.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal, TypedDict
|
|
8
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
from labtasker.errors import ConfigError
|
|
11
|
+
from labtasker.local import LocalPaths, local_paths, require_local_capabilities
|
|
12
|
+
from labtasker.validation import RequestValidationError, invalid_config, validate_identifier
|
|
13
|
+
|
|
14
|
+
DEFAULT_QUEUE = "default"
|
|
15
|
+
CONFIG_FIELDS = {"url", "queue", "token"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EndpointRecord(TypedDict):
|
|
19
|
+
mode: Literal["http", "local"]
|
|
20
|
+
url: str | None
|
|
21
|
+
socket: str | None
|
|
22
|
+
directory: str | None
|
|
23
|
+
database: str | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ResolvedConfig:
|
|
28
|
+
url: str | None
|
|
29
|
+
queue: str
|
|
30
|
+
token: str | None
|
|
31
|
+
local: LocalPaths | None
|
|
32
|
+
|
|
33
|
+
def public_dict(self) -> dict[str, object]:
|
|
34
|
+
return {
|
|
35
|
+
"mode": "local" if self.local is not None else "http",
|
|
36
|
+
"directory": str(self.local.directory) if self.local is not None else None,
|
|
37
|
+
"database": str(self.local.database) if self.local is not None else None,
|
|
38
|
+
"socket": str(self.local.socket) if self.local is not None else None,
|
|
39
|
+
"url": self.url,
|
|
40
|
+
"queue": self.queue,
|
|
41
|
+
"token_configured": self.token is not None,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def endpoint_dict(self) -> EndpointRecord:
|
|
45
|
+
return {
|
|
46
|
+
"mode": "local" if self.local is not None else "http",
|
|
47
|
+
"url": self.url,
|
|
48
|
+
"socket": str(self.local.socket) if self.local is not None else None,
|
|
49
|
+
"directory": str(self.local.directory) if self.local is not None else None,
|
|
50
|
+
"database": str(self.local.database) if self.local is not None else None,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_config(
|
|
55
|
+
*,
|
|
56
|
+
url: str | None = None,
|
|
57
|
+
token: str | None = None,
|
|
58
|
+
queue: str | None = None,
|
|
59
|
+
cwd: Path | None = None,
|
|
60
|
+
environment: dict[str, str] | None = None,
|
|
61
|
+
) -> ResolvedConfig:
|
|
62
|
+
working_directory = Path.cwd() if cwd is None else cwd
|
|
63
|
+
environment_values = os.environ if environment is None else environment
|
|
64
|
+
file_values = _read_config_file(working_directory)
|
|
65
|
+
constructor_values = {"url": url, "token": token, "queue": queue}
|
|
66
|
+
environment_fields = {
|
|
67
|
+
"url": environment_values.get("LABTASKER_URL"),
|
|
68
|
+
"token": environment_values.get("LABTASKER_TOKEN"),
|
|
69
|
+
"queue": environment_values.get("LABTASKER_QUEUE"),
|
|
70
|
+
}
|
|
71
|
+
defaults: dict[str, str | None] = {
|
|
72
|
+
"url": None,
|
|
73
|
+
"token": None,
|
|
74
|
+
"queue": DEFAULT_QUEUE,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
effective: dict[str, str | None] = {}
|
|
78
|
+
sources: dict[str, str] = {}
|
|
79
|
+
config_path = str(working_directory / ".labtasker" / "config.toml")
|
|
80
|
+
for field in ("url", "token", "queue"):
|
|
81
|
+
candidates = [
|
|
82
|
+
(constructor_values[field], "constructor"),
|
|
83
|
+
(environment_fields[field], "environment"),
|
|
84
|
+
(file_values.get(field), config_path),
|
|
85
|
+
(defaults[field], "default"),
|
|
86
|
+
]
|
|
87
|
+
selected = next(
|
|
88
|
+
((candidate, source) for candidate, source in candidates if candidate is not None),
|
|
89
|
+
(None, "default"),
|
|
90
|
+
)
|
|
91
|
+
value, source = selected
|
|
92
|
+
effective[field] = value
|
|
93
|
+
sources[field] = source
|
|
94
|
+
|
|
95
|
+
effective_url = _validate_url(effective["url"], source=sources["url"])
|
|
96
|
+
effective_queue = _validate_queue(effective["queue"], source=sources["queue"])
|
|
97
|
+
effective_token = _validate_token(effective["token"], source=sources["token"])
|
|
98
|
+
local: LocalPaths | None = None
|
|
99
|
+
if effective_url is None:
|
|
100
|
+
if effective_token is not None:
|
|
101
|
+
raise invalid_config(
|
|
102
|
+
"A token requires an explicit URL; local mode does not use authentication.",
|
|
103
|
+
source=sources["token"],
|
|
104
|
+
field="token",
|
|
105
|
+
)
|
|
106
|
+
require_local_capabilities()
|
|
107
|
+
local = local_paths(working_directory)
|
|
108
|
+
return ResolvedConfig(
|
|
109
|
+
url=effective_url,
|
|
110
|
+
queue=effective_queue,
|
|
111
|
+
token=effective_token,
|
|
112
|
+
local=local,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_config_file(cwd: Path) -> dict[str, str]:
|
|
117
|
+
config_path = cwd / ".labtasker" / "config.toml"
|
|
118
|
+
legacy_path = cwd / ".labtasker" / "client.toml"
|
|
119
|
+
if not config_path.exists():
|
|
120
|
+
if legacy_path.exists():
|
|
121
|
+
raise ConfigError(
|
|
122
|
+
"legacy_config_found",
|
|
123
|
+
"A v1 client.toml was found; create the v2 flat config.toml manually.",
|
|
124
|
+
{"source": str(legacy_path)},
|
|
125
|
+
)
|
|
126
|
+
return {}
|
|
127
|
+
try:
|
|
128
|
+
raw = config_path.read_bytes()
|
|
129
|
+
parsed = tomllib.loads(raw.decode("utf-8"))
|
|
130
|
+
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
|
|
131
|
+
raise invalid_config(
|
|
132
|
+
"The client configuration file could not be read or parsed.",
|
|
133
|
+
source=str(config_path),
|
|
134
|
+
) from error
|
|
135
|
+
if set(parsed) - CONFIG_FIELDS:
|
|
136
|
+
raise invalid_config(
|
|
137
|
+
f"Unknown configuration keys: {sorted(set(parsed) - CONFIG_FIELDS)!r}.",
|
|
138
|
+
source=str(config_path),
|
|
139
|
+
)
|
|
140
|
+
values: dict[str, str] = {}
|
|
141
|
+
for field, value in parsed.items():
|
|
142
|
+
if not isinstance(value, str) or not value:
|
|
143
|
+
raise invalid_config(
|
|
144
|
+
f"Configuration field '{field}' must be a non-empty string.",
|
|
145
|
+
source=str(config_path),
|
|
146
|
+
field=field,
|
|
147
|
+
)
|
|
148
|
+
values[field] = value
|
|
149
|
+
return values
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _validate_url(value: str | None, *, source: str) -> str | None:
|
|
153
|
+
if value is None:
|
|
154
|
+
return None
|
|
155
|
+
if not isinstance(value, str) or not value:
|
|
156
|
+
raise invalid_config("URL must be a non-empty string.", source=source, field="url")
|
|
157
|
+
try:
|
|
158
|
+
parsed = urlsplit(value)
|
|
159
|
+
port = parsed.port
|
|
160
|
+
except ValueError as error:
|
|
161
|
+
raise invalid_config("URL is invalid.", source=source, field="url") from error
|
|
162
|
+
if (
|
|
163
|
+
parsed.scheme not in {"http", "https"}
|
|
164
|
+
or not parsed.netloc
|
|
165
|
+
or parsed.hostname is None
|
|
166
|
+
or parsed.username is not None
|
|
167
|
+
or parsed.password is not None
|
|
168
|
+
or parsed.query
|
|
169
|
+
or parsed.fragment
|
|
170
|
+
or (port is not None and not 1 <= port <= 65535)
|
|
171
|
+
):
|
|
172
|
+
raise invalid_config(
|
|
173
|
+
"URL must be an absolute HTTP(S) base URL without userinfo, query or fragment.",
|
|
174
|
+
source=source,
|
|
175
|
+
field="url",
|
|
176
|
+
)
|
|
177
|
+
path = parsed.path.rstrip("/")
|
|
178
|
+
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _validate_queue(value: str | None, *, source: str) -> str:
|
|
182
|
+
try:
|
|
183
|
+
return validate_identifier(value, field="queue")
|
|
184
|
+
except RequestValidationError as error:
|
|
185
|
+
raise invalid_config(str(error), source=source, field="queue") from error
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _validate_token(value: str | None, *, source: str) -> str | None:
|
|
189
|
+
if value is None:
|
|
190
|
+
return None
|
|
191
|
+
if not isinstance(value, str) or not value:
|
|
192
|
+
raise invalid_config("Token must be a non-empty string.", source=source, field="token")
|
|
193
|
+
return value
|
labtasker/errors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LabtaskerError(Exception):
|
|
7
|
+
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.code = code
|
|
10
|
+
self.message = message
|
|
11
|
+
self.details = {} if details is None else details
|
|
12
|
+
|
|
13
|
+
def as_envelope(self) -> dict[str, object]:
|
|
14
|
+
return {
|
|
15
|
+
"error": {
|
|
16
|
+
"code": self.code,
|
|
17
|
+
"message": self.message,
|
|
18
|
+
"details": self.details,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigError(LabtaskerError):
|
|
24
|
+
def __init__(self, code: str, message: str, details: dict[str, Any]) -> None:
|
|
25
|
+
if code not in {"invalid_config", "legacy_config_found"}:
|
|
26
|
+
raise ValueError(f"Unsupported ConfigError code: {code}")
|
|
27
|
+
super().__init__(code, message, details)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TransportError(LabtaskerError):
|
|
31
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
32
|
+
super().__init__("transport_error", message, details)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class APIError(LabtaskerError):
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
status_code: int,
|
|
39
|
+
code: str,
|
|
40
|
+
message: str,
|
|
41
|
+
details: dict[str, Any],
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(code, message, details)
|
|
44
|
+
self.status_code = status_code
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TransientError(Exception):
|
|
48
|
+
"""Return the current Task to pending without charging this incident."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TaskError(Exception):
|
|
52
|
+
"""Fail the current execution and continue the Worker loop."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FatalWorkerError(Exception):
|
|
56
|
+
"""Fail the current execution and stop the Worker process."""
|
labtasker/execution.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from labtasker.client import Client
|
|
12
|
+
from labtasker.config import EndpointRecord
|
|
13
|
+
from labtasker.errors import ConfigError
|
|
14
|
+
from labtasker.journal import LocalRunJournal
|
|
15
|
+
from labtasker.models import Task, TaskInfo
|
|
16
|
+
from labtasker.types import JSONValue
|
|
17
|
+
from labtasker.validation import RequestValidationError, validate_json_object
|
|
18
|
+
|
|
19
|
+
CompletionReporter = Callable[[dict[str, JSONValue]], bool]
|
|
20
|
+
ContextKind = Literal["python", "command"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunControl:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
force_stop_timeout: float | None,
|
|
28
|
+
force_stop: Callable[[], None],
|
|
29
|
+
) -> None:
|
|
30
|
+
self._condition = threading.Condition()
|
|
31
|
+
self._force_stop_timeout = force_stop_timeout
|
|
32
|
+
self._force_stop = force_stop
|
|
33
|
+
self._revoked_at: float | None = None
|
|
34
|
+
self._revoked_action: str | None = None
|
|
35
|
+
self._fatal_error: Exception | None = None
|
|
36
|
+
self._completed = False
|
|
37
|
+
self._executor_done = False
|
|
38
|
+
self._watchdog = threading.Thread(
|
|
39
|
+
target=self._watch_force_stop,
|
|
40
|
+
name="labtasker-force-stop",
|
|
41
|
+
daemon=True,
|
|
42
|
+
)
|
|
43
|
+
self._watchdog.start()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def revoked(self) -> bool:
|
|
47
|
+
with self._condition:
|
|
48
|
+
return self._revoked_at is not None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def revoked_action(self) -> str | None:
|
|
52
|
+
with self._condition:
|
|
53
|
+
return self._revoked_action
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def completed(self) -> bool:
|
|
57
|
+
with self._condition:
|
|
58
|
+
return self._completed
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def fatal_error(self) -> Exception | None:
|
|
62
|
+
with self._condition:
|
|
63
|
+
return self._fatal_error
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def active(self) -> bool:
|
|
67
|
+
with self._condition:
|
|
68
|
+
return self._revoked_at is None and not self._completed
|
|
69
|
+
|
|
70
|
+
def revoke(self, action: str) -> None:
|
|
71
|
+
with self._condition:
|
|
72
|
+
if self._completed or self._revoked_at is not None:
|
|
73
|
+
return
|
|
74
|
+
self._revoked_at = time.monotonic()
|
|
75
|
+
self._revoked_action = action
|
|
76
|
+
self._condition.notify_all()
|
|
77
|
+
|
|
78
|
+
def fail(self, error: Exception) -> None:
|
|
79
|
+
with self._condition:
|
|
80
|
+
if self._completed or self._revoked_at is not None:
|
|
81
|
+
return
|
|
82
|
+
self._fatal_error = error
|
|
83
|
+
self._revoked_at = time.monotonic()
|
|
84
|
+
self._revoked_action = error.__class__.__name__
|
|
85
|
+
self._condition.notify_all()
|
|
86
|
+
|
|
87
|
+
def complete(self) -> None:
|
|
88
|
+
with self._condition:
|
|
89
|
+
if self._revoked_at is None:
|
|
90
|
+
self._completed = True
|
|
91
|
+
self._condition.notify_all()
|
|
92
|
+
|
|
93
|
+
def executor_done(self) -> None:
|
|
94
|
+
with self._condition:
|
|
95
|
+
self._executor_done = True
|
|
96
|
+
self._condition.notify_all()
|
|
97
|
+
|
|
98
|
+
def set_force_stop_timeout(self, value: float | None) -> None:
|
|
99
|
+
with self._condition:
|
|
100
|
+
if self._completed:
|
|
101
|
+
raise RuntimeError("The current run has already completed.")
|
|
102
|
+
self._force_stop_timeout = value
|
|
103
|
+
self._condition.notify_all()
|
|
104
|
+
|
|
105
|
+
def _watch_force_stop(self) -> None:
|
|
106
|
+
while True:
|
|
107
|
+
with self._condition:
|
|
108
|
+
if self._executor_done or self._completed:
|
|
109
|
+
return
|
|
110
|
+
if self._revoked_at is None or self._force_stop_timeout is None:
|
|
111
|
+
self._condition.wait()
|
|
112
|
+
continue
|
|
113
|
+
deadline = self._revoked_at + self._force_stop_timeout
|
|
114
|
+
remaining = deadline - time.monotonic()
|
|
115
|
+
if remaining > 0:
|
|
116
|
+
self._condition.wait(remaining)
|
|
117
|
+
continue
|
|
118
|
+
self._force_stop()
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ExecutionContext:
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
info: TaskInfo,
|
|
127
|
+
kind: ContextKind,
|
|
128
|
+
journal: LocalRunJournal,
|
|
129
|
+
reporter: CompletionReporter,
|
|
130
|
+
control: RunControl | None,
|
|
131
|
+
) -> None:
|
|
132
|
+
self.info = info
|
|
133
|
+
self.kind = kind
|
|
134
|
+
self.journal = journal
|
|
135
|
+
self.reporter = reporter
|
|
136
|
+
self.control = control
|
|
137
|
+
self._lock = threading.Lock()
|
|
138
|
+
self._finish_started = False
|
|
139
|
+
self._finished = False
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def finished(self) -> bool:
|
|
143
|
+
with self._lock:
|
|
144
|
+
return self._finished
|
|
145
|
+
|
|
146
|
+
def finish(self, result: dict[str, JSONValue]) -> None:
|
|
147
|
+
with self._lock:
|
|
148
|
+
if self._finish_started:
|
|
149
|
+
raise RuntimeError("finish() has already been called for this execution.")
|
|
150
|
+
self._finish_started = True
|
|
151
|
+
_best_effort_journal(lambda: self.journal.reporting("complete", result))
|
|
152
|
+
accepted = self.reporter(result)
|
|
153
|
+
if not accepted:
|
|
154
|
+
_best_effort_journal(self.journal.revoked)
|
|
155
|
+
raise RuntimeError("The current run was revoked before finish() could complete it.")
|
|
156
|
+
with self._lock:
|
|
157
|
+
self._finished = True
|
|
158
|
+
if self.control is not None:
|
|
159
|
+
self.control.complete()
|
|
160
|
+
_best_effort_journal(self.journal.acknowledged)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_CONTEXT_LOCK = threading.RLock()
|
|
164
|
+
_ACTIVE_CONTEXT: ExecutionContext | None = None
|
|
165
|
+
_ENV_CONTEXT: ExecutionContext | None = None
|
|
166
|
+
_FORK_HOOK_INSTALLED = False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def activate_context(context: ExecutionContext) -> None:
|
|
170
|
+
global _ACTIVE_CONTEXT, _FORK_HOOK_INSTALLED
|
|
171
|
+
with _CONTEXT_LOCK:
|
|
172
|
+
if _ACTIVE_CONTEXT is not None:
|
|
173
|
+
raise RuntimeError("A Labtasker execution context is already active.")
|
|
174
|
+
if not _FORK_HOOK_INSTALLED and hasattr(os, "register_at_fork"):
|
|
175
|
+
os.register_at_fork(after_in_child=_clear_after_fork)
|
|
176
|
+
_FORK_HOOK_INSTALLED = True
|
|
177
|
+
_ACTIVE_CONTEXT = context
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def deactivate_context(context: ExecutionContext) -> None:
|
|
181
|
+
global _ACTIVE_CONTEXT
|
|
182
|
+
with _CONTEXT_LOCK:
|
|
183
|
+
if _ACTIVE_CONTEXT is context:
|
|
184
|
+
_ACTIVE_CONTEXT = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def active_context_present() -> bool:
|
|
188
|
+
with _CONTEXT_LOCK:
|
|
189
|
+
return _ACTIVE_CONTEXT is not None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def task_info() -> TaskInfo:
|
|
193
|
+
context = _get_context()
|
|
194
|
+
if context is None:
|
|
195
|
+
raise RuntimeError("No active Labtasker Task execution is available.")
|
|
196
|
+
return context.info
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def finish(
|
|
200
|
+
result: dict[str, JSONValue] | None = None,
|
|
201
|
+
*,
|
|
202
|
+
skip_if_no_labtasker: bool = False,
|
|
203
|
+
) -> None:
|
|
204
|
+
context = _get_context()
|
|
205
|
+
if context is None:
|
|
206
|
+
if skip_if_no_labtasker:
|
|
207
|
+
return
|
|
208
|
+
raise RuntimeError("No active Labtasker Task execution is available.")
|
|
209
|
+
normalized = validate_json_object({} if result is None else result, field="result")
|
|
210
|
+
context.finish(normalized)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cancellation_requested() -> bool:
|
|
214
|
+
context = _require_python_context()
|
|
215
|
+
if context.finished or context.control is None:
|
|
216
|
+
return False
|
|
217
|
+
return context.control.revoked
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def set_force_stop_timeout(seconds: float | None) -> None:
|
|
221
|
+
context = _require_python_context()
|
|
222
|
+
if context.finished or context.control is None:
|
|
223
|
+
raise RuntimeError("The current run is no longer cancellable.")
|
|
224
|
+
context.control.set_force_stop_timeout(_validate_force_stop_timeout(seconds))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _require_python_context() -> ExecutionContext:
|
|
228
|
+
context = _get_context()
|
|
229
|
+
if context is None or context.kind != "python":
|
|
230
|
+
raise RuntimeError("This function requires an active Python Worker execution.")
|
|
231
|
+
return context
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _get_context() -> ExecutionContext | None:
|
|
235
|
+
with _CONTEXT_LOCK:
|
|
236
|
+
if _ACTIVE_CONTEXT is not None:
|
|
237
|
+
return _ACTIVE_CONTEXT
|
|
238
|
+
return _load_environment_context()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _load_environment_context() -> ExecutionContext | None:
|
|
242
|
+
global _ENV_CONTEXT
|
|
243
|
+
with _CONTEXT_LOCK:
|
|
244
|
+
if _ENV_CONTEXT is not None:
|
|
245
|
+
return _ENV_CONTEXT
|
|
246
|
+
names = {
|
|
247
|
+
"queue": "LABTASKER_QUEUE",
|
|
248
|
+
"task_id": "LABTASKER_TASK_ID",
|
|
249
|
+
"run_id": "LABTASKER_RUN_ID",
|
|
250
|
+
"route": "LABTASKER_ROUTE",
|
|
251
|
+
"run_dir": "LABTASKER_RUN_DIR",
|
|
252
|
+
}
|
|
253
|
+
values = {field: os.environ.get(name) for field, name in names.items()}
|
|
254
|
+
execution_fields = {"task_id", "run_id", "route", "run_dir"}
|
|
255
|
+
present = {field for field, value in values.items() if value is not None}
|
|
256
|
+
if not (present & execution_fields):
|
|
257
|
+
return None
|
|
258
|
+
if present != set(names):
|
|
259
|
+
raise ConfigError(
|
|
260
|
+
"invalid_config",
|
|
261
|
+
"Inherited Labtasker execution context is incomplete.",
|
|
262
|
+
{"missing": sorted(set(names) - present)},
|
|
263
|
+
)
|
|
264
|
+
url = os.environ.get("LABTASKER_URL")
|
|
265
|
+
socket = os.environ.get("LABTASKER_SOCKET")
|
|
266
|
+
local_directory = os.environ.get("LABTASKER_LOCAL_DIRECTORY")
|
|
267
|
+
token = os.environ.get("LABTASKER_TOKEN")
|
|
268
|
+
http_endpoint = url is not None and socket is None and local_directory is None
|
|
269
|
+
local_endpoint = (
|
|
270
|
+
url is None and socket is not None and local_directory is not None and token is None
|
|
271
|
+
)
|
|
272
|
+
if not (http_endpoint or local_endpoint):
|
|
273
|
+
raise ConfigError(
|
|
274
|
+
"invalid_config",
|
|
275
|
+
"Inherited Labtasker execution endpoint is incomplete or ambiguous.",
|
|
276
|
+
{"expected": ("LABTASKER_URL, or LABTASKER_SOCKET and LABTASKER_LOCAL_DIRECTORY")},
|
|
277
|
+
)
|
|
278
|
+
run_dir = Path(values["run_dir"] or "")
|
|
279
|
+
if not run_dir.is_absolute():
|
|
280
|
+
raise ConfigError(
|
|
281
|
+
"invalid_config",
|
|
282
|
+
"LABTASKER_RUN_DIR must be absolute.",
|
|
283
|
+
{"field": "LABTASKER_RUN_DIR"},
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
task = Task.model_validate_json((run_dir / "task.json").read_bytes(), strict=True)
|
|
287
|
+
run_id = values["run_id"] or ""
|
|
288
|
+
journal = LocalRunJournal.open(run_dir)
|
|
289
|
+
expected_endpoint: EndpointRecord
|
|
290
|
+
if http_endpoint:
|
|
291
|
+
expected_endpoint = {
|
|
292
|
+
"mode": "http",
|
|
293
|
+
"url": url,
|
|
294
|
+
"socket": None,
|
|
295
|
+
"directory": None,
|
|
296
|
+
"database": None,
|
|
297
|
+
}
|
|
298
|
+
client = Client(url=url, token=token, queue=values["queue"])
|
|
299
|
+
else:
|
|
300
|
+
directory = Path(local_directory or "")
|
|
301
|
+
if not directory.is_absolute() or directory.resolve() != directory:
|
|
302
|
+
raise ValueError("LABTASKER_LOCAL_DIRECTORY must be a canonical absolute path")
|
|
303
|
+
client = Client._from_local_directory(
|
|
304
|
+
directory,
|
|
305
|
+
queue=values["queue"] or "",
|
|
306
|
+
)
|
|
307
|
+
local = client.configuration.local
|
|
308
|
+
assert local is not None
|
|
309
|
+
if str(local.socket) != socket:
|
|
310
|
+
raise ValueError("local execution socket does not match its directory")
|
|
311
|
+
expected_endpoint = client.configuration.endpoint_dict()
|
|
312
|
+
if (
|
|
313
|
+
task.id != values["task_id"]
|
|
314
|
+
or task.queue != values["queue"]
|
|
315
|
+
or journal.endpoint != expected_endpoint
|
|
316
|
+
or journal.queue != values["queue"]
|
|
317
|
+
or journal.task_id != values["task_id"]
|
|
318
|
+
or journal.run_id != run_id
|
|
319
|
+
or journal.route != values["route"]
|
|
320
|
+
):
|
|
321
|
+
raise ValueError("execution environment does not match the local journal")
|
|
322
|
+
info = TaskInfo(**task.model_dump(), run_id=run_id, run_dir=journal.run_dir)
|
|
323
|
+
except Exception as error:
|
|
324
|
+
raise ConfigError(
|
|
325
|
+
"invalid_config",
|
|
326
|
+
"Inherited Labtasker execution context could not be loaded.",
|
|
327
|
+
{"source": str(run_dir)},
|
|
328
|
+
) from error
|
|
329
|
+
|
|
330
|
+
def report(result: dict[str, JSONValue]) -> bool:
|
|
331
|
+
from labtasker.worker import report_complete_until_resolved
|
|
332
|
+
|
|
333
|
+
return report_complete_until_resolved(
|
|
334
|
+
client,
|
|
335
|
+
queue=values["queue"] or "",
|
|
336
|
+
task_id=values["task_id"] or "",
|
|
337
|
+
run_id=run_id,
|
|
338
|
+
result=result,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
_ENV_CONTEXT = ExecutionContext(
|
|
342
|
+
info=info,
|
|
343
|
+
kind="command",
|
|
344
|
+
journal=journal,
|
|
345
|
+
reporter=report,
|
|
346
|
+
control=None,
|
|
347
|
+
)
|
|
348
|
+
return _ENV_CONTEXT
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _best_effort_journal(operation: Callable[[], None]) -> None:
|
|
352
|
+
try:
|
|
353
|
+
operation()
|
|
354
|
+
except Exception as error:
|
|
355
|
+
warnings.warn(
|
|
356
|
+
f"Labtasker could not update the local run journal: {error}",
|
|
357
|
+
RuntimeWarning,
|
|
358
|
+
stacklevel=3,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _validate_force_stop_timeout(value: float | None) -> float | None:
|
|
363
|
+
if value is None:
|
|
364
|
+
return None
|
|
365
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
366
|
+
raise RequestValidationError(
|
|
367
|
+
"force_stop_timeout must be a finite non-negative number or None"
|
|
368
|
+
)
|
|
369
|
+
normalized = float(value)
|
|
370
|
+
if not 0 <= normalized < float("inf"):
|
|
371
|
+
raise RequestValidationError(
|
|
372
|
+
"force_stop_timeout must be a finite non-negative number or None"
|
|
373
|
+
)
|
|
374
|
+
return normalized
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _clear_after_fork() -> None:
|
|
378
|
+
global _ACTIVE_CONTEXT, _ENV_CONTEXT
|
|
379
|
+
_ACTIVE_CONTEXT = None
|
|
380
|
+
_ENV_CONTEXT = None
|