datatorch 0.4.9.2__tar.gz → 0.6.2__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 (113) hide show
  1. {datatorch-0.4.9.2 → datatorch-0.6.2}/PKG-INFO +12 -2
  2. {datatorch-0.4.9.2 → datatorch-0.6.2}/README.md +11 -1
  3. datatorch-0.6.2/datatorch/agent/agent.py +147 -0
  4. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/client.py +120 -107
  5. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/action/action.py +0 -4
  6. datatorch-0.6.2/datatorch/agent/pipelines/job/job.py +53 -0
  7. datatorch-0.6.2/datatorch/agent/pipelines/resolver.py +156 -0
  8. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/runner/command.py +5 -2
  9. datatorch-0.6.2/datatorch/agent/pipelines/runner/docker.py +34 -0
  10. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/runner/factory.py +7 -0
  11. datatorch-0.6.2/datatorch/agent/pipelines/runner/runner.py +122 -0
  12. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/runner/shell.py +5 -2
  13. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/step/step.py +42 -50
  14. datatorch-0.6.2/datatorch/agent/pipelines/template.py +167 -0
  15. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/action/create.py +3 -1
  16. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/action/run.py +2 -3
  17. datatorch-0.6.2/datatorch/cli/pipeline/run.py +22 -0
  18. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch.egg-info/PKG-INFO +12 -2
  19. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch.egg-info/SOURCES.txt +1 -0
  20. {datatorch-0.4.9.2 → datatorch-0.6.2}/setup.py +1 -1
  21. datatorch-0.4.9.2/datatorch/agent/agent.py +0 -80
  22. datatorch-0.4.9.2/datatorch/agent/pipelines/job/job.py +0 -61
  23. datatorch-0.4.9.2/datatorch/agent/pipelines/runner/docker.py +0 -22
  24. datatorch-0.4.9.2/datatorch/agent/pipelines/runner/runner.py +0 -80
  25. datatorch-0.4.9.2/datatorch/agent/pipelines/template.py +0 -157
  26. datatorch-0.4.9.2/datatorch/cli/pipeline/run.py +0 -30
  27. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/__init__.py +0 -0
  28. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/__init__.py +0 -0
  29. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/directory.py +0 -0
  30. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/log_handler.py +0 -0
  31. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/logging.py +0 -0
  32. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/monitoring.py +0 -0
  33. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/__init__.py +0 -0
  34. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/action/__init__.py +0 -0
  35. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/action/cache.py +0 -0
  36. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/action/config.py +0 -0
  37. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/job/__init__.py +0 -0
  38. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/pipeline.py +0 -0
  39. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/runner/__init__.py +0 -0
  40. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/runner/node.py +0 -0
  41. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/runner/python.py +0 -0
  42. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/pipelines/step/__init__.py +0 -0
  43. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/services/__init__.py +0 -0
  44. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/utils/__init__.py +0 -0
  45. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/utils/docker.py +0 -0
  46. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/agent/utils/process.py +0 -0
  47. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/__init__.py +0 -0
  48. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/api.py +0 -0
  49. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/client.py +0 -0
  50. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/__init__.py +0 -0
  51. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/annotation.py +0 -0
  52. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/base.py +0 -0
  53. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/dataset.py +0 -0
  54. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/file.py +0 -0
  55. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/label.py +0 -0
  56. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/project.py +0 -0
  57. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/settings.py +0 -0
  58. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/sources/__init__.py +0 -0
  59. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/sources/image/__init__.py +0 -0
  60. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/sources/image/bounding_box.py +0 -0
  61. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/sources/image/segmentations.py +0 -0
  62. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/sources/image/typings.py +0 -0
  63. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/sources/source.py +0 -0
  64. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/storage_link.py +0 -0
  65. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/entity/user.py +0 -0
  66. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/scripts/__init__.py +0 -0
  67. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/scripts/import_coco.py +0 -0
  68. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/scripts/import_pixmask.py +0 -0
  69. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/scripts/utils/__init__.py +0 -0
  70. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/scripts/utils/simplify.py +0 -0
  71. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/utils.py +0 -0
  72. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/api/where.py +0 -0
  73. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/__init__.py +0 -0
  74. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/action/__init__.py +0 -0
  75. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/action/pull.py +0 -0
  76. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/agent/__init__.py +0 -0
  77. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/agent/create.py +0 -0
  78. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/agent/dir.py +0 -0
  79. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/agent/start.py +0 -0
  80. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/groups.py +0 -0
  81. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/import_cmds/__init__.py +0 -0
  82. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/import_cmds/coco.py +0 -0
  83. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/import_cmds/pixmask.py +0 -0
  84. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/main/__init__.py +0 -0
  85. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/main/login.py +0 -0
  86. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/main/logout.py +0 -0
  87. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/main/upgrade.py +0 -0
  88. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/main/version.py +0 -0
  89. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/pipeline/__init__.py +0 -0
  90. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/pipeline/generate.py +0 -0
  91. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/pipeline/upload.py +0 -0
  92. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/spinner.py +0 -0
  93. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/upload/__init__.py +0 -0
  94. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/upload/folder.py +0 -0
  95. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/cli/utils.py +0 -0
  96. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/core/__init__.py +0 -0
  97. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/core/env.py +0 -0
  98. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/core/folder.py +0 -0
  99. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/core/settings.py +0 -0
  100. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/utils/__init__.py +0 -0
  101. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/utils/converters.py +0 -0
  102. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/utils/files.py +0 -0
  103. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/utils/hash_table.py +0 -0
  104. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/utils/objects.py +0 -0
  105. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/utils/package.py +0 -0
  106. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/utils/string_style.py +0 -0
  107. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch/utils/url.py +0 -0
  108. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch.egg-info/dependency_links.txt +0 -0
  109. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch.egg-info/entry_points.txt +0 -0
  110. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch.egg-info/not-zip-safe +0 -0
  111. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch.egg-info/requires.txt +0 -0
  112. {datatorch-0.4.9.2 → datatorch-0.6.2}/datatorch.egg-info/top_level.txt +0 -0
  113. {datatorch-0.4.9.2 → datatorch-0.6.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datatorch
3
- Version: 0.4.9.2
3
+ Version: 0.6.2
4
4
  Summary: A CLI and library for interacting with DataTorch.
5
5
  Home-page: https://github.com/datatorch/python
6
6
  Author: DataTorch
@@ -65,7 +65,7 @@ Dynamic: summary
65
65
 
66
66
  ## Installation
67
67
 
68
- *Note*: Newer versions (>=0.4.8.2) of DataTorch client use `libmagic` for more
68
+ _Note_: Newer versions (>=0.4.8.2) of DataTorch client use `libmagic` for more
69
69
  accurate mimetype guessing. This falls back to guessing via extension. Consider
70
70
  installing `libmagic` if you have issues with file types uploaded to DataTorch.
71
71
 
@@ -86,3 +86,13 @@ pip3 install -r requirements.txt
86
86
 
87
87
  Open your VSCode settings, by going `Code -> Preferences -> Settings`. Search
88
88
  for "python formatting provider" and select "black" from the dropdown menu.
89
+
90
+ #### Testing
91
+
92
+ python3 -m build
93
+
94
+ python3 -m venv env
95
+
96
+ rm -rf env
97
+
98
+ source env/bin/activate && pip3 install dist/datatorch-0.6.1-py3-none-any.whl && pip3 install pycocotools
@@ -15,7 +15,7 @@
15
15
 
16
16
  ## Installation
17
17
 
18
- *Note*: Newer versions (>=0.4.8.2) of DataTorch client use `libmagic` for more
18
+ _Note_: Newer versions (>=0.4.8.2) of DataTorch client use `libmagic` for more
19
19
  accurate mimetype guessing. This falls back to guessing via extension. Consider
20
20
  installing `libmagic` if you have issues with file types uploaded to DataTorch.
21
21
 
@@ -36,3 +36,13 @@ pip3 install -r requirements.txt
36
36
 
37
37
  Open your VSCode settings, by going `Code -> Preferences -> Settings`. Search
38
38
  for "python formatting provider" and select "black" from the dropdown menu.
39
+
40
+ #### Testing
41
+
42
+ python3 -m build
43
+
44
+ python3 -m venv env
45
+
46
+ rm -rf env
47
+
48
+ source env/bin/activate && pip3 install dist/datatorch-0.6.1-py3-none-any.whl && pip3 install pycocotools
@@ -0,0 +1,147 @@
1
+ import os
2
+ import logging
3
+ import asyncio
4
+
5
+ from typing import Dict, List
6
+ from .client import AgentApiClient, AgentStepDispatch
7
+ from .pipelines.template import Variables
8
+ from .log_handler import AgentAPIHandler
9
+ from .monitoring import AgentSystemStats
10
+ from .directory import agent_directory
11
+
12
+ from gql.client import AsyncClientSession
13
+ from datatorch.agent.pipelines.step import Step
14
+
15
+ logger = logging.getLogger(__name__)
16
+ tasks: List[asyncio.Task] = []
17
+
18
+
19
+ class Agent(object):
20
+ """
21
+ Agent protocol v2: the server sequences steps. The agent receives one
22
+ ready step at a time — its inputs already resolved server-side —
23
+ claims it, executes it, and reports the result. Job-level state and
24
+ cross-step dataflow live entirely on the server.
25
+ """
26
+
27
+ @classmethod
28
+ async def run(cls, session):
29
+ agent = cls(session)
30
+ await agent.process_loop()
31
+
32
+ def __init__(self, session: AsyncClientSession):
33
+ self.api = AgentApiClient(session)
34
+ self.directory = agent_directory
35
+ # Running step tasks, keyed by stepId, so a server cancel/timeout
36
+ # signal can abort the right one. Entries are cleaned up when the
37
+ # step finishes.
38
+ self._step_tasks: Dict[str, asyncio.Task] = {}
39
+ logger.debug(f"Switch to agent directory: {self.directory.dir}")
40
+ os.chdir(self.directory.dir)
41
+
42
+ self._init_logger()
43
+ self._init_threads()
44
+
45
+ def _init_logger(self):
46
+ self.logger = logging.getLogger("datatorch.agent")
47
+ self.logger_api_handler = AgentAPIHandler(self.api)
48
+ self.logger.addHandler(self.logger_api_handler)
49
+ self.logger.debug("Agent logger has been initalized.")
50
+
51
+ def _init_threads(self):
52
+ self.system_stats = AgentSystemStats(self)
53
+ loop = asyncio.get_running_loop()
54
+ task = loop.create_task(self.system_stats.start())
55
+ tasks.append(task)
56
+
57
+ async def process_loop(self):
58
+ """Drains steps missed while offline, then waits for pushes.
59
+
60
+ Runs the dispatch stream alongside a cancel-signal listener: the
61
+ server pushes a stop signal when a running step is canceled or
62
+ times out, and the agent aborts that step's task.
63
+ """
64
+ loop = asyncio.get_running_loop()
65
+
66
+ pending = await self.api.pending_steps()
67
+ if pending:
68
+ logger.info(f"Recovered {len(pending)} pending step(s) on connect.")
69
+ for dispatch in pending:
70
+ self._start_step(dispatch)
71
+
72
+ logger.info("Waiting for steps.")
73
+ cancel_task = loop.create_task(self._cancel_loop())
74
+ tasks.append(cancel_task)
75
+ try:
76
+ async for request in self.api.agent_steps():
77
+ self._start_step(request.get("step"))
78
+ finally:
79
+ cancel_task.cancel()
80
+
81
+ async def _cancel_loop(self):
82
+ """Aborts a running step's task when the server signals a stop."""
83
+ async for request in self.api.agent_step_cancels():
84
+ cancel = request.get("cancel") or {}
85
+ step_id = cancel.get("stepId")
86
+ task = self._step_tasks.get(step_id)
87
+ if task and not task.done():
88
+ logger.info(f"Received stop signal for step {step_id}.")
89
+ task.cancel()
90
+ else:
91
+ # Unknown/finished step — the signal is best-effort and the
92
+ # server has already moved the step terminal.
93
+ logger.debug(f"Stop signal for step {step_id} ignored (not running).")
94
+
95
+ def _start_step(self, dispatch: AgentStepDispatch):
96
+ """Schedules a step and tracks its task for cancellation."""
97
+ loop = asyncio.get_running_loop()
98
+ step_id = dispatch.get("stepId")
99
+ task = loop.create_task(self._run_step(dispatch))
100
+ self._step_tasks[step_id] = task
101
+ tasks.append(task)
102
+
103
+ async def _run_step(self, dispatch: AgentStepDispatch):
104
+ """Claims and executes a single dispatched step."""
105
+ step_id = dispatch.get("stepId")
106
+ step_name = dispatch.get("stepName") or dispatch.get("action")
107
+
108
+ try:
109
+ claimed = await self.api.claim_step(step_id)
110
+ if not claimed:
111
+ # Another process (or an earlier duplicate dispatch) owns it.
112
+ logger.debug(f"Step {step_id} already claimed; skipping.")
113
+ return
114
+
115
+ logger.info(f"Step received (name: {step_name}, id: {step_id})")
116
+ step = Step.from_dispatch(dispatch, api=self.api)
117
+
118
+ try:
119
+ outputs = await step.run(Variables())
120
+ except asyncio.CancelledError:
121
+ # Either a disconnect mid-step (the reconciler fails the
122
+ # orphaned RUNNING step once the agent stays offline) or a
123
+ # server stop signal (the step is already terminal
124
+ # server-side). Either way the child process was killed by
125
+ # the runner; don't report a result — a late completion
126
+ # would be absorbed by the RUNNING guard anyway.
127
+ logger.info(f"Step {step_id} aborted.")
128
+ await step.upload_logs()
129
+ raise
130
+ except Exception as e:
131
+ logger.error(f"Step {step_id} failed: {e}")
132
+ step.log(f"Step failed: {e}.")
133
+ await step.upload_logs()
134
+ await self.api.complete_step(
135
+ step_id, success=False, error_message=str(e)
136
+ )
137
+ return
138
+
139
+ await self.api.complete_step(
140
+ step_id,
141
+ success=True,
142
+ output=outputs,
143
+ rendered_input=step.rendered_inputs,
144
+ )
145
+ logger.info(f"Step finished (name: {step_name}, id: {step_id})")
146
+ finally:
147
+ self._step_tasks.pop(step_id, None)
@@ -1,5 +1,4 @@
1
- from datatorch.api.api import ApiClient
2
- from typing import AsyncGenerator, List, cast
1
+ from typing import AsyncGenerator, List, Optional, cast
3
2
  from gql import gql
4
3
  from .directory import agent_directory
5
4
  from gql.client import AsyncClientSession
@@ -12,95 +11,152 @@ class Log(TypedDict):
12
11
  message: str
13
12
 
14
13
 
15
- class AgentPipelineConfig(TypedDict):
16
- id: str
17
- projectId: str
18
- creatorId: str
19
- lastRunNumber: int
14
+ class AgentStepDispatch(TypedDict):
15
+ """A ready step pushed to this agent (agent protocol v2).
20
16
 
17
+ ``input`` arrives server-resolved: ``steps.*`` and ``input.*``
18
+ references are already substituted with concrete values.
19
+ """
21
20
 
22
- class AgentStepsConfig(TypedDict):
23
- id: str
24
- name: str
21
+ stepId: str
22
+ agentId: str
25
23
  action: str
26
- index: int
24
+ stepName: Optional[str]
25
+ stepIndex: int
26
+ input: dict
27
+ jobId: str
28
+ jobName: str
29
+ runId: str
30
+ runNumber: int
31
+ pipelineId: str
32
+ projectId: str
27
33
 
28
34
 
29
- class AgentTriggerConfig(TypedDict):
30
- id: str
31
- type: str
32
- event: dict
35
+ class AgentStepRequest(TypedDict):
36
+ step: AgentStepDispatch
33
37
 
34
38
 
35
- class AgentRunConfig(TypedDict):
36
- id: str
37
- name: str
38
- text: str
39
- config: dict
40
- runNumber: int
41
- pipeline: AgentPipelineConfig
42
- trigger: AgentTriggerConfig
39
+ class AgentStepCancel(TypedDict):
40
+ """A stop signal for a step this agent is currently running."""
43
41
 
42
+ stepId: str
43
+ agentId: str
44
44
 
45
- class AgentJobConfig(TypedDict):
46
- id: str
47
- name: str
48
- run: AgentRunConfig
49
- steps: List[AgentStepsConfig]
50
45
 
46
+ class AgentStepCancelRequest(TypedDict):
47
+ cancel: AgentStepCancel
51
48
 
52
- class AgentRequestConfig(TypedDict):
53
- job: AgentJobConfig
49
+
50
+ STEP_DISPATCH_FIELDS = """
51
+ stepId
52
+ agentId
53
+ action
54
+ stepName
55
+ stepIndex
56
+ input
57
+ jobId
58
+ jobName
59
+ runId
60
+ runNumber
61
+ pipelineId
62
+ projectId
63
+ """
54
64
 
55
65
 
56
66
  class AgentApiClient(object):
57
67
  def __init__(self, session: AsyncClientSession):
58
68
  self.session = session
59
69
 
60
- def agent_jobs(self):
61
- """Subscriptions to the agent job assignment namespace."""
70
+ def agent_steps(self):
71
+ """Subscribe to ready steps dispatched to this agent."""
62
72
  # fmt: off
63
73
  sub = gql("""
64
74
  subscription {
65
- job: agentJobs {
66
- id
67
- name
68
- run {
69
- id
70
- name
71
- text
72
- config
73
- runNumber
74
- pipeline {
75
- id
76
- projectId
77
- creatorId
78
- lastRunNumber
79
- }
80
- trigger {
81
- id
82
- trigger {
83
- id
84
- type
85
- config
86
- }
87
- event
88
- }
89
- }
90
- steps {
91
- id
92
- name
93
- index
94
- action
95
- }
75
+ step: agentSteps {
76
+ %s
77
+ }
78
+ }
79
+ """ % STEP_DISPATCH_FIELDS)
80
+ # fmt: on
81
+ return cast(AsyncGenerator[AgentStepRequest, None], self.session.subscribe(sub))
82
+
83
+ def agent_step_cancels(self):
84
+ """Subscribe to stop signals for steps this agent is running.
85
+
86
+ Best effort: the server has already moved the step terminal, so a
87
+ missed signal only means the step runs to harmless completion.
88
+ """
89
+ # fmt: off
90
+ sub = gql("""
91
+ subscription {
92
+ cancel: agentStepCancels {
93
+ stepId
94
+ agentId
96
95
  }
97
96
  }
98
97
  """)
99
98
  # fmt: on
100
99
  return cast(
101
- AsyncGenerator[AgentRequestConfig, None], self.session.subscribe(sub)
100
+ AsyncGenerator[AgentStepCancelRequest, None],
101
+ self.session.subscribe(sub),
102
102
  )
103
103
 
104
+ async def pending_steps(self) -> List[AgentStepDispatch]:
105
+ """Steps dispatched to this agent but not yet claimed.
106
+
107
+ Called on (re)connect: pushes missed while offline are recovered
108
+ here.
109
+ """
110
+ # fmt: off
111
+ query = """
112
+ query PendingSteps {
113
+ steps: agentPendingSteps {
114
+ %s
115
+ }
116
+ }
117
+ """ % STEP_DISPATCH_FIELDS
118
+ # fmt: on
119
+ result = await self.execute(query)
120
+ return cast(List[AgentStepDispatch], result.get("steps", []))
121
+
122
+ async def claim_step(self, step_id: str) -> bool:
123
+ """Atomically claim a dispatched step. False = already claimed."""
124
+ # fmt: off
125
+ mutate = """
126
+ mutation ClaimStep($id: ID!) {
127
+ claimed: claimPipelineStep(id: $id)
128
+ }
129
+ """
130
+ # fmt: on
131
+ result = await self.execute(mutate, params={"id": step_id})
132
+ return bool(result.get("claimed"))
133
+
134
+ async def complete_step(
135
+ self,
136
+ step_id: str,
137
+ success: bool,
138
+ output: Optional[dict] = None,
139
+ rendered_input: Optional[dict] = None,
140
+ error_message: Optional[str] = None,
141
+ ) -> bool:
142
+ """Report the result of a claimed step."""
143
+ # fmt: off
144
+ mutate = """
145
+ mutation CompleteStep($id: ID!, $input: CompletePipelineStepInput!) {
146
+ completed: completePipelineStep(id: $id, input: $input)
147
+ }
148
+ """
149
+ # fmt: on
150
+ step_input = {"success": success}
151
+ if output is not None:
152
+ step_input["output"] = output
153
+ if rendered_input is not None:
154
+ step_input["renderedInput"] = rendered_input
155
+ if error_message is not None:
156
+ step_input["errorMessage"] = error_message
157
+ result = await self.execute(mutate, params={"id": step_id, "input": step_input})
158
+ return bool(result.get("completed"))
159
+
104
160
  def initial_metrics(self, metrics):
105
161
  # fmt: off
106
162
  mutate = """
@@ -170,45 +226,6 @@ class AgentApiClient(object):
170
226
  params = {"agentId": agent_directory.settings.agent_id, **metrics}
171
227
  return self.execute(mutate, params=params)
172
228
 
173
- def update_step(self, values: dict):
174
- # fmt: off
175
- mutate = """
176
- mutation UpdateStepRun(
177
- $id: ID!
178
- $inputs: JSON
179
- $outputs: JSON
180
- $status: PipelineStepStatus
181
- $startedAt: DateTime
182
- $finishedAt: DateTime
183
- ) {
184
- updatePipelineStep(
185
- id: $id
186
- input: {
187
- status: $status
188
- output: $outputs
189
- input: $inputs
190
- startedAt: $startedAt
191
- finishedAt: $finishedAt
192
- }
193
- )
194
- }
195
- """
196
- # fmt: on
197
- return self.execute(mutate, params=values)
198
-
199
- async def update_job(self, values: dict):
200
- # fmt: off
201
- mutate = """
202
- mutation UpdateJob($id: ID!, $status: PipelineJobStatus) {
203
- updatePipelineJobRun(
204
- id: $id,
205
- input: { status: $status }
206
- )
207
- }
208
- """
209
- # fmt: on
210
- return await self.execute(mutate, params=values)
211
-
212
229
  def upload_step_logs(self, step_id: str, logs: List[Log]):
213
230
  # fmt: off
214
231
  mutate = """
@@ -227,7 +244,3 @@ class AgentApiClient(object):
227
244
  return await self.session.execute(
228
245
  query, *args, variable_values=removed_none, **kwargs
229
246
  )
230
-
231
-
232
- def create_client():
233
- ApiClient
@@ -117,10 +117,6 @@ class Action(object):
117
117
  variable_value = json.loads(variable_value)
118
118
  variables.add_input(k, variable_value)
119
119
 
120
- if self.step is not None:
121
- # Update steps output after casting.
122
- await self.step.update(inputs=variables.inputs)
123
-
124
120
  logger.debug(f"Inputs for '{self.full_name}': {json.dumps(variables.inputs)}")
125
121
 
126
122
  output = self.get_cached(variables)
@@ -0,0 +1,53 @@
1
+ import logging
2
+
3
+ from ..step import Step
4
+ from ..template import Variables
5
+ from ..resolver import UnresolvedReferenceError, resolve_step_input
6
+
7
+ logger = logging.getLogger("datatorch.agent.job")
8
+
9
+
10
+ class Job(object):
11
+ """
12
+ LOCAL-MODE step sequencer (``datatorch pipeline run <yaml>``).
13
+
14
+ Against the server, jobs are sequenced by the server-side orchestrator
15
+ and this class is not involved — the agent executes one dispatched
16
+ step at a time. Local mode replicates the server's behavior so the
17
+ same yaml works in both places: steps run sequentially, each step's
18
+ ``${{ steps.<name>.output.<key> }}`` / ``${{ input.<key> }}``
19
+ references are resolved (strictly) from earlier outputs before it
20
+ runs, and the first failure skips the rest.
21
+ """
22
+
23
+ def __init__(self, config: dict, trigger_input: dict = None):
24
+ self.config = config
25
+ self.name = config.get("name")
26
+ self.trigger_input = trigger_input or {}
27
+
28
+ async def run(self):
29
+ """Runs each step of the job, resolving references in between."""
30
+ outputs_by_name = {}
31
+
32
+ for step_config in self.config.get("steps", []):
33
+ name = step_config.get("name")
34
+ try:
35
+ resolved, unresolved = resolve_step_input(
36
+ step_config.get("inputs", {}),
37
+ outputs_by_name,
38
+ self.trigger_input,
39
+ )
40
+ if unresolved:
41
+ raise UnresolvedReferenceError(unresolved)
42
+
43
+ step = Step.from_dict({**step_config, "inputs": resolved})
44
+ outputs = await step.run(Variables())
45
+ if name:
46
+ outputs_by_name[name] = outputs or {}
47
+ except Exception as e:
48
+ logger.error(f"Job {self.name} failed on step '{name}': {e}")
49
+ logger.error("Skipping remaining steps.")
50
+ raise
51
+
52
+ logger.info(f"Successfully completed job '{self.name}'.")
53
+ return outputs_by_name
@@ -0,0 +1,156 @@
1
+ """
2
+ Local-mode port of the server's step input resolver.
3
+
4
+ When a pipeline runs against the DataTorch server, the server resolves
5
+ ``${{ steps.<name>.output.<key> }}``, ``${{ input.<key> }}`` and
6
+ ``${{ jobs.<name>.outputs.<key> }}`` references before dispatching each
7
+ step. Local mode (``datatorch pipeline run``) replicates those exact
8
+ semantics so a pipeline yaml behaves identically in both places:
9
+
10
+ - A reference spanning the whole string resolves to the raw value
11
+ (numbers/objects preserved); embedded references interpolate as strings.
12
+ - ``outputs``/``inputs`` are accepted as aliases of ``output``/``input``.
13
+ - Unresolvable references are an error (strict — matching the server,
14
+ which fails the step at dispatch).
15
+
16
+ Cross-job note: ``${{ jobs.<name>.outputs.<key> }}`` is recognized as a
17
+ server namespace here for parity, but local mode runs a single job at a
18
+ time (there is no cross-job sequencer — see ``job.Job``), so no job
19
+ outputs are supplied and such a reference resolves as unresolvable, i.e.
20
+ raises rather than passing a literal ``${{ }}`` to the action. A yaml that
21
+ uses job dependencies is a server-executed workflow.
22
+ """
23
+
24
+ import json
25
+ import re
26
+ from typing import Any, Dict, List, Set, Tuple
27
+
28
+ # Named groups so the combined alternation reads by name, not by positional
29
+ # index that shifts when a namespace is added — mirrors the server resolver.
30
+ # The name group is ``[^}]+?`` (not ``.+?``) so it can't backtrack across a
31
+ # ``}}`` boundary — a string that both starts and ends with a ref must not be
32
+ # mistaken for one spanning ref.
33
+ STEP_REF = (
34
+ r"\$\{\{\s*steps\.(?P<stepName>[^}]+?)\.outputs?\.(?P<stepKey>[\w.\-]+)\s*\}\}"
35
+ )
36
+ INPUT_REF = r"\$\{\{\s*inputs?\.(?P<inputKey>[\w.\-]+)\s*\}\}"
37
+ JOB_REF = r"\$\{\{\s*jobs\.(?P<jobName>[^}]+?)\.outputs?\.(?P<jobKey>[\w.\-]+)\s*\}\}"
38
+
39
+ SERVER_REF = re.compile(f"{STEP_REF}|{INPUT_REF}|{JOB_REF}")
40
+ WHOLE_STRING_REF = re.compile(f"^(?:{STEP_REF}|{INPUT_REF}|{JOB_REF})$")
41
+
42
+ _MISSING = object()
43
+
44
+
45
+ class UnresolvedReferenceError(Exception):
46
+ def __init__(self, refs: List[str]):
47
+ self.refs = refs
48
+ super().__init__(
49
+ "Unresolvable input reference(s): "
50
+ + ", ".join(refs)
51
+ + ". References must point to a named earlier step output, "
52
+ + "a trigger input, or a needed job output."
53
+ )
54
+
55
+
56
+ def _lookup_mapping(mapping: Dict[str, dict], name, key):
57
+ if name is None or key is None:
58
+ return _MISSING
59
+ values = mapping.get(name.strip(), _MISSING)
60
+ if values is _MISSING or not isinstance(values, dict):
61
+ return _MISSING
62
+ return values.get(key, _MISSING)
63
+
64
+
65
+ def _lookup(
66
+ outputs_by_name: Dict[str, dict],
67
+ trigger_input: dict,
68
+ job_outputs_by_name: Dict[str, dict],
69
+ groups: Dict[str, Any],
70
+ ):
71
+ step = _lookup_mapping(
72
+ outputs_by_name, groups.get("stepName"), groups.get("stepKey")
73
+ )
74
+ if groups.get("stepName") is not None:
75
+ return step
76
+ job = _lookup_mapping(
77
+ job_outputs_by_name, groups.get("jobName"), groups.get("jobKey")
78
+ )
79
+ if groups.get("jobName") is not None:
80
+ return job
81
+ input_key = groups.get("inputKey")
82
+ if input_key is not None:
83
+ return trigger_input.get(input_key, _MISSING)
84
+ return _MISSING
85
+
86
+
87
+ def _resolve(
88
+ value, outputs_by_name, trigger_input, job_outputs_by_name, misses: Set[str]
89
+ ):
90
+ if isinstance(value, str):
91
+ whole = WHOLE_STRING_REF.match(value)
92
+ if whole:
93
+ resolved = _lookup(
94
+ outputs_by_name,
95
+ trigger_input,
96
+ job_outputs_by_name,
97
+ whole.groupdict(),
98
+ )
99
+ if resolved is _MISSING:
100
+ misses.add(value.strip())
101
+ return value
102
+ return resolved
103
+
104
+ def replace(match: "re.Match") -> str:
105
+ resolved = _lookup(
106
+ outputs_by_name,
107
+ trigger_input,
108
+ job_outputs_by_name,
109
+ match.groupdict(),
110
+ )
111
+ if resolved is _MISSING:
112
+ misses.add(match.group(0))
113
+ return match.group(0)
114
+ if isinstance(resolved, (dict, list)):
115
+ return json.dumps(resolved)
116
+ if isinstance(resolved, bool):
117
+ # Match the server's String(true|false)
118
+ return "true" if resolved else "false"
119
+ return str(resolved)
120
+
121
+ return SERVER_REF.sub(replace, value)
122
+ if isinstance(value, list):
123
+ return [
124
+ _resolve(v, outputs_by_name, trigger_input, job_outputs_by_name, misses)
125
+ for v in value
126
+ ]
127
+ if isinstance(value, dict):
128
+ return {
129
+ k: _resolve(v, outputs_by_name, trigger_input, job_outputs_by_name, misses)
130
+ for k, v in value.items()
131
+ }
132
+ return value
133
+
134
+
135
+ def resolve_step_input(
136
+ inputs: Any,
137
+ outputs_by_name: Dict[str, dict],
138
+ trigger_input: dict = {},
139
+ job_outputs_by_name: Dict[str, dict] = {},
140
+ ) -> Tuple[Any, List[str]]:
141
+ """Deep-resolve server-namespace references in a step's inputs.
142
+
143
+ Returns ``(resolved, unresolved)`` where ``unresolved`` lists the
144
+ references (as authored) that could not be resolved. ``job_outputs_by_name``
145
+ is empty in local mode (single-job execution), so ``${{ jobs.* }}``
146
+ references resolve as unresolvable.
147
+ """
148
+ misses: Set[str] = set()
149
+ resolved = _resolve(
150
+ inputs,
151
+ outputs_by_name,
152
+ trigger_input or {},
153
+ job_outputs_by_name or {},
154
+ misses,
155
+ )
156
+ return resolved, sorted(misses)