Skip to content

Commit 1554bac

Browse files
committed
0.7.3
1 parent 18f2528 commit 1554bac

7 files changed

Lines changed: 137 additions & 36 deletions

File tree

‎datatorch/agent/client.py‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ class Log(TypedDict):
1515

1616

1717
class AgentStepDispatch(TypedDict):
18-
"""A ready step pushed to this agent (agent protocol v2).
18+
"""A ready step pushed to this runner (runner protocol v2).
1919
2020
``input`` arrives server-resolved: ``steps.*`` and ``input.*``
2121
references are already substituted with concrete values.
2222
"""
2323

2424
stepId: str
25-
agentId: str
25+
runnerId: str
2626
action: str
2727
stepName: Optional[str]
2828
stepIndex: int
@@ -40,10 +40,10 @@ class AgentStepRequest(TypedDict):
4040

4141

4242
class AgentStepCancel(TypedDict):
43-
"""A stop signal for a step this agent is currently running."""
43+
"""A stop signal for a step this runner is currently running."""
4444

4545
stepId: str
46-
agentId: str
46+
runnerId: str
4747

4848

4949
class AgentStepCancelRequest(TypedDict):
@@ -52,7 +52,7 @@ class AgentStepCancelRequest(TypedDict):
5252

5353
STEP_DISPATCH_FIELDS = """
5454
stepId
55-
agentId
55+
runnerId
5656
action
5757
stepName
5858
stepIndex
@@ -71,11 +71,11 @@ def __init__(self, session: AsyncClientSession):
7171
self.session = session
7272

7373
def agent_steps(self):
74-
"""Subscribe to ready steps dispatched to this agent."""
74+
"""Subscribe to ready steps dispatched to this runner."""
7575
# fmt: off
7676
sub = gql("""
7777
subscription {
78-
step: agentSteps {
78+
step: runnerSteps {
7979
%s
8080
}
8181
}
@@ -84,17 +84,17 @@ def agent_steps(self):
8484
return cast(AsyncGenerator[AgentStepRequest, None], self.session.subscribe(sub))
8585

8686
def agent_step_cancels(self):
87-
"""Subscribe to stop signals for steps this agent is running.
87+
"""Subscribe to stop signals for steps this runner is running.
8888
8989
Best effort: the server has already moved the step terminal, so a
9090
missed signal only means the step runs to harmless completion.
9191
"""
9292
# fmt: off
9393
sub = gql("""
9494
subscription {
95-
cancel: agentStepCancels {
95+
cancel: runnerStepCancels {
9696
stepId
97-
agentId
97+
runnerId
9898
}
9999
}
100100
""")
@@ -105,15 +105,15 @@ def agent_step_cancels(self):
105105
)
106106

107107
async def pending_steps(self) -> List[AgentStepDispatch]:
108-
"""Steps dispatched to this agent but not yet claimed.
108+
"""Steps dispatched to this runner but not yet claimed.
109109
110110
Called on (re)connect: pushes missed while offline are recovered
111111
here.
112112
"""
113113
# fmt: off
114114
query = """
115115
query PendingSteps {
116-
steps: agentPendingSteps {
116+
steps: runnerPendingSteps {
117117
%s
118118
}
119119
}
@@ -163,7 +163,7 @@ async def complete_step(
163163
def initial_metrics(self, metrics):
164164
# fmt: off
165165
mutate = """
166-
mutation updateAgent(
166+
mutation updateRunner(
167167
$id: ID!
168168
$version: String
169169
$os: String
@@ -177,7 +177,7 @@ def initial_metrics(self, metrics):
177177
$cpuCoresPhysical: Int
178178
$cpuCoresLogical: Int
179179
) {
180-
updateAgent(id: $id, input: {
180+
updateRunner(id: $id, input: {
181181
version: $version
182182
os: $os
183183
osRelease: $osRelease
@@ -201,8 +201,8 @@ def initial_metrics(self, metrics):
201201
def metrics(self, metrics):
202202
# fmt: off
203203
mutate = """
204-
mutation updateAgent(
205-
$agentId: ID!
204+
mutation createRunnerMetric(
205+
$runnerId: ID!
206206
$sampledAt: DateTime!
207207
$avgLoad1: Float
208208
$avgLoad5: Float
@@ -211,8 +211,8 @@ def metrics(self, metrics):
211211
$diskUsage: Float
212212
$memoryUsage: Float
213213
) {
214-
createAgentMetric(input: {
215-
agentId: $agentId
214+
createRunnerMetric(input: {
215+
runnerId: $runnerId
216216
sampledAt: $sampledAt
217217
avgLoad1: $avgLoad1
218218
avgLoad5: $avgLoad5
@@ -226,7 +226,7 @@ def metrics(self, metrics):
226226
}
227227
"""
228228
# fmt: on
229-
params = {"agentId": agent_directory.settings.agent_id, **metrics}
229+
params = {"runnerId": agent_directory.settings.agent_id, **metrics}
230230
return self.execute(mutate, params=params)
231231

232232
def upload_step_logs(self, step_id: str, logs: List[Log]):

‎datatorch/agent/pipelines/runner/python.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import sys
33
import json
4-
import base64
4+
import shlex
55

66
from .runner import Runner
77

@@ -18,13 +18,15 @@ def __init__(self, *args, **kwargs):
1818

1919
async def execute(self):
2020
main = self.get("main").strip("/")
21-
# Ok this command parsing stuff is to handle Application Support folder in Macs
22-
# Because there are spaces in the folder name
23-
main_command_spaces = os.path.join(self.action.dir, main)
24-
main_command_nospaces = main_command_spaces.replace(" ", "\ ")
21+
main_path = os.path.join(self.action.dir, main)
2522

26-
json_input = json.dumps(self.variables.inputs).replace("'", '\\"')
23+
# Inputs reach the action verbatim as one JSON argv argument.
24+
# shlex.quote covers every shell metacharacter, apostrophes
25+
# included, and the space in macOS "Application Support" paths.
26+
json_input = json.dumps(self.variables.inputs)
2727

2828
await self.monitor_cmd(
29-
f"{sys.executable} {main_command_nospaces} '{json_input}'"
29+
" ".join(
30+
shlex.quote(part) for part in (sys.executable, main_path, json_input)
31+
)
3032
)

‎datatorch/agent/pipelines/runner/shell.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import shlex
23

34
from .runner import Runner
45

@@ -18,6 +19,12 @@ async def execute(self):
1819
# ${{ input.* }} — injection policy). Inputs reach the script as
1920
# $INPUT_<NAME> environment variables.
2021
script = self.get_command("script").strip("/")
21-
script_command = os.path.join(self.action.dir, script)
22-
await self.run_cmd("chmod +x {}".format(script_command.split(" ", 1)[0]))
23-
await self.monitor_cmd(script_command, env=self.input_env())
22+
# `script` may carry arguments after the path ("run.sh --flag");
23+
# only the path is joined to the action dir and quoted, since the
24+
# dir can contain a space (macOS "Application Support").
25+
script_path, _, script_args = script.partition(" ")
26+
quoted_path = shlex.quote(os.path.join(self.action.dir, script_path))
27+
await self.run_cmd(f"chmod +x {quoted_path}")
28+
await self.monitor_cmd(
29+
f"{quoted_path} {script_args}".rstrip(), env=self.input_env()
30+
)

‎datatorch/api/client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
__all__ = "Client"
2121

2222

23-
AGENT_TOKEN_HEADER = "datatorch-agent-token"
23+
AGENT_TOKEN_HEADER = "datatorch-runner-token"
2424
API_KEY_HEADER = "datatorch-api-key"
2525

2626

‎datatorch/cli/agent/create.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ def create_agent(name: str) -> dict:
1414
# fmt: off
1515
results = api.execute(
1616
"""
17-
mutation CreateAgent($name: String!, $version: String!) {
18-
createAgent(name: $name, version: $version) {
19-
agent {
17+
mutation CreateRunner($name: String!, $version: String!) {
18+
createRunner(name: $name, version: $version) {
19+
runner {
2020
id
2121
}
2222
token
@@ -54,8 +54,8 @@ def create(ctx):
5454
spinner = Spinner("Creating runner")
5555

5656
try:
57-
agent = create_agent(name)["createAgent"]
58-
agent_settings.agent_id = agent["agent"]["id"]
57+
agent = create_agent(name)["createRunner"]
58+
agent_settings.agent_id = agent["runner"]["id"]
5959
agent_settings.agent_token = agent["token"]
6060
agent_settings.api_url = user_settings.api_url
6161
except Exception as ex:

‎setup.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
setup(
3535
name="datatorch",
36-
version="0.7.2",
36+
version="0.7.3",
3737
description="A CLI and library for interacting with DataTorch.",
3838
author="DataTorch",
3939
author_email="support@datatorch.io",
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""
2+
Inputs reach a python action as one JSON argv argument, and actions live
3+
under the agent directory, which on macOS is inside "Application Support".
4+
Both must survive the shell untouched: the runner used to hand-escape
5+
them, which turned every apostrophe in an input into a double quote and
6+
broke script paths containing a space.
7+
"""
8+
9+
import asyncio
10+
import os
11+
import tempfile
12+
13+
from datatorch.agent.pipelines.runner.python import PythonRunner
14+
from datatorch.agent.pipelines.runner.shell import ShellRunner
15+
16+
17+
class _FakeVariables:
18+
def __init__(self, inputs):
19+
self.inputs = inputs
20+
21+
def render(self, value):
22+
return value
23+
24+
def render_command(self, value):
25+
return value
26+
27+
def env_inputs(self):
28+
return {f"INPUT_{k.upper()}": str(v) for k, v in self.inputs.items()}
29+
30+
31+
class _FakeAction:
32+
step = None
33+
34+
def __init__(self, directory):
35+
self.dir = directory
36+
37+
38+
def _action_dir() -> str:
39+
# A space in the path, like ~/Library/Application Support/DataTorch.
40+
path = os.path.join(tempfile.mkdtemp(prefix="datatorch-test-"), "App Support")
41+
os.makedirs(path)
42+
return path
43+
44+
45+
AWKWARD_INPUTS = {
46+
"message": "what's up? it's \"quoted\", $HOME, `whoami`, $(id), a\\b, 100%\nline two\ttab",
47+
"unicode": "café — 日本語 'single' \"double\"",
48+
"empty": "",
49+
"count": 3,
50+
"items": ["it's", "fine"],
51+
}
52+
53+
54+
def test_python_runner_passes_inputs_verbatim():
55+
directory = _action_dir()
56+
with open(os.path.join(directory, "main.py"), "w") as f:
57+
f.write(
58+
"import json, sys\n"
59+
"print('::echo::' + json.dumps(json.loads(sys.argv[-1])))\n"
60+
)
61+
62+
runner = PythonRunner({"main": "main.py"}, _FakeAction(directory))
63+
outputs = asyncio.run(runner.run(_FakeVariables(dict(AWKWARD_INPUTS))))
64+
65+
assert outputs["echo"] == AWKWARD_INPUTS
66+
67+
68+
def test_shell_runner_script_path_with_space():
69+
directory = _action_dir()
70+
with open(os.path.join(directory, "run.sh"), "w") as f:
71+
f.write('#!/bin/sh\necho "::args::$#"\necho "::first::\\"$1\\""\n')
72+
73+
runner = ShellRunner({"script": "run.sh hello"}, _FakeAction(directory))
74+
outputs = asyncio.run(runner.run(_FakeVariables({})))
75+
76+
assert outputs == {"args": 1, "first": "hello"}
77+
78+
79+
def test_shell_runner_inputs_arrive_as_env():
80+
directory = _action_dir()
81+
with open(os.path.join(directory, "run.sh"), "w") as f:
82+
f.write(
83+
"#!/bin/sh\n"
84+
'printf \'::message::%s\\n\' "$(printf %s "$INPUT_MESSAGE" | '
85+
"python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')\"\n"
86+
)
87+
88+
runner = ShellRunner({"script": "run.sh"}, _FakeAction(directory))
89+
message = 'it\'s "fine" $HOME'
90+
outputs = asyncio.run(runner.run(_FakeVariables({"message": message})))
91+
92+
assert outputs["message"] == message

0 commit comments

Comments
 (0)