labtasker 0.1.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 +7 -0
- labtasker/__main__.py +6 -0
- labtasker/api_models.py +205 -0
- labtasker/client/__init__.py +0 -0
- labtasker/client/cli/__init__.py +26 -0
- labtasker/client/cli/cli.py +43 -0
- labtasker/client/cli/config.py +80 -0
- labtasker/client/cli/loop.py +131 -0
- labtasker/client/cli/queue.py +148 -0
- labtasker/client/cli/task.py +435 -0
- labtasker/client/cli/worker.py +167 -0
- labtasker/client/client_api.py +30 -0
- labtasker/client/core/__init__.py +0 -0
- labtasker/client/core/api.py +400 -0
- labtasker/client/core/cli_utils.py +238 -0
- labtasker/client/core/cmd_parser/LabCmd.g4 +14 -0
- labtasker/client/core/cmd_parser/LabCmd.py +620 -0
- labtasker/client/core/cmd_parser/LabCmdLexer.g4 +15 -0
- labtasker/client/core/cmd_parser/LabCmdLexer.py +493 -0
- labtasker/client/core/cmd_parser/LabCmdListener.py +54 -0
- labtasker/client/core/cmd_parser/__init__.py +5 -0
- labtasker/client/core/cmd_parser/parser.py +294 -0
- labtasker/client/core/config.py +156 -0
- labtasker/client/core/context.py +54 -0
- labtasker/client/core/exceptions.py +55 -0
- labtasker/client/core/heartbeat.py +103 -0
- labtasker/client/core/job_runner.py +227 -0
- labtasker/client/core/logging.py +91 -0
- labtasker/client/core/paths.py +64 -0
- labtasker/client/core/plugin_utils.py +72 -0
- labtasker/client/templates/labtasker_root/.gitignore +4 -0
- labtasker/client/templates/labtasker_root/client.toml +14 -0
- labtasker/client/templates/labtasker_root/logs/.gitkeep +1 -0
- labtasker/concurrent.py +11 -0
- labtasker/constants.py +7 -0
- labtasker/filtering.py +82 -0
- labtasker/security.py +26 -0
- labtasker/server/__init__.py +0 -0
- labtasker/server/config.py +59 -0
- labtasker/server/database.py +1018 -0
- labtasker/server/db_utils.py +91 -0
- labtasker/server/dependencies.py +39 -0
- labtasker/server/endpoints.py +458 -0
- labtasker/server/fsm.py +258 -0
- labtasker/server/logging.py +44 -0
- labtasker/server/run.py +21 -0
- labtasker/utils.py +353 -0
- labtasker-0.1.0.dist-info/METADATA +89 -0
- labtasker-0.1.0.dist-info/RECORD +52 -0
- labtasker-0.1.0.dist-info/WHEEL +5 -0
- labtasker-0.1.0.dist-info/entry_points.txt +2 -0
- labtasker-0.1.0.dist-info/top_level.txt +1 -0
labtasker/__init__.py
ADDED
labtasker/__main__.py
ADDED
labtasker/api_models.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
5
|
+
|
|
6
|
+
from labtasker.constants import Priority
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseApiModel(BaseModel):
|
|
10
|
+
"""
|
|
11
|
+
Base API model for all API models.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HealthCheckResponse(BaseApiModel):
|
|
18
|
+
status: str = Field(..., pattern=r"^(healthy|unhealthy)$")
|
|
19
|
+
database: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class QueueCreateRequest(BaseApiModel):
|
|
23
|
+
queue_name: str = Field(
|
|
24
|
+
..., pattern=r"^[a-zA-Z0-9_-]+$", min_length=1, max_length=100
|
|
25
|
+
)
|
|
26
|
+
password: SecretStr = Field(..., min_length=1, max_length=100)
|
|
27
|
+
metadata: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def to_request_dict(self):
|
|
30
|
+
"""
|
|
31
|
+
Used to form a quest, since password must be revealed
|
|
32
|
+
"""
|
|
33
|
+
result = self.model_dump()
|
|
34
|
+
result.update({"password": self.password.get_secret_value()})
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class QueueCreateResponse(BaseApiModel):
|
|
39
|
+
queue_id: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class QueueGetResponse(BaseApiModel):
|
|
43
|
+
queue_id: str = Field(alias="_id")
|
|
44
|
+
queue_name: str
|
|
45
|
+
created_at: datetime
|
|
46
|
+
last_modified: datetime
|
|
47
|
+
metadata: Dict[str, Any]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TaskSubmitRequest(BaseApiModel):
|
|
51
|
+
"""Task submission request."""
|
|
52
|
+
|
|
53
|
+
task_name: Optional[str] = Field(
|
|
54
|
+
None, pattern=r"^[a-zA-Z0-9_-]+$", min_length=1, max_length=100
|
|
55
|
+
)
|
|
56
|
+
args: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
|
57
|
+
metadata: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
|
58
|
+
cmd: Optional[Union[str, List[str]]] = None
|
|
59
|
+
heartbeat_timeout: Optional[float] = None
|
|
60
|
+
task_timeout: Optional[int] = None
|
|
61
|
+
max_retries: int = 3
|
|
62
|
+
priority: int = Priority.MEDIUM
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TaskFetchRequest(BaseApiModel):
|
|
66
|
+
worker_id: Optional[str] = None
|
|
67
|
+
eta_max: Optional[str] = None
|
|
68
|
+
heartbeat_timeout: Optional[float] = None
|
|
69
|
+
start_heartbeat: bool = True
|
|
70
|
+
required_fields: Optional[Dict[str, Any]] = None
|
|
71
|
+
extra_filter: Optional[Dict[str, Any]] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Task(BaseApiModel):
|
|
75
|
+
task_id: str = Field(alias="_id") # Accepts "_id" as an input field
|
|
76
|
+
queue_id: str
|
|
77
|
+
status: str
|
|
78
|
+
task_name: Optional[str]
|
|
79
|
+
created_at: datetime
|
|
80
|
+
start_time: Optional[datetime]
|
|
81
|
+
last_heartbeat: Optional[datetime]
|
|
82
|
+
last_modified: datetime
|
|
83
|
+
heartbeat_timeout: Optional[float]
|
|
84
|
+
task_timeout: Optional[int]
|
|
85
|
+
max_retries: int
|
|
86
|
+
retries: int
|
|
87
|
+
priority: int
|
|
88
|
+
metadata: Dict
|
|
89
|
+
args: Dict
|
|
90
|
+
cmd: Union[str, List[str]]
|
|
91
|
+
summary: Dict
|
|
92
|
+
worker_id: Optional[str]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TaskUpdateRequest(BaseApiModel):
|
|
96
|
+
"""This should be consistent with Task.
|
|
97
|
+
Fields that disallow manual update are commented out.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
task_id: str = Field(alias="_id") # Accepts "_id" as an input field
|
|
101
|
+
# queue_id: str
|
|
102
|
+
status: Optional[str] = None
|
|
103
|
+
task_name: Optional[str] = Field(
|
|
104
|
+
None, pattern=r"^[a-zA-Z0-9_-]+$", min_length=1, max_length=100
|
|
105
|
+
)
|
|
106
|
+
# created_at: datetime
|
|
107
|
+
# start_time: Optional[datetime]
|
|
108
|
+
# last_heartbeat: Optional[datetime]
|
|
109
|
+
# last_modified: datetime
|
|
110
|
+
heartbeat_timeout: Optional[float] = None
|
|
111
|
+
task_timeout: Optional[int] = None
|
|
112
|
+
max_retries: Optional[int] = None
|
|
113
|
+
retries: Optional[int] = None
|
|
114
|
+
priority: Optional[int] = None
|
|
115
|
+
metadata: Optional[Dict] = None
|
|
116
|
+
args: Optional[Dict] = None
|
|
117
|
+
cmd: Optional[Union[str, List[str]]] = None
|
|
118
|
+
summary: Optional[Dict] = None
|
|
119
|
+
# worker_id: Optional[str]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TaskFetchResponse(BaseApiModel):
|
|
123
|
+
found: bool = False
|
|
124
|
+
task: Optional[Task] = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TaskLsRequest(BaseApiModel):
|
|
128
|
+
offset: int = Field(0, ge=0)
|
|
129
|
+
limit: int = Field(100, ge=0, le=1000)
|
|
130
|
+
task_id: Optional[str] = None
|
|
131
|
+
task_name: Optional[str] = None
|
|
132
|
+
extra_filter: Optional[Dict[str, Any]] = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TaskLsResponse(BaseApiModel):
|
|
136
|
+
found: bool = False
|
|
137
|
+
content: List[Task] = Field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TaskSubmitResponse(BaseApiModel):
|
|
141
|
+
task_id: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TaskStatusUpdateRequest(BaseApiModel):
|
|
145
|
+
status: str = Field(..., pattern=r"^(success|failed|cancelled)$")
|
|
146
|
+
worker_id: Optional[str] = None
|
|
147
|
+
summary: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class WorkerCreateRequest(BaseApiModel):
|
|
151
|
+
worker_name: Optional[str] = None
|
|
152
|
+
metadata: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
|
153
|
+
max_retries: Optional[int] = 3
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class WorkerCreateResponse(BaseApiModel):
|
|
157
|
+
worker_id: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class WorkerStatusUpdateRequest(BaseApiModel):
|
|
161
|
+
status: str = Field(..., pattern=r"^(active|suspended|failed)$")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class WorkerLsRequest(BaseApiModel):
|
|
165
|
+
offset: int = Field(0, ge=0)
|
|
166
|
+
limit: int = Field(100, ge=0, le=1000)
|
|
167
|
+
worker_id: Optional[str] = None
|
|
168
|
+
worker_name: Optional[str] = None
|
|
169
|
+
extra_filter: Optional[Dict[str, Any]] = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Worker(BaseApiModel):
|
|
173
|
+
worker_id: str = Field(alias="_id")
|
|
174
|
+
queue_id: str
|
|
175
|
+
status: str
|
|
176
|
+
worker_name: Optional[str] = Field(
|
|
177
|
+
None, pattern=r"^[a-zA-Z0-9_-]+$", min_length=1, max_length=100
|
|
178
|
+
)
|
|
179
|
+
metadata: Dict
|
|
180
|
+
retries: int
|
|
181
|
+
max_retries: int
|
|
182
|
+
created_at: datetime
|
|
183
|
+
last_modified: datetime
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class WorkerLsResponse(BaseApiModel):
|
|
187
|
+
found: bool = False
|
|
188
|
+
content: List[Worker] = Field(default_factory=list)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class QueueUpdateRequest(BaseApiModel):
|
|
192
|
+
new_queue_name: Optional[str] = Field(
|
|
193
|
+
None, pattern=r"^[a-zA-Z0-9_-]+$", min_length=1, max_length=100
|
|
194
|
+
)
|
|
195
|
+
new_password: Optional[SecretStr] = Field(None, min_length=1, max_length=100)
|
|
196
|
+
metadata_update: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
|
197
|
+
|
|
198
|
+
def to_request_dict(self):
|
|
199
|
+
"""
|
|
200
|
+
Used to form a quest, since password must be revealed
|
|
201
|
+
"""
|
|
202
|
+
result = self.model_dump()
|
|
203
|
+
if self.new_password:
|
|
204
|
+
result.update({"new_password": self.new_password.get_secret_value()})
|
|
205
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# 1 level commands
|
|
2
|
+
import labtasker.client.cli.config
|
|
3
|
+
import labtasker.client.cli.loop
|
|
4
|
+
|
|
5
|
+
# multi level commands
|
|
6
|
+
import labtasker.client.cli.queue as queue
|
|
7
|
+
import labtasker.client.cli.task as task
|
|
8
|
+
import labtasker.client.cli.worker as worker
|
|
9
|
+
from labtasker.client.cli.cli import app
|
|
10
|
+
from labtasker.client.core.config import get_client_config
|
|
11
|
+
from labtasker.client.core.logging import stderr_console
|
|
12
|
+
from labtasker.client.core.paths import get_labtasker_client_config_path
|
|
13
|
+
from labtasker.client.core.plugin_utils import load_plugins
|
|
14
|
+
|
|
15
|
+
app.add_typer(queue.app, name="queue", help=queue.__doc__)
|
|
16
|
+
app.add_typer(task.app, name="task", help=task.__doc__)
|
|
17
|
+
app.add_typer(worker.app, name="worker", help=task.__doc__)
|
|
18
|
+
|
|
19
|
+
if get_labtasker_client_config_path().exists():
|
|
20
|
+
load_plugins(group="labtasker.client.cli", config=get_client_config().cli_plugins)
|
|
21
|
+
else:
|
|
22
|
+
stderr_console.print(
|
|
23
|
+
f"[bold orange1]Warning:[/bold orange1] config file not found at {get_labtasker_client_config_path()}. "
|
|
24
|
+
f"[orange1]Skipped plugin loading.[/orange1] "
|
|
25
|
+
f"It is recommended to initialize config via running [orange1]`labtasker config`[/orange1] first."
|
|
26
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implements top level cli (mainly callbacks and setup)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
from typing_extensions import Annotated
|
|
10
|
+
|
|
11
|
+
from labtasker import __version__
|
|
12
|
+
from labtasker.client.core.api import health_check
|
|
13
|
+
from labtasker.client.core.config import requires_client_config
|
|
14
|
+
from labtasker.client.core.logging import stderr_console, stdout_console
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(pretty_exceptions_show_locals=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def version_callback(value: bool):
|
|
20
|
+
if value:
|
|
21
|
+
stdout_console.print(f"Labtasker Version: {__version__}")
|
|
22
|
+
raise typer.Exit()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback()
|
|
26
|
+
def callback(
|
|
27
|
+
version: Annotated[
|
|
28
|
+
Optional[bool],
|
|
29
|
+
typer.Option(
|
|
30
|
+
..., "--version", callback=version_callback, help="Print Labtasker version."
|
|
31
|
+
),
|
|
32
|
+
] = None,
|
|
33
|
+
): ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
@requires_client_config
|
|
38
|
+
def health():
|
|
39
|
+
"""Check server connection and server health."""
|
|
40
|
+
try:
|
|
41
|
+
stdout_console.print(health_check())
|
|
42
|
+
except (httpx.ConnectError, httpx.HTTPStatusError) as e:
|
|
43
|
+
stderr_console.print(e)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implements `labtasker config`
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from typing import IO, Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import pydantic
|
|
10
|
+
import tomlkit
|
|
11
|
+
import tomlkit.exceptions
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from labtasker.client.cli.cli import app
|
|
15
|
+
from labtasker.client.core.config import ClientConfig, init_labtasker_root
|
|
16
|
+
from labtasker.client.core.logging import stderr_console, stdout_console
|
|
17
|
+
from labtasker.client.core.paths import (
|
|
18
|
+
get_labtasker_client_config_path,
|
|
19
|
+
get_labtasker_root,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command()
|
|
24
|
+
def config(
|
|
25
|
+
editor: Optional[str] = typer.Option(
|
|
26
|
+
None,
|
|
27
|
+
help="Editor to use.",
|
|
28
|
+
),
|
|
29
|
+
):
|
|
30
|
+
"""Configure local client. Run `labtasker config` which opens the configuration file using system configured editor"""
|
|
31
|
+
# 0. Check if labtasker root exists, if not, init
|
|
32
|
+
if not get_labtasker_root().exists():
|
|
33
|
+
typer.confirm(
|
|
34
|
+
"Labtasker root directory not found. Initializing with default template?",
|
|
35
|
+
abort=True,
|
|
36
|
+
)
|
|
37
|
+
init_labtasker_root()
|
|
38
|
+
|
|
39
|
+
# 1. Open editor and edit configuration in a temp file
|
|
40
|
+
with tempfile.NamedTemporaryFile(
|
|
41
|
+
"w+b",
|
|
42
|
+
prefix="labtasker.tmp.",
|
|
43
|
+
suffix=".toml",
|
|
44
|
+
) as f: # type: IO[bytes]
|
|
45
|
+
|
|
46
|
+
# 1.1 Copy existing config if exists
|
|
47
|
+
if get_labtasker_client_config_path().exists():
|
|
48
|
+
with open(get_labtasker_client_config_path(), "rb") as f_existing:
|
|
49
|
+
f.write(f_existing.read())
|
|
50
|
+
|
|
51
|
+
# 1.2 Edit
|
|
52
|
+
while True:
|
|
53
|
+
try:
|
|
54
|
+
# a. Edit
|
|
55
|
+
f.seek(0)
|
|
56
|
+
click.edit(filename=f.name, editor=editor)
|
|
57
|
+
|
|
58
|
+
# b. Reload and validate
|
|
59
|
+
f.seek(0)
|
|
60
|
+
ClientConfig.model_validate(tomlkit.load(f))
|
|
61
|
+
|
|
62
|
+
f.seek(0)
|
|
63
|
+
updated_content = f.read()
|
|
64
|
+
|
|
65
|
+
break
|
|
66
|
+
except (
|
|
67
|
+
tomlkit.exceptions.ParseError,
|
|
68
|
+
pydantic.ValidationError,
|
|
69
|
+
) as e:
|
|
70
|
+
stderr_console.print(
|
|
71
|
+
"[bold red]Error:[/bold red] error when parsing config.\n"
|
|
72
|
+
f"Detail: {str(e)}"
|
|
73
|
+
)
|
|
74
|
+
typer.confirm("Continue to edit?", abort=True)
|
|
75
|
+
|
|
76
|
+
# 2. Save
|
|
77
|
+
with open(get_labtasker_client_config_path(), "wb") as f_existing:
|
|
78
|
+
f_existing.write(updated_content)
|
|
79
|
+
|
|
80
|
+
stdout_console.print("[bold green]Configuration updated successfully.[/bold green]")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implements `labtasker loop xxx`
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
import labtasker
|
|
13
|
+
import labtasker.client.core.context
|
|
14
|
+
from labtasker.client.cli.cli import app
|
|
15
|
+
from labtasker.client.core.cli_utils import (
|
|
16
|
+
cli_utils_decorator,
|
|
17
|
+
eta_max_validation,
|
|
18
|
+
parse_metadata,
|
|
19
|
+
)
|
|
20
|
+
from labtasker.client.core.cmd_parser import cmd_interpolate
|
|
21
|
+
from labtasker.client.core.config import get_client_config
|
|
22
|
+
from labtasker.client.core.exceptions import CmdParserError
|
|
23
|
+
from labtasker.client.core.job_runner import finish
|
|
24
|
+
from labtasker.client.core.job_runner import loop as loop_run
|
|
25
|
+
from labtasker.client.core.logging import logger, stderr_console, stdout_console
|
|
26
|
+
from labtasker.utils import keys_to_query_dict
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InfiniteDefaultDict(defaultdict):
|
|
30
|
+
|
|
31
|
+
def __getitem__(self, key):
|
|
32
|
+
if key not in self:
|
|
33
|
+
self[key] = InfiniteDefaultDict()
|
|
34
|
+
return super().__getitem__(key)
|
|
35
|
+
|
|
36
|
+
def get(self, key, default=None):
|
|
37
|
+
if key not in self:
|
|
38
|
+
self[key] = InfiniteDefaultDict()
|
|
39
|
+
return super().get(key, default)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
@cli_utils_decorator
|
|
44
|
+
def loop(
|
|
45
|
+
cmd: str = typer.Option(
|
|
46
|
+
...,
|
|
47
|
+
"--cmd",
|
|
48
|
+
"-c",
|
|
49
|
+
help="Command to run. Support argument auto interpolation, formatted like %(arg1).",
|
|
50
|
+
),
|
|
51
|
+
extra_filter: Optional[str] = typer.Option(
|
|
52
|
+
None,
|
|
53
|
+
help='Optional mongodb filter as a dict string (e.g., \'{"key": "value"}\').',
|
|
54
|
+
),
|
|
55
|
+
worker_id: Optional[str] = typer.Option(
|
|
56
|
+
None,
|
|
57
|
+
help="Worker ID to run the command under.",
|
|
58
|
+
),
|
|
59
|
+
eta_max: Optional[str] = typer.Option(
|
|
60
|
+
None,
|
|
61
|
+
callback=eta_max_validation,
|
|
62
|
+
help="Maximum ETA for the task. (e.g. '1h', '1h30m', '50s')",
|
|
63
|
+
),
|
|
64
|
+
heartbeat_timeout: Optional[float] = typer.Option(
|
|
65
|
+
None,
|
|
66
|
+
help="Heartbeat timeout for the task in seconds.",
|
|
67
|
+
),
|
|
68
|
+
):
|
|
69
|
+
"""Run the wrapped job command in loop.
|
|
70
|
+
Job command follows a template string syntax: e.g. `python main.py --arg1 %(arg1) --arg2 %(arg2)`.
|
|
71
|
+
The argument inside %(...) will be autofilled by the task args fetched from task queue.
|
|
72
|
+
"""
|
|
73
|
+
extra_filter = parse_metadata(extra_filter)
|
|
74
|
+
|
|
75
|
+
if heartbeat_timeout is None:
|
|
76
|
+
heartbeat_timeout = get_client_config().task.heartbeat_interval * 3
|
|
77
|
+
|
|
78
|
+
# Generate required fields dict
|
|
79
|
+
dummy_variable_table = InfiniteDefaultDict()
|
|
80
|
+
try:
|
|
81
|
+
_, queried_keys = cmd_interpolate(cmd, dummy_variable_table)
|
|
82
|
+
except (CmdParserError, KeyError, TypeError) as e:
|
|
83
|
+
raise typer.BadParameter(f"Command error with exception {e}")
|
|
84
|
+
|
|
85
|
+
required_fields = keys_to_query_dict(list(queried_keys))
|
|
86
|
+
|
|
87
|
+
logger.info(f"Got command: {cmd}")
|
|
88
|
+
|
|
89
|
+
@loop_run(
|
|
90
|
+
required_fields=required_fields,
|
|
91
|
+
extra_filter=extra_filter,
|
|
92
|
+
worker_id=worker_id,
|
|
93
|
+
eta_max=eta_max,
|
|
94
|
+
heartbeat_timeout=heartbeat_timeout,
|
|
95
|
+
pass_args_dict=True,
|
|
96
|
+
)
|
|
97
|
+
def run_cmd(args):
|
|
98
|
+
# Interpolate command
|
|
99
|
+
interpolated_cmd, _ = cmd_interpolate(cmd, args)
|
|
100
|
+
logger.info(f"Prepared to run interpolated command: {interpolated_cmd}")
|
|
101
|
+
|
|
102
|
+
with subprocess.Popen(
|
|
103
|
+
shlex.split(interpolated_cmd),
|
|
104
|
+
stdout=subprocess.PIPE,
|
|
105
|
+
stderr=subprocess.PIPE,
|
|
106
|
+
text=True,
|
|
107
|
+
) as process:
|
|
108
|
+
while True:
|
|
109
|
+
output = process.stdout.readline()
|
|
110
|
+
error = process.stderr.readline()
|
|
111
|
+
|
|
112
|
+
if output:
|
|
113
|
+
stdout_console.print(output.strip())
|
|
114
|
+
if error:
|
|
115
|
+
stderr_console.print(error.strip())
|
|
116
|
+
|
|
117
|
+
# Break loop when process completes and streams are empty
|
|
118
|
+
if process.poll() is not None and not output and not error:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
process.wait()
|
|
122
|
+
if process.returncode != 0:
|
|
123
|
+
finish("failed")
|
|
124
|
+
else:
|
|
125
|
+
finish("success")
|
|
126
|
+
|
|
127
|
+
logger.info(f"Task {labtasker.client.core.context.task_info().task_id} ended.")
|
|
128
|
+
|
|
129
|
+
run_cmd()
|
|
130
|
+
|
|
131
|
+
logger.info("Loop finished.")
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task queue related CRUD operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from typing_extensions import Annotated
|
|
9
|
+
|
|
10
|
+
from labtasker.client.core.api import (
|
|
11
|
+
create_queue,
|
|
12
|
+
delete_queue,
|
|
13
|
+
get_queue,
|
|
14
|
+
update_queue,
|
|
15
|
+
)
|
|
16
|
+
from labtasker.client.core.cli_utils import cli_utils_decorator, parse_metadata
|
|
17
|
+
from labtasker.client.core.config import get_client_config
|
|
18
|
+
from labtasker.client.core.logging import stdout_console
|
|
19
|
+
|
|
20
|
+
app = typer.Typer()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command()
|
|
24
|
+
@cli_utils_decorator
|
|
25
|
+
def create(
|
|
26
|
+
queue_name: Annotated[
|
|
27
|
+
str,
|
|
28
|
+
typer.Option(
|
|
29
|
+
prompt=True,
|
|
30
|
+
envvar="QUEUE_NAME",
|
|
31
|
+
help="Queue name for current experiment.",
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
password: Annotated[
|
|
35
|
+
str,
|
|
36
|
+
typer.Option(
|
|
37
|
+
prompt=True,
|
|
38
|
+
confirmation_prompt=True,
|
|
39
|
+
hide_input=True,
|
|
40
|
+
envvar="PASSWORD",
|
|
41
|
+
help="Password for current queue.",
|
|
42
|
+
),
|
|
43
|
+
],
|
|
44
|
+
metadata: Optional[str] = typer.Option(
|
|
45
|
+
None,
|
|
46
|
+
help='Optional metadata as a python dict string (e.g., \'{"key": "value"}\').',
|
|
47
|
+
),
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Create a queue.
|
|
51
|
+
"""
|
|
52
|
+
metadata = parse_metadata(metadata)
|
|
53
|
+
stdout_console.print(
|
|
54
|
+
create_queue(
|
|
55
|
+
queue_name=queue_name,
|
|
56
|
+
password=password,
|
|
57
|
+
metadata=metadata,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
@cli_utils_decorator
|
|
64
|
+
def create_from_config(
|
|
65
|
+
metadata: Optional[str] = typer.Option(
|
|
66
|
+
None,
|
|
67
|
+
help='Optional metadata as a python dict string (e.g., \'{"key": "value"}\').',
|
|
68
|
+
)
|
|
69
|
+
):
|
|
70
|
+
"""
|
|
71
|
+
Create a queue from config in `.labtasker/client.toml`.
|
|
72
|
+
"""
|
|
73
|
+
metadata = parse_metadata(metadata)
|
|
74
|
+
config = get_client_config()
|
|
75
|
+
stdout_console.print(
|
|
76
|
+
create_queue(
|
|
77
|
+
queue_name=config.queue.queue_name,
|
|
78
|
+
password=config.queue.password.get_secret_value(),
|
|
79
|
+
metadata=metadata,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
@cli_utils_decorator
|
|
86
|
+
def get():
|
|
87
|
+
"""Get current queue info."""
|
|
88
|
+
stdout_console.print(get_queue())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
@cli_utils_decorator
|
|
93
|
+
def update(
|
|
94
|
+
new_queue_name: Optional[str] = typer.Option(
|
|
95
|
+
None,
|
|
96
|
+
help="New name for the queue.",
|
|
97
|
+
),
|
|
98
|
+
new_password: Optional[str] = typer.Option(
|
|
99
|
+
None,
|
|
100
|
+
prompt=True,
|
|
101
|
+
confirmation_prompt=True,
|
|
102
|
+
hide_input=True,
|
|
103
|
+
prompt_required=False, # only trigger interactive prompt when `--new-password` is provided
|
|
104
|
+
help="New password for the queue.",
|
|
105
|
+
),
|
|
106
|
+
metadata: Optional[str] = typer.Option(
|
|
107
|
+
None,
|
|
108
|
+
help='Optional metadata update as a python dict string (e.g., \'{"key": "value"}\').',
|
|
109
|
+
),
|
|
110
|
+
):
|
|
111
|
+
"""
|
|
112
|
+
Update the current queue.
|
|
113
|
+
If you do not wish to expose password in command (e.g. `labtasker queue update --new-password my-pass --new-queue-name my-name`),
|
|
114
|
+
omit the content of `--new-password` and an interactive prompt will show up (i.e. labtasker queue update --new-password --new-queue-name my-name).
|
|
115
|
+
"""
|
|
116
|
+
# Parse metadata
|
|
117
|
+
parsed_metadata = parse_metadata(metadata)
|
|
118
|
+
|
|
119
|
+
# Proceed with the update logic
|
|
120
|
+
stdout_console.print(
|
|
121
|
+
f"Updating queue with:\n"
|
|
122
|
+
f" New Queue Name: {new_queue_name or 'No change'}\n"
|
|
123
|
+
f" New Password: {'******' if new_password else 'No change'}\n"
|
|
124
|
+
f" Metadata: {parsed_metadata or 'No change'}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
updated_queue = update_queue(
|
|
128
|
+
new_queue_name=new_queue_name,
|
|
129
|
+
new_password=new_password,
|
|
130
|
+
metadata_update=parsed_metadata,
|
|
131
|
+
)
|
|
132
|
+
stdout_console.print(updated_queue)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command()
|
|
136
|
+
@cli_utils_decorator
|
|
137
|
+
def delete(
|
|
138
|
+
cascade: bool = typer.Option(False, help="Delete all tasks in the queue."),
|
|
139
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm the operation."),
|
|
140
|
+
):
|
|
141
|
+
"""Delete current queue."""
|
|
142
|
+
if not yes:
|
|
143
|
+
typer.confirm(
|
|
144
|
+
f"Are you sure you want to delete current queue '{get_queue().queue_name}' with cascade={cascade}?",
|
|
145
|
+
abort=True,
|
|
146
|
+
)
|
|
147
|
+
delete_queue(cascade_delete=cascade)
|
|
148
|
+
stdout_console.print("Queue deleted.")
|