datatorch 0.7.4__tar.gz → 0.7.7__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.
- {datatorch-0.7.4 → datatorch-0.7.7}/PKG-INFO +1 -1
- datatorch-0.7.7/datatorch/cli/action/create.py +129 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/client.py +49 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/daemon.py +59 -0
- datatorch-0.7.7/datatorch/runner/directory.py +290 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/monitoring.py +34 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/action/__init__.py +10 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/action/config.py +8 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/PKG-INFO +1 -1
- {datatorch-0.7.4 → datatorch-0.7.7}/setup.py +1 -1
- datatorch-0.7.4/datatorch/cli/action/create.py +0 -49
- datatorch-0.7.4/datatorch/runner/directory.py +0 -148
- {datatorch-0.7.4 → datatorch-0.7.7}/README.md +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/agent/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/api.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/client.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/annotation.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/base.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/dataset.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/file.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/label.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/project.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/settings.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/image/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/image/bounding_box.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/image/segmentations.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/image/typings.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/source.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/storage_link.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/user.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/pull.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/import_coco.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/import_pixmask.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/utils/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/utils/simplify.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/utils.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/where.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/action/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/action/pull.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/action/run.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/groups.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/import_cmds/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/import_cmds/coco.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/import_cmds/pixmask.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/login.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/logout.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/pull.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/upgrade.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/version.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/pipeline/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/pipeline/generate.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/pipeline/run.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/pipeline/upload.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/runner/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/runner/create.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/runner/dir.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/runner/start.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/spinner.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/upload/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/upload/folder.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/utils.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/core/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/core/env.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/core/folder.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/core/settings.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/log_handler.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/logging.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/action/action.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/action/cache.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/job/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/job/job.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/pipeline.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/resolver.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/command.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/docker.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/factory.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/node.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/python.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/runner.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/shell.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/step/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/step/step.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/template.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/services/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/utils/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/utils/docker.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/utils/process.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/__init__.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/converters.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/files.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/hash_table.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/objects.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/package.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/string_style.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/url.py +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/SOURCES.txt +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/dependency_links.txt +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/entry_points.txt +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/not-zip-safe +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/requires.txt +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/top_level.txt +0 -0
- {datatorch-0.7.4 → datatorch-0.7.7}/setup.cfg +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import click
|
|
3
|
+
import yaml
|
|
4
|
+
import json as jsonlib
|
|
5
|
+
|
|
6
|
+
# What a local action's run.py starts as: the image comes in, a result
|
|
7
|
+
# goes out, and the model goes in the middle.
|
|
8
|
+
MODEL_SCRIPT = """import datatorch
|
|
9
|
+
|
|
10
|
+
image_path = datatorch.get_input("imagePath")
|
|
11
|
+
|
|
12
|
+
# Load your model and run it on image_path here. Anything your model
|
|
13
|
+
# needs must be installed in the Python this runner runs with.
|
|
14
|
+
result = "ok"
|
|
15
|
+
|
|
16
|
+
datatorch.set_output("result", result)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command(
|
|
21
|
+
help="Creates a basic hello world action in the current folder, or, with "
|
|
22
|
+
"--local, in this runner's own actions folder so a pipeline can run it as "
|
|
23
|
+
"local/<name>@<tag> without putting the code anywhere else."
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"-l",
|
|
27
|
+
"--local",
|
|
28
|
+
"local_name",
|
|
29
|
+
type=str,
|
|
30
|
+
default=None,
|
|
31
|
+
metavar="NAME",
|
|
32
|
+
help="Create it under the runner's actions/local/NAME/TAG folder (lowercase, "
|
|
33
|
+
"digits, - and _). The runner reports it to DataTorch within a minute.",
|
|
34
|
+
)
|
|
35
|
+
@click.option(
|
|
36
|
+
"-t",
|
|
37
|
+
"--tag",
|
|
38
|
+
type=str,
|
|
39
|
+
default="v1",
|
|
40
|
+
show_default=True,
|
|
41
|
+
help="With --local: the tag folder. Pipelines pin it as local/NAME@TAG.",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"-n",
|
|
45
|
+
"--name",
|
|
46
|
+
type=str,
|
|
47
|
+
default="Hello World",
|
|
48
|
+
help="Name of action to be created.",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"-d",
|
|
52
|
+
"--description",
|
|
53
|
+
type=str,
|
|
54
|
+
default="Action created using CI template.",
|
|
55
|
+
help="Description of action to be created.",
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--json",
|
|
59
|
+
is_flag=True,
|
|
60
|
+
default=False,
|
|
61
|
+
help="Use JSON action instead of YAML. JSON format supports schemas for better typing.",
|
|
62
|
+
)
|
|
63
|
+
def create(name, description, json, local_name, tag):
|
|
64
|
+
target = "."
|
|
65
|
+
if local_name is not None:
|
|
66
|
+
from datatorch.runner.directory import RunnerDirectory, runner_directory
|
|
67
|
+
|
|
68
|
+
if not RunnerDirectory.LOCAL_ACTION_NAME.match(local_name):
|
|
69
|
+
raise click.BadParameter(
|
|
70
|
+
"use lowercase letters, digits, - and _", param_hint="--local"
|
|
71
|
+
)
|
|
72
|
+
if not RunnerDirectory.LOCAL_ACTION_TAG.match(tag):
|
|
73
|
+
raise click.BadParameter("not a tag folder name", param_hint="--tag")
|
|
74
|
+
target = os.path.join(runner_directory.local_actions_dir, local_name, tag)
|
|
75
|
+
os.makedirs(target, exist_ok=True)
|
|
76
|
+
if name == "Hello World":
|
|
77
|
+
name = local_name
|
|
78
|
+
if local_name is not None:
|
|
79
|
+
# A local action is almost always someone's own model: start it
|
|
80
|
+
# as one. The runner hands over the image's path; run.py returns
|
|
81
|
+
# a result. Mirrors the in-browser editor's scaffold.
|
|
82
|
+
config = {
|
|
83
|
+
"$schema": "http://datatorch.io/schema/action.v1.json",
|
|
84
|
+
"name": name,
|
|
85
|
+
"description": (
|
|
86
|
+
description
|
|
87
|
+
if description != "Action created using CI template."
|
|
88
|
+
else "Runs my model on one image."
|
|
89
|
+
),
|
|
90
|
+
"inputs": {
|
|
91
|
+
"imagePath": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"required": True,
|
|
94
|
+
"description": "Path of the image on disk",
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"outputs": {"result": {"type": "string"}},
|
|
98
|
+
"runs": {"using": "python", "main": "run.py"},
|
|
99
|
+
}
|
|
100
|
+
with open(os.path.join(target, "run.py"), "w") as script:
|
|
101
|
+
script.write(MODEL_SCRIPT)
|
|
102
|
+
else:
|
|
103
|
+
config = {
|
|
104
|
+
"$schema": "http://datatorch.io/schema/action.v1.json",
|
|
105
|
+
"name": name,
|
|
106
|
+
"description": description,
|
|
107
|
+
"inputs": {
|
|
108
|
+
"example": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"description": "Input string that will get printed to console.",
|
|
111
|
+
"default": "Default Example",
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"outputs": {},
|
|
115
|
+
# Inputs reach shell commands as $INPUT_<NAME> environment variables;
|
|
116
|
+
# interpolating ${{ input.* }} into a command raises InputInjectionError.
|
|
117
|
+
"runs": {"using": "cmd", "command": 'echo "$INPUT_EXAMPLE"'},
|
|
118
|
+
}
|
|
119
|
+
if json:
|
|
120
|
+
with open(os.path.join(target, "action-datatorch.json"), "w") as outfile:
|
|
121
|
+
jsonlib.dump(config, outfile, indent=2, sort_keys=True)
|
|
122
|
+
else:
|
|
123
|
+
with open(os.path.join(target, "action-datatorch.yaml"), "w") as outfile:
|
|
124
|
+
yaml.dump(config, outfile, default_flow_style=False)
|
|
125
|
+
if local_name is not None:
|
|
126
|
+
click.echo(f"Created {os.path.abspath(target)}")
|
|
127
|
+
click.echo(
|
|
128
|
+
f"Reference it from a pipeline step as: action: local/{local_name}@{tag}"
|
|
129
|
+
)
|
|
@@ -160,6 +160,36 @@ class AgentApiClient(object):
|
|
|
160
160
|
result = await self.execute(mutate, params={"id": step_id, "input": step_input})
|
|
161
161
|
return bool(result.get("completed"))
|
|
162
162
|
|
|
163
|
+
def runner_commands(self):
|
|
164
|
+
"""Subscribe to requests the server makes of this runner: read or
|
|
165
|
+
write one of its own `actions/local/<name>/<tag>` folders (October
|
|
166
|
+
2026). Answered with `complete_runner_command`."""
|
|
167
|
+
# fmt: off
|
|
168
|
+
sub = gql("""
|
|
169
|
+
subscription {
|
|
170
|
+
command: runnerCommands {
|
|
171
|
+
id
|
|
172
|
+
runnerId
|
|
173
|
+
kind
|
|
174
|
+
payload
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
""")
|
|
178
|
+
# fmt: on
|
|
179
|
+
return self.session.subscribe(sub)
|
|
180
|
+
|
|
181
|
+
def complete_runner_command(self, command_id: str, result=None, error: str = None):
|
|
182
|
+
# fmt: off
|
|
183
|
+
mutate = """
|
|
184
|
+
mutation completeRunnerCommand($id: ID!, $result: JSON, $error: String) {
|
|
185
|
+
completeRunnerCommand(id: $id, result: $result, error: $error)
|
|
186
|
+
}
|
|
187
|
+
"""
|
|
188
|
+
# fmt: on
|
|
189
|
+
return self.execute(
|
|
190
|
+
gql(mutate), params={"id": command_id, "result": result, "error": error}
|
|
191
|
+
)
|
|
192
|
+
|
|
163
193
|
def initial_metrics(self, metrics):
|
|
164
194
|
# fmt: off
|
|
165
195
|
mutate = """
|
|
@@ -198,6 +228,25 @@ class AgentApiClient(object):
|
|
|
198
228
|
params = {"id": runner_directory.settings.runner_id, **metrics}
|
|
199
229
|
return self.execute(gql(mutate), params=params)
|
|
200
230
|
|
|
231
|
+
def report_local_actions(self, actions):
|
|
232
|
+
"""Tells the server which `actions/local/<name>/<tag>` folders this
|
|
233
|
+
runner holds (their action yaml text). The whole list each time: a
|
|
234
|
+
folder no longer listed is gone from the server too."""
|
|
235
|
+
# fmt: off
|
|
236
|
+
mutate = """
|
|
237
|
+
mutation reportRunnerLocalActions(
|
|
238
|
+
$id: ID!
|
|
239
|
+
$localActions: [RunnerLocalActionInput!]!
|
|
240
|
+
) {
|
|
241
|
+
updateRunner(id: $id, input: { localActions: $localActions }) {
|
|
242
|
+
id
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
"""
|
|
246
|
+
# fmt: on
|
|
247
|
+
params = {"id": runner_directory.settings.runner_id, "localActions": actions}
|
|
248
|
+
return self.execute(gql(mutate), params=params)
|
|
249
|
+
|
|
201
250
|
def metrics(self, metrics):
|
|
202
251
|
# fmt: off
|
|
203
252
|
mutate = """
|
|
@@ -72,11 +72,70 @@ class RunnerDaemon(object):
|
|
|
72
72
|
logger.info("Waiting for steps.")
|
|
73
73
|
cancel_task = loop.create_task(self._cancel_loop())
|
|
74
74
|
tasks.append(cancel_task)
|
|
75
|
+
command_task = loop.create_task(self._command_loop())
|
|
76
|
+
tasks.append(command_task)
|
|
75
77
|
try:
|
|
76
78
|
async for request in self.api.agent_steps():
|
|
77
79
|
self._start_step(request.get("step"))
|
|
78
80
|
finally:
|
|
79
81
|
cancel_task.cancel()
|
|
82
|
+
command_task.cancel()
|
|
83
|
+
|
|
84
|
+
async def _command_loop(self):
|
|
85
|
+
"""Answers the server's requests about this runner's own action
|
|
86
|
+
folders (read or write `actions/local/<name>/<tag>`). Nothing
|
|
87
|
+
outside those folders is ever read or written: the directory
|
|
88
|
+
refuses any other path. A failure is the answer's error, never a
|
|
89
|
+
crash of the loop."""
|
|
90
|
+
try:
|
|
91
|
+
async for request in self.api.runner_commands():
|
|
92
|
+
command = request.get("command") or {}
|
|
93
|
+
loop = asyncio.get_running_loop()
|
|
94
|
+
loop.create_task(self._answer_command(command))
|
|
95
|
+
except asyncio.CancelledError:
|
|
96
|
+
raise
|
|
97
|
+
except Exception as ex:
|
|
98
|
+
# A server from before runner commands has no such subscription;
|
|
99
|
+
# steps still flow, only editing from DataTorch is off.
|
|
100
|
+
logger.warning(f"Runner commands are not available: {ex}")
|
|
101
|
+
|
|
102
|
+
async def _answer_command(self, command: dict):
|
|
103
|
+
command_id = command.get("id")
|
|
104
|
+
kind = command.get("kind")
|
|
105
|
+
payload = command.get("payload") or {}
|
|
106
|
+
result, error = None, None
|
|
107
|
+
try:
|
|
108
|
+
name, tag = payload.get("name"), payload.get("tag")
|
|
109
|
+
if kind == "READ_LOCAL_ACTION":
|
|
110
|
+
result = {"files": runner_directory.local_action_files(name, tag)}
|
|
111
|
+
elif kind == "WRITE_LOCAL_ACTION":
|
|
112
|
+
files = runner_directory.write_local_action_files(
|
|
113
|
+
name, tag, payload.get("files") or [], payload.get("delete") or []
|
|
114
|
+
)
|
|
115
|
+
result = {"files": files}
|
|
116
|
+
logger.info(f"Wrote actions/local/{name}/{tag} on request.")
|
|
117
|
+
# Report the folder's yaml now, so the picker lists it at once.
|
|
118
|
+
try:
|
|
119
|
+
await self.api.report_local_actions(
|
|
120
|
+
runner_directory.local_actions()
|
|
121
|
+
)
|
|
122
|
+
except Exception as ex:
|
|
123
|
+
logger.warning(
|
|
124
|
+
f"Could not report local actions after a write: {ex}"
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
error = f"Unknown runner command '{kind}'."
|
|
128
|
+
except (ValueError, FileNotFoundError, OSError) as ex:
|
|
129
|
+
error = str(ex)
|
|
130
|
+
except Exception as ex: # the loop must survive anything
|
|
131
|
+
logger.exception("Runner command failed.")
|
|
132
|
+
error = f"{type(ex).__name__}: {ex}"
|
|
133
|
+
try:
|
|
134
|
+
await self.api.complete_runner_command(
|
|
135
|
+
command_id, result=result, error=error
|
|
136
|
+
)
|
|
137
|
+
except Exception as ex:
|
|
138
|
+
logger.warning(f"Could not answer runner command {command_id}: {ex}")
|
|
80
139
|
|
|
81
140
|
async def _cancel_loop(self):
|
|
82
141
|
"""Aborts a running step's task when the server signals a stop."""
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import List, Union
|
|
4
|
+
from datatorch.utils.files import mkdir_exists
|
|
5
|
+
from datatorch.core import Settings, folder, env, user_settings
|
|
6
|
+
|
|
7
|
+
# The server refuses a longer action file (MAX_LOCAL_ACTION_TEXT there).
|
|
8
|
+
MAX_LOCAL_ACTION_TEXT = 64 * 1024
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RunnerDirectory(object):
|
|
12
|
+
@staticmethod
|
|
13
|
+
def path() -> str:
|
|
14
|
+
"""The runner's working directory (actions cache, runs, logs, settings).
|
|
15
|
+
|
|
16
|
+
`DATATORCH_RUNNER_PATH` wins, then the pre-rename `DATATORCH_AGENT_PATH`,
|
|
17
|
+
then `<app dir>/runner`. An install that still has `<app dir>/agent`
|
|
18
|
+
from before the rename is moved to `runner` once, so a runner keeps
|
|
19
|
+
its identity, token and cached actions across the upgrade.
|
|
20
|
+
"""
|
|
21
|
+
explicit = os.getenv("DATATORCH_RUNNER_PATH") or os.getenv(
|
|
22
|
+
"DATATORCH_AGENT_PATH"
|
|
23
|
+
)
|
|
24
|
+
if explicit:
|
|
25
|
+
return explicit
|
|
26
|
+
app = folder.get_app_dir()
|
|
27
|
+
new = os.path.join(app, "runner")
|
|
28
|
+
old = os.path.join(app, "agent")
|
|
29
|
+
if not os.path.exists(new) and os.path.isdir(old):
|
|
30
|
+
try:
|
|
31
|
+
os.rename(old, new)
|
|
32
|
+
except OSError:
|
|
33
|
+
return old
|
|
34
|
+
return new
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.settings = RunnerSettings()
|
|
38
|
+
|
|
39
|
+
mkdir_exists(self.dir)
|
|
40
|
+
mkdir_exists(self.db_dir)
|
|
41
|
+
mkdir_exists(self.logs_dir)
|
|
42
|
+
mkdir_exists(self.temp_dir)
|
|
43
|
+
mkdir_exists(self.runs_dir)
|
|
44
|
+
mkdir_exists(self.actions_dir)
|
|
45
|
+
mkdir_exists(self.projects_dir)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def root(self):
|
|
49
|
+
return self.path()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def dir(self):
|
|
53
|
+
return self.path()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def runs_dir(self):
|
|
57
|
+
return os.path.join(self.dir, "runs")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def logs_dir(self):
|
|
61
|
+
"""Directory where agent logs are stored."""
|
|
62
|
+
return os.path.join(self.dir, "logs")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def db_dir(self):
|
|
66
|
+
"""Sqlite database are stored."""
|
|
67
|
+
return os.path.join(self.dir, "db")
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def projects_dir(self):
|
|
71
|
+
"""Directory where projects are stored.
|
|
72
|
+
|
|
73
|
+
Commonly used for caching project information such as annotations and
|
|
74
|
+
files.
|
|
75
|
+
"""
|
|
76
|
+
return os.path.join(self.dir, "projects")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def actions_dir(self):
|
|
80
|
+
"""Directory where actions are stored."""
|
|
81
|
+
return os.path.join(self.dir, "actions")
|
|
82
|
+
|
|
83
|
+
def open(self, file: str, mode: str):
|
|
84
|
+
return open(os.path.join(self.dir, file), mode)
|
|
85
|
+
|
|
86
|
+
def action_dir(self, name: str, version: str):
|
|
87
|
+
return os.path.join(self.actions_dir, *name.lower().split("/"), version)
|
|
88
|
+
|
|
89
|
+
# Folder name and tag the server accepts for a local action
|
|
90
|
+
# (services/api/src/modules/runners/localActions.ts).
|
|
91
|
+
LOCAL_ACTION_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
92
|
+
LOCAL_ACTION_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
93
|
+
ACTION_FILES = (
|
|
94
|
+
"action-datatorch.yaml",
|
|
95
|
+
"action-datatorch.yml",
|
|
96
|
+
"action-datatorch.json",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def local_actions_dir(self):
|
|
101
|
+
"""Where a person keeps their own actions: `actions/local/<name>/<tag>`.
|
|
102
|
+
|
|
103
|
+
A pipeline step references one as `local/<name>@<tag>`; the folder
|
|
104
|
+
is run as-is and never fetched from anywhere.
|
|
105
|
+
"""
|
|
106
|
+
return os.path.join(self.actions_dir, "local")
|
|
107
|
+
|
|
108
|
+
def local_actions(self) -> List[dict]:
|
|
109
|
+
"""Every `actions/local/<name>/<tag>` folder holding an action file,
|
|
110
|
+
as `{name, tag, text}` with the action file's text, sorted. This is
|
|
111
|
+
what the runner reports to the server so the pipeline editor can list
|
|
112
|
+
and wire the action: the yaml only, never the code."""
|
|
113
|
+
found = []
|
|
114
|
+
root = self.local_actions_dir
|
|
115
|
+
if not os.path.isdir(root):
|
|
116
|
+
return found
|
|
117
|
+
for name in sorted(os.listdir(root)):
|
|
118
|
+
name_dir = os.path.join(root, name)
|
|
119
|
+
if not os.path.isdir(name_dir) or not self.LOCAL_ACTION_NAME.match(name):
|
|
120
|
+
continue
|
|
121
|
+
for tag in sorted(os.listdir(name_dir)):
|
|
122
|
+
tag_dir = os.path.join(name_dir, tag)
|
|
123
|
+
if not os.path.isdir(tag_dir) or not self.LOCAL_ACTION_TAG.match(tag):
|
|
124
|
+
continue
|
|
125
|
+
for file in self.ACTION_FILES:
|
|
126
|
+
path = os.path.join(tag_dir, file)
|
|
127
|
+
if not os.path.isfile(path):
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
131
|
+
text = handle.read(MAX_LOCAL_ACTION_TEXT + 1)
|
|
132
|
+
except OSError:
|
|
133
|
+
continue
|
|
134
|
+
# A .json file is read by the server's yaml parser as-is.
|
|
135
|
+
found.append({"name": name, "tag": tag, "text": text})
|
|
136
|
+
break
|
|
137
|
+
return found
|
|
138
|
+
|
|
139
|
+
# What the editor reads from and writes into a folder (the server caps
|
|
140
|
+
# the same way; this side is the one that matters).
|
|
141
|
+
MAX_LOCAL_ACTION_FILES = 40
|
|
142
|
+
MAX_LOCAL_ACTION_FILE_TEXT = 256 * 1024
|
|
143
|
+
SKIPPED_DIRS = ("__pycache__", "node_modules", ".git", "venv", ".venv")
|
|
144
|
+
|
|
145
|
+
def local_action_dir(self, name: str, tag: str) -> str:
|
|
146
|
+
"""`actions/local/<name>/<tag>`, for a name and tag that are folder
|
|
147
|
+
names (anything else is refused, never joined)."""
|
|
148
|
+
if not self.LOCAL_ACTION_NAME.match(name or ""):
|
|
149
|
+
raise ValueError(f"'{name}' is not a local action folder name.")
|
|
150
|
+
if not self.LOCAL_ACTION_TAG.match(tag or ""):
|
|
151
|
+
raise ValueError(f"'{tag}' is not a tag folder name.")
|
|
152
|
+
return os.path.join(self.local_actions_dir, name, tag)
|
|
153
|
+
|
|
154
|
+
def _inside(self, folder: str, rel: str) -> str:
|
|
155
|
+
"""The absolute path of `rel` inside `folder`, or a ValueError: no
|
|
156
|
+
absolute paths, no `..`, no hidden segments, no symlink escapes."""
|
|
157
|
+
rel = (rel or "").replace("\\", "/").strip()
|
|
158
|
+
if not rel or rel.startswith("/"):
|
|
159
|
+
raise ValueError(f"'{rel}': use a relative path.")
|
|
160
|
+
for part in rel.split("/"):
|
|
161
|
+
if part in ("", ".", "..") or part.startswith("."):
|
|
162
|
+
raise ValueError(f"'{rel}': not a path inside the action folder.")
|
|
163
|
+
root = os.path.realpath(folder)
|
|
164
|
+
full = os.path.realpath(os.path.join(root, *rel.split("/")))
|
|
165
|
+
if full != root and not full.startswith(root + os.sep):
|
|
166
|
+
raise ValueError(f"'{rel}': not a path inside the action folder.")
|
|
167
|
+
return full
|
|
168
|
+
|
|
169
|
+
def local_action_files(self, name: str, tag: str) -> List[dict]:
|
|
170
|
+
"""The text files of one local action folder, as `{path, text, size}`,
|
|
171
|
+
sorted; binaries, hidden files, caches and big files are left out."""
|
|
172
|
+
folder = self.local_action_dir(name, tag)
|
|
173
|
+
if not os.path.isdir(folder):
|
|
174
|
+
raise FileNotFoundError(
|
|
175
|
+
f"No folder actions/local/{name}/{tag} on this runner."
|
|
176
|
+
)
|
|
177
|
+
found = []
|
|
178
|
+
for root, dirs, files in os.walk(folder):
|
|
179
|
+
dirs[:] = sorted(
|
|
180
|
+
d for d in dirs if not d.startswith(".") and d not in self.SKIPPED_DIRS
|
|
181
|
+
)
|
|
182
|
+
for file in sorted(files):
|
|
183
|
+
if file.startswith("."):
|
|
184
|
+
continue
|
|
185
|
+
full = os.path.join(root, file)
|
|
186
|
+
if os.path.islink(full):
|
|
187
|
+
continue
|
|
188
|
+
size = os.path.getsize(full)
|
|
189
|
+
if size > self.MAX_LOCAL_ACTION_FILE_TEXT:
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
with open(full, "r", encoding="utf-8") as handle:
|
|
193
|
+
text = handle.read()
|
|
194
|
+
except (OSError, UnicodeDecodeError):
|
|
195
|
+
continue
|
|
196
|
+
rel = os.path.relpath(full, folder).replace(os.sep, "/")
|
|
197
|
+
found.append({"path": rel, "text": text, "size": size})
|
|
198
|
+
if len(found) >= self.MAX_LOCAL_ACTION_FILES:
|
|
199
|
+
return found
|
|
200
|
+
return found
|
|
201
|
+
|
|
202
|
+
def write_local_action_files(
|
|
203
|
+
self, name: str, tag: str, files: List[dict], delete: List[str] = None
|
|
204
|
+
) -> List[dict]:
|
|
205
|
+
"""Writes `files` (`{path, text}`) into, and deletes `delete` from, one
|
|
206
|
+
local action folder, creating the folder when it is new. Every path
|
|
207
|
+
is checked to be inside the folder before anything is touched."""
|
|
208
|
+
folder = self.local_action_dir(name, tag)
|
|
209
|
+
writes = [
|
|
210
|
+
(self._inside(folder, f.get("path")), f.get("text") or "")
|
|
211
|
+
for f in files or []
|
|
212
|
+
]
|
|
213
|
+
removes = [self._inside(folder, p) for p in delete or []]
|
|
214
|
+
for _, text in writes:
|
|
215
|
+
if len(text) > self.MAX_LOCAL_ACTION_FILE_TEXT:
|
|
216
|
+
raise ValueError("A file is longer than the editor allows.")
|
|
217
|
+
mkdir_exists(folder)
|
|
218
|
+
for full, text in writes:
|
|
219
|
+
mkdir_exists(os.path.dirname(full))
|
|
220
|
+
with open(full, "w", encoding="utf-8") as handle:
|
|
221
|
+
handle.write(text)
|
|
222
|
+
for full in removes:
|
|
223
|
+
if os.path.isfile(full):
|
|
224
|
+
os.remove(full)
|
|
225
|
+
return self.local_action_files(name, tag)
|
|
226
|
+
|
|
227
|
+
def run_dir(self, task_id: str):
|
|
228
|
+
"""Returns the directory for a given task"""
|
|
229
|
+
path = os.path.join(self.runs_dir, task_id)
|
|
230
|
+
mkdir_exists(path)
|
|
231
|
+
return path
|
|
232
|
+
|
|
233
|
+
def project_dir(self, project_id: str):
|
|
234
|
+
"""Returns the directory for a given project"""
|
|
235
|
+
path = os.path.join(self.projects_dir, project_id)
|
|
236
|
+
mkdir_exists(path)
|
|
237
|
+
return path
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def temp_dir(self):
|
|
241
|
+
return os.path.join(self.dir, "temp")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class RunnerSettings(Settings):
|
|
245
|
+
"""The runner's own settings file. Keys are `runnerId` / `runnerToken`;
|
|
246
|
+
the pre-rename `agentId` / `agentToken` are still read, so an upgraded
|
|
247
|
+
runner keeps working until it is re-registered."""
|
|
248
|
+
|
|
249
|
+
def __init__(self):
|
|
250
|
+
super().__init__(RunnerDirectory.path())
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def runner_id(self):
|
|
254
|
+
return (
|
|
255
|
+
os.getenv(env.RUNNER_ID)
|
|
256
|
+
or self.get("runnerId", env=env.AGENT_ID)
|
|
257
|
+
or self.get("agentId")
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
@runner_id.setter
|
|
261
|
+
def runner_id(self, value):
|
|
262
|
+
self.set("runnerId", value)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def runner_token(self):
|
|
266
|
+
return self.get("runnerToken") or self.get("agentToken")
|
|
267
|
+
|
|
268
|
+
@runner_token.setter
|
|
269
|
+
def runner_token(self, value):
|
|
270
|
+
self.set("runnerToken", value)
|
|
271
|
+
|
|
272
|
+
# Pre-rename names, kept for callers outside this package.
|
|
273
|
+
agent_id = runner_id
|
|
274
|
+
agent_token = runner_token
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def api_url(self):
|
|
278
|
+
return self.get("apiUrl", user_settings.api_url)
|
|
279
|
+
|
|
280
|
+
@api_url.setter
|
|
281
|
+
def api_url(self, value):
|
|
282
|
+
self.set("apiUrl", value)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
runner_directory = RunnerDirectory()
|
|
286
|
+
|
|
287
|
+
# Pre-rename names, kept for callers outside this package.
|
|
288
|
+
AgentDirectory = RunnerDirectory
|
|
289
|
+
AgentSettings = RunnerSettings
|
|
290
|
+
agent_directory = runner_directory
|
|
@@ -2,10 +2,13 @@ import psutil
|
|
|
2
2
|
import logging
|
|
3
3
|
import platform
|
|
4
4
|
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
5
7
|
|
|
6
8
|
from psutil._common import scpufreq
|
|
7
9
|
from datetime import datetime, timezone
|
|
8
10
|
from datatorch.utils.package import get_version
|
|
11
|
+
from datatorch.runner.directory import runner_directory
|
|
9
12
|
from collections import namedtuple
|
|
10
13
|
|
|
11
14
|
logger = logging.getLogger(__name__)
|
|
@@ -78,12 +81,14 @@ class AgentSystemStats(object):
|
|
|
78
81
|
self.sample_rate = sample_rate
|
|
79
82
|
self.sample = 0
|
|
80
83
|
self.report_failures = 0
|
|
84
|
+
self.local_actions_digest = None
|
|
81
85
|
|
|
82
86
|
async def start(self):
|
|
83
87
|
try:
|
|
84
88
|
logger.info("Sending initial system metrics.")
|
|
85
89
|
logger.info(self.initial_stats())
|
|
86
90
|
await self.agent.api.initial_metrics(self.initial_stats())
|
|
91
|
+
await self._report_local_actions()
|
|
87
92
|
|
|
88
93
|
logger.info("Starting system monitoring task.")
|
|
89
94
|
await self._task_monitoring()
|
|
@@ -99,8 +104,37 @@ class AgentSystemStats(object):
|
|
|
99
104
|
stats = self.stats()
|
|
100
105
|
if self.sample != 1:
|
|
101
106
|
await self._report(stats)
|
|
107
|
+
# A folder dropped into actions/local shows up in the
|
|
108
|
+
# editor within a minute, no restart needed.
|
|
109
|
+
await self._report_local_actions()
|
|
102
110
|
await asyncio.sleep(self.sample_rate)
|
|
103
111
|
|
|
112
|
+
async def _report_local_actions(self):
|
|
113
|
+
"""Sends the actions/local folders when they changed since last sent.
|
|
114
|
+
A failure is logged and retried on the next sample; it never counts
|
|
115
|
+
toward the reconnect threshold (the metric report does that)."""
|
|
116
|
+
try:
|
|
117
|
+
actions = runner_directory.local_actions()
|
|
118
|
+
except Exception as ex: # a broken folder must not stop the runner
|
|
119
|
+
logger.warning(f"Could not read local actions: {ex}")
|
|
120
|
+
return
|
|
121
|
+
digest = hashlib.sha256(
|
|
122
|
+
json.dumps(actions, sort_keys=True).encode("utf-8")
|
|
123
|
+
).hexdigest()
|
|
124
|
+
if digest == self.local_actions_digest:
|
|
125
|
+
return
|
|
126
|
+
try:
|
|
127
|
+
await asyncio.wait_for(
|
|
128
|
+
self.agent.api.report_local_actions(actions),
|
|
129
|
+
timeout=self.REPORT_TIMEOUT,
|
|
130
|
+
)
|
|
131
|
+
self.local_actions_digest = digest
|
|
132
|
+
logger.info(
|
|
133
|
+
f"Reported {len(actions)} local action(s) from {runner_directory.local_actions_dir}."
|
|
134
|
+
)
|
|
135
|
+
except Exception as ex:
|
|
136
|
+
logger.warning(f"Could not report local actions: {ex}")
|
|
137
|
+
|
|
104
138
|
async def _report(self, stats):
|
|
105
139
|
"""Sends one metric report; repeated failures force a reconnect."""
|
|
106
140
|
try:
|
|
@@ -27,6 +27,16 @@ async def get_action(config: ActionConfig, step: "Step" = None) -> Action:
|
|
|
27
27
|
logger.debug(
|
|
28
28
|
"Action found locally ({}@{}).".format(config.name, config.version)
|
|
29
29
|
)
|
|
30
|
+
elif config.is_local:
|
|
31
|
+
# `local/<name>@<tag>` lives only in this runner's folder; there is
|
|
32
|
+
# nothing to download, so say where it was expected instead of
|
|
33
|
+
# failing a git clone of github.com/local/<name>.
|
|
34
|
+
raise FileNotFoundError(
|
|
35
|
+
f"Local action {config.full_name} is not on this runner: expected "
|
|
36
|
+
f"an action-datatorch.yaml in {action_dir}. Create one with "
|
|
37
|
+
f"`datatorch action create --local {config.name.split('/', 1)[1]} "
|
|
38
|
+
f"--tag {config.version}`, or pin the job to the runner that has it."
|
|
39
|
+
)
|
|
30
40
|
else:
|
|
31
41
|
logger.debug("Downloading action {}@{}.".format(config.name, config.version))
|
|
32
42
|
await config.download()
|
|
@@ -84,3 +84,11 @@ class ActionConfig(object):
|
|
|
84
84
|
@property
|
|
85
85
|
def full_name(self):
|
|
86
86
|
return f"{self.name}@{self.version}"
|
|
87
|
+
|
|
88
|
+
# Reserved owner for a folder kept on the runner itself:
|
|
89
|
+
# `local/<name>@<tag>` → `<runner dir>/actions/local/<name>/<tag>`.
|
|
90
|
+
LOCAL_OWNER = "local"
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_local(self) -> bool:
|
|
94
|
+
return self.name.lower().startswith(self.LOCAL_OWNER + "/")
|