Skip to content
This repository was archived by the owner on Aug 11, 2020. It is now read-only.

Commit 11a344e

Browse files
Merge pull request #93 from Paperspace/PS-9792
Ps 9792
2 parents 1e01fb6 + 87a90c1 commit 11a344e

File tree

5 files changed

+270
-22
lines changed

5 files changed

+270
-22
lines changed

paperspace/cli/jobs.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import click
22

33
from paperspace import client, config
4-
from paperspace.cli import common
5-
from paperspace.cli.cli_types import json_string
6-
from paperspace.cli.common import del_if_value_is_none, ClickGroup
74
from paperspace.cli.cli import cli
5+
from paperspace.cli.cli_types import json_string
6+
from paperspace.cli.common import api_key_option, del_if_value_is_none, ClickGroup
87
from paperspace.commands import jobs as jobs_commands
98

109

@@ -20,7 +19,7 @@ def jobs_group():
2019
required=True,
2120
help="Delete job with given ID",
2221
)
23-
@common.api_key_option
22+
@api_key_option
2423
def delete_job(job_id, api_key=None):
2524
jobs_api = client.API(config.CONFIG_HOST, api_key=api_key)
2625
command = jobs_commands.DeleteJobCommand(api=jobs_api)
@@ -34,7 +33,7 @@ def delete_job(job_id, api_key=None):
3433
required=True,
3534
help="Stop job with given ID",
3635
)
37-
@common.api_key_option
36+
@api_key_option
3837
def stop_job(job_id, api_key=None):
3938
jobs_api = client.API(config.CONFIG_HOST, api_key=api_key)
4039
command = jobs_commands.StopJobCommand(api=jobs_api)
@@ -57,9 +56,9 @@ def stop_job(job_id, api_key=None):
5756
"experimentId",
5857
help="Use to filter jobs by experiment ID",
5958
)
60-
@common.api_key_option
59+
@api_key_option
6160
def list_jobs(api_key, **filters):
62-
common.del_if_value_is_none(filters)
61+
del_if_value_is_none(filters)
6362
jobs_api = client.API(config.CONFIG_HOST, api_key=api_key)
6463
command = jobs_commands.ListJobsCommand(api=jobs_api)
6564
command.execute(filters=filters)
@@ -87,7 +86,7 @@ def list_jobs(api_key, **filters):
8786
@click.option("--relDockerfilePath", "relDockerfilePath", help="Relative path to Dockerfile")
8887
@click.option("--registryUsername", "registryUsername", help="Docker registry username")
8988
@click.option("--registryPassword", "registryPassword", help="Docker registry password")
90-
@common.api_key_option
89+
@api_key_option
9190
def create_job(api_key, **kwargs):
9291
del_if_value_is_none(kwargs)
9392
jobs_api = client.API(config.CONFIG_HOST, api_key=api_key)
@@ -101,8 +100,44 @@ def create_job(api_key, **kwargs):
101100
"job_id",
102101
required=True
103102
)
104-
@common.api_key_option
103+
@api_key_option
105104
def list_logs(job_id, api_key=None):
106105
logs_api = client.API(config.CONFIG_LOG_HOST, api_key=api_key)
107106
command = jobs_commands.JobLogsCommand(api=logs_api)
108107
command.execute(job_id)
108+
109+
110+
@jobs_group.group("artifacts", help="Manage jobs' artifacts", cls=ClickGroup)
111+
def artifacts():
112+
pass
113+
114+
115+
@artifacts.command("destroy", help="Destroy job's artifacts")
116+
@click.argument("job_id")
117+
@click.option("--files", "files")
118+
@api_key_option
119+
def destroy_artifacts(job_id, api_key=None, files=None):
120+
jobs_api = client.API(config.CONFIG_HOST, api_key=api_key)
121+
command = jobs_commands.ArtifactsDestroyCommand(api=jobs_api)
122+
command.execute(job_id, files=files)
123+
124+
125+
@artifacts.command("get", help="Get job's artifacts")
126+
@click.argument("job_id")
127+
@api_key_option
128+
def get_artifacts(job_id, api_key=None):
129+
jobs_api = client.API(config.CONFIG_HOST, api_key=api_key)
130+
command = jobs_commands.ArtifactsGetCommand(api=jobs_api)
131+
command.execute(job_id)
132+
133+
134+
@artifacts.command("list", help="List job's artifacts")
135+
@click.argument("job_id")
136+
@click.option("--size", "-s", "size", help="Show file size", is_flag=True)
137+
@click.option("--links", "-l", "links", help="Show file URL", is_flag=True)
138+
@click.option("--files", "files", help="Get only given file (use at the end * as a wildcard)")
139+
@api_key_option
140+
def list_artifacts(job_id, size, links, files, api_key=None):
141+
jobs_api = client.API(config.CONFIG_HOST, api_key=api_key)
142+
command = jobs_commands.ArtifactsListCommand(api=jobs_api)
143+
command.execute(job_id=job_id, size=size, links=links, files=files)

paperspace/commands/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from collections import OrderedDict
2+
3+
from paperspace import logger
4+
5+
6+
class CommandBase(object):
7+
def __init__(self, api=None, logger_=logger):
8+
self.api = api
9+
self.logger = logger_
10+
11+
def _print_dict_recursive(self, input_dict, indent=0, tabulator=" "):
12+
for key, val in input_dict.items():
13+
self.logger.log("%s%s:" % (tabulator * indent, key))
14+
if type(val) is dict:
15+
self._print_dict_recursive(OrderedDict(val), indent + 1)
16+
else:
17+
self.logger.log("%s%s" % (tabulator * (indent + 1), val))

paperspace/commands/jobs.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
from click import style
55

66
from paperspace import config, client
7+
from paperspace.commands import common
8+
from paperspace.exceptions import BadResponseError
79
from paperspace.utils import get_terminal_lines
810
from paperspace.workspace import S3WorkspaceHandler
9-
from paperspace.commands import common
1011

1112

1213
class JobsCommandBase(common.CommandBase):
@@ -140,3 +141,76 @@ def execute(self, json_):
140141
self._log_message(response,
141142
"Job created",
142143
"Unknown error while creating job")
144+
145+
146+
class ArtifactsDestroyCommand(JobsCommandBase):
147+
def execute(self, job_id, files=None):
148+
url = '/jobs/{}/artifactsDestroy'.format(job_id)
149+
params = None
150+
if files:
151+
params = {'files': files}
152+
response = self.api.post(url, params=params)
153+
self._log_message(response, "Artifacts destroyed", "Unknown error while destroying artifacts")
154+
155+
156+
class ArtifactsGetCommand(JobsCommandBase):
157+
def execute(self, job_id):
158+
url = '/jobs/artifactsGet'
159+
response = self.api.get(url, params={'jobId': job_id})
160+
161+
self._log_artifacts(response)
162+
163+
def _log_artifacts(self, response):
164+
try:
165+
artifacts_json = response.json()
166+
if response.ok:
167+
self._print_dict_recursive(artifacts_json)
168+
else:
169+
raise BadResponseError(
170+
'{}: {}'.format(artifacts_json['error']['status'], artifacts_json['error']['message']))
171+
except (ValueError, KeyError, BadResponseError) as e:
172+
self.logger.error("Error occurred while getting artifacts: {}".format(str(e)))
173+
174+
175+
class ArtifactsListCommand(common.ListCommand):
176+
kwargs = {}
177+
178+
def execute(self, **kwargs):
179+
self.kwargs = kwargs
180+
return super(ArtifactsListCommand, self).execute(**kwargs)
181+
182+
@property
183+
def request_url(self):
184+
return '/jobs/artifactsList'
185+
186+
def _get_request_params(self, kwargs):
187+
params = {'jobId': kwargs['job_id']}
188+
189+
files = kwargs.get('files')
190+
if files:
191+
params['files'] = files
192+
size = kwargs.get('size', False)
193+
if size:
194+
params['size'] = size
195+
links = kwargs.get('links', False)
196+
if links:
197+
params['links'] = links
198+
199+
return params
200+
201+
def _get_table_data(self, artifacts):
202+
columns = ['Files']
203+
if self.kwargs.get('size'):
204+
columns.append('Size (in bytes)')
205+
if self.kwargs.get('links'):
206+
columns.append('URL')
207+
208+
data = [tuple(columns)]
209+
for artifact in artifacts:
210+
row = [artifact.get('file')]
211+
if 'size' in artifact.keys():
212+
row.append(artifact['size'])
213+
if 'url' in artifact.keys():
214+
row.append(artifact['url'])
215+
data.append(tuple(row))
216+
return data

tests/functional/test_jobs.py

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import mock
2+
import pytest
23
from click.testing import CliRunner
34

4-
import paperspace
55
from paperspace.cli import cli
66
from paperspace.client import default_headers
77
from tests import example_responses, MockResponse
88

99

10-
class TestListJobs(object):
11-
URL = "https://api.paperspace.io/jobs/getJobs/"
10+
class TestJobs(object):
11+
EXPECTED_STDOUT_WITH_WRONG_API_TOKEN = "Invalid API token\n"
12+
RESPONSE_JSON_WITH_WRONG_API_TOKEN = {"status": 400, "message": "Invalid API token"}
13+
1214
EXPECTED_HEADERS = default_headers.copy()
15+
EXPECTED_HEADERS_WITH_CHANGED_API_KEY = default_headers.copy()
16+
EXPECTED_HEADERS_WITH_CHANGED_API_KEY["X-API-Key"] = "some_key"
17+
18+
19+
class TestListJobs(TestJobs):
20+
URL = "https://api.paperspace.io/jobs/getJobs/"
1321
BASIC_COMMAND = ["jobs", "list"]
1422
EXPECTED_RESPONSE_JSON = example_responses.LIST_JOBS_RESPONSE_JSON
1523
EXPECTED_STDOUT = """+----------------+---------------------------+-------------------+----------------+--------------+--------------------------+
@@ -30,11 +38,6 @@ class TestListJobs(object):
3038
"""
3139

3240
BASIC_COMMAND_WITH_API_KEY = ["jobs", "list", "--apiKey", "some_key"]
33-
EXPECTED_HEADERS_WITH_CHANGED_API_KEY = paperspace.client.default_headers.copy()
34-
EXPECTED_HEADERS_WITH_CHANGED_API_KEY["X-API-Key"] = "some_key"
35-
36-
RESPONSE_JSON_WITH_WRONG_API_TOKEN = {"status": 400, "message": "Invalid API token"}
37-
EXPECTED_STDOUT_WITH_WRONG_API_TOKEN = "Invalid API token\n"
3841

3942
RESPONSE_JSON_WHEN_NO_JOBS_WERE_FOUND = []
4043
EXPECTED_STDOUT_WHEN_NO_JOBS_WERE_FOUND = "No data found\n"
@@ -169,11 +172,8 @@ def test_should_print_proper_message_when_jobs_list_was_used_with_mutually_exclu
169172
assert result.exit_code == 0
170173

171174

172-
class TestJobLogs(object):
175+
class TestJobLogs(TestJobs):
173176
URL = "https://logs.paperspace.io/jobs/logs?jobId=some_job_id&line=0"
174-
EXPECTED_HEADERS = default_headers.copy()
175-
EXPECTED_HEADERS_WITH_CHANGED_API_KEY = default_headers.copy()
176-
EXPECTED_HEADERS_WITH_CHANGED_API_KEY["X-API-Key"] = "some_key"
177177

178178
RESPONSE_JSON_WITH_WRONG_API_TOKEN = {"status": 400, "message": "Invalid API token"}
179179
EXPECTED_RESPONSE_JSON = example_responses.LIST_OF_LOGS_FOR_JOB
@@ -257,3 +257,86 @@ def test_should_print_error_message_when_error_status_code_received_but_no_conte
257257
params=None)
258258
assert result.output == "Error while parsing response data: No JSON\n"
259259
assert result.exit_code == 0
260+
261+
262+
class TestJobArtifactsCommands(TestJobs):
263+
runner = CliRunner()
264+
URL = "https://api.paperspace.io"
265+
266+
@mock.patch("paperspace.client.requests.post")
267+
def test_should_send_valid_post_request_when_destroying_artifacts_with_files_specified(self, post_patched):
268+
post_patched.return_value = MockResponse(status_code=200)
269+
job_id = "some_job_id"
270+
file_names = "some_file_names"
271+
result = self.runner.invoke(cli.cli, ["jobs", "artifacts", "destroy", job_id, "--files", file_names, "--apiKey",
272+
"some_key"])
273+
274+
post_patched.assert_called_with("{}/jobs/{}/artifactsDestroy".format(self.URL, job_id),
275+
files=None,
276+
headers=self.EXPECTED_HEADERS_WITH_CHANGED_API_KEY,
277+
json=None,
278+
params={"files": file_names})
279+
assert result.exit_code == 0
280+
281+
@mock.patch("paperspace.client.requests.post")
282+
def test_should_send_valid_post_request_when_destroying_artifacts_without_files_specified(self, post_patched):
283+
post_patched.return_value = MockResponse(status_code=200)
284+
job_id = "some_job_id"
285+
result = self.runner.invoke(cli.cli, ["jobs", "artifacts", "destroy", job_id, "--apiKey", "some_key"])
286+
287+
post_patched.assert_called_with("{}/jobs/{}/artifactsDestroy".format(self.URL, job_id),
288+
files=None,
289+
headers=self.EXPECTED_HEADERS_WITH_CHANGED_API_KEY,
290+
json=None,
291+
params=None)
292+
assert result.exit_code == 0
293+
294+
@mock.patch("paperspace.client.requests.get")
295+
def test_should_send_send_valid_get_request_and_receive_json_response(self, get_patched):
296+
get_patched.return_value = MockResponse(status_code=200)
297+
job_id = "some_job_id"
298+
result = self.runner.invoke(cli.cli, ["jobs", "artifacts", "get", job_id, "--apiKey", "some_key"])
299+
300+
get_patched.assert_called_with("{}/jobs/artifactsGet".format(self.URL),
301+
headers=self.EXPECTED_HEADERS_WITH_CHANGED_API_KEY,
302+
json=None,
303+
params={"jobId": job_id})
304+
assert result.exit_code == 0
305+
306+
@mock.patch("paperspace.client.requests.get")
307+
def test_should_send_valid_get_request_with_all_parameters_for_a_list_of_artifacts(self, get_patched):
308+
get_patched.return_value = MockResponse(status_code=200)
309+
job_id = "some_job_id"
310+
result = self.runner.invoke(cli.cli,
311+
["jobs", "artifacts", "list", job_id, "--apiKey", "some_key", "--size", "--links",
312+
"--files", "foo"])
313+
314+
get_patched.assert_called_with("{}/jobs/artifactsList".format(self.URL),
315+
headers=self.EXPECTED_HEADERS_WITH_CHANGED_API_KEY,
316+
json=None,
317+
params={"jobId": job_id,
318+
"size": True,
319+
"links": True,
320+
"files": "foo"})
321+
assert result.exit_code == 0
322+
323+
@mock.patch("paperspace.client.requests.get")
324+
@pytest.mark.parametrize('option,param', [("--size", "size"),
325+
("-s", "size"),
326+
("--links", "links"),
327+
("-l", "links")])
328+
def test_should_send_valid_get_request_with_valid_param_for_a_list_of_artifacts_for_both_formats_of_param(self,
329+
get_patched,
330+
option,
331+
param):
332+
get_patched.return_value = MockResponse(status_code=200)
333+
job_id = "some_job_id"
334+
result = self.runner.invoke(cli.cli,
335+
["jobs", "artifacts", "list", job_id, "--apiKey", "some_key"] + [option])
336+
337+
get_patched.assert_called_with("{}/jobs/artifactsList".format(self.URL),
338+
headers=self.EXPECTED_HEADERS_WITH_CHANGED_API_KEY,
339+
json=None,
340+
params={"jobId": job_id,
341+
param: True})
342+
assert result.exit_code == 0

tests/unit/test_base.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from collections import OrderedDict
2+
3+
import mock
4+
import pytest
5+
6+
from paperspace.commands import CommandBase
7+
8+
output_response = ""
9+
10+
11+
class TestBaseClass(object):
12+
def test_json_print(self):
13+
global output_response
14+
output_response = ""
15+
16+
def log_to_var(message):
17+
global output_response
18+
output_response = "{}{}\n".format(output_response, message)
19+
20+
logger_ = mock.MagicMock()
21+
logger_.log = log_to_var
22+
23+
input_dict = {
24+
"foo": {
25+
'bar': {
26+
"baz": "faz"
27+
}
28+
}
29+
}
30+
expected_string = """foo:
31+
bar:
32+
baz:
33+
faz
34+
"""
35+
36+
command = CommandBase(logger_=logger_)
37+
command._print_dict_recursive(OrderedDict(input_dict))
38+
39+
assert output_response == expected_string

0 commit comments

Comments
 (0)