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.
Files changed (112) hide show
  1. {datatorch-0.7.4 → datatorch-0.7.7}/PKG-INFO +1 -1
  2. datatorch-0.7.7/datatorch/cli/action/create.py +129 -0
  3. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/client.py +49 -0
  4. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/daemon.py +59 -0
  5. datatorch-0.7.7/datatorch/runner/directory.py +290 -0
  6. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/monitoring.py +34 -0
  7. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/action/__init__.py +10 -0
  8. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/action/config.py +8 -0
  9. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/PKG-INFO +1 -1
  10. {datatorch-0.7.4 → datatorch-0.7.7}/setup.py +1 -1
  11. datatorch-0.7.4/datatorch/cli/action/create.py +0 -49
  12. datatorch-0.7.4/datatorch/runner/directory.py +0 -148
  13. {datatorch-0.7.4 → datatorch-0.7.7}/README.md +0 -0
  14. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/__init__.py +0 -0
  15. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/agent/__init__.py +0 -0
  16. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/__init__.py +0 -0
  17. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/api.py +0 -0
  18. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/client.py +0 -0
  19. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/__init__.py +0 -0
  20. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/annotation.py +0 -0
  21. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/base.py +0 -0
  22. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/dataset.py +0 -0
  23. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/file.py +0 -0
  24. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/label.py +0 -0
  25. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/project.py +0 -0
  26. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/settings.py +0 -0
  27. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/__init__.py +0 -0
  28. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/image/__init__.py +0 -0
  29. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/image/bounding_box.py +0 -0
  30. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/image/segmentations.py +0 -0
  31. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/image/typings.py +0 -0
  32. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/sources/source.py +0 -0
  33. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/storage_link.py +0 -0
  34. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/entity/user.py +0 -0
  35. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/pull.py +0 -0
  36. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/__init__.py +0 -0
  37. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/import_coco.py +0 -0
  38. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/import_pixmask.py +0 -0
  39. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/utils/__init__.py +0 -0
  40. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/scripts/utils/simplify.py +0 -0
  41. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/utils.py +0 -0
  42. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/api/where.py +0 -0
  43. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/__init__.py +0 -0
  44. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/action/__init__.py +0 -0
  45. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/action/pull.py +0 -0
  46. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/action/run.py +0 -0
  47. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/groups.py +0 -0
  48. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/import_cmds/__init__.py +0 -0
  49. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/import_cmds/coco.py +0 -0
  50. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/import_cmds/pixmask.py +0 -0
  51. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/__init__.py +0 -0
  52. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/login.py +0 -0
  53. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/logout.py +0 -0
  54. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/pull.py +0 -0
  55. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/upgrade.py +0 -0
  56. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/main/version.py +0 -0
  57. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/pipeline/__init__.py +0 -0
  58. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/pipeline/generate.py +0 -0
  59. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/pipeline/run.py +0 -0
  60. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/pipeline/upload.py +0 -0
  61. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/runner/__init__.py +0 -0
  62. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/runner/create.py +0 -0
  63. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/runner/dir.py +0 -0
  64. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/runner/start.py +0 -0
  65. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/spinner.py +0 -0
  66. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/upload/__init__.py +0 -0
  67. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/upload/folder.py +0 -0
  68. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/cli/utils.py +0 -0
  69. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/core/__init__.py +0 -0
  70. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/core/env.py +0 -0
  71. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/core/folder.py +0 -0
  72. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/core/settings.py +0 -0
  73. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/__init__.py +0 -0
  74. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/log_handler.py +0 -0
  75. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/logging.py +0 -0
  76. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/__init__.py +0 -0
  77. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/action/action.py +0 -0
  78. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/action/cache.py +0 -0
  79. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/job/__init__.py +0 -0
  80. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/job/job.py +0 -0
  81. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/pipeline.py +0 -0
  82. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/resolver.py +0 -0
  83. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/__init__.py +0 -0
  84. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/command.py +0 -0
  85. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/docker.py +0 -0
  86. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/factory.py +0 -0
  87. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/node.py +0 -0
  88. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/python.py +0 -0
  89. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/runner.py +0 -0
  90. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/runner/shell.py +0 -0
  91. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/step/__init__.py +0 -0
  92. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/step/step.py +0 -0
  93. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/pipelines/template.py +0 -0
  94. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/services/__init__.py +0 -0
  95. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/utils/__init__.py +0 -0
  96. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/utils/docker.py +0 -0
  97. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/runner/utils/process.py +0 -0
  98. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/__init__.py +0 -0
  99. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/converters.py +0 -0
  100. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/files.py +0 -0
  101. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/hash_table.py +0 -0
  102. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/objects.py +0 -0
  103. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/package.py +0 -0
  104. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/string_style.py +0 -0
  105. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch/utils/url.py +0 -0
  106. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/SOURCES.txt +0 -0
  107. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/dependency_links.txt +0 -0
  108. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/entry_points.txt +0 -0
  109. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/not-zip-safe +0 -0
  110. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/requires.txt +0 -0
  111. {datatorch-0.7.4 → datatorch-0.7.7}/datatorch.egg-info/top_level.txt +0 -0
  112. {datatorch-0.7.4 → datatorch-0.7.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datatorch
3
- Version: 0.7.4
3
+ Version: 0.7.7
4
4
  Summary: A CLI and library for interacting with DataTorch.
5
5
  Home-page: https://github.com/datatorch/python
6
6
  Author: DataTorch
@@ -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 + "/")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datatorch
3
- Version: 0.7.4
3
+ Version: 0.7.7
4
4
  Summary: A CLI and library for interacting with DataTorch.
5
5
  Home-page: https://github.com/datatorch/python
6
6
  Author: DataTorch
@@ -33,7 +33,7 @@ requirements = [
33
33
 
34
34
  setup(
35
35
  name="datatorch",
36
- version="0.7.4",
36
+ version="0.7.7",
37
37
  description="A CLI and library for interacting with DataTorch.",
38
38
  author="DataTorch",
39
39
  author_email="support@datatorch.io",