Skip to content

Commit be7fa45

Browse files
authored
Merge pull request #703 from simvue-io/wk9874/offline_fixes
General fixes for v2.0.0a1
2 parents 3639a4f + 45be82f commit be7fa45

File tree

11 files changed

+352
-293
lines changed

11 files changed

+352
-293
lines changed

poetry.lock

Lines changed: 227 additions & 222 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ dependencies = [
4646
"humanfriendly (>=10.0,<11.0)",
4747
"randomname (>=0.2.1,<0.3.0)",
4848
"codecarbon (>=2.8.3,<3.0.0)",
49-
"numpy (>=2.2.2,<3.0.0)",
49+
"numpy (>=2.0.0,<3.0.0)",
5050
"flatdict (>=4.0.1,<5.0.0)",
5151
"semver (>=3.0.4,<4.0.0)",
5252
"email-validator (>=2.2.0,<3.0.0)",

simvue/api/objects/artifact/fetch.py

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,62 @@ def __new__(cls, identifier: str | None = None, **kwargs):
2323
else:
2424
return ObjectArtifact(identifier=identifier, **kwargs)
2525

26+
@classmethod
27+
def from_run(
28+
cls,
29+
run_id: str,
30+
category: typing.Literal["input", "output", "code"] | None = None,
31+
**kwargs,
32+
) -> typing.Generator[tuple[str, FileArtifact | ObjectArtifact], None, None]:
33+
"""Return artifacts associated with a given run.
34+
35+
Parameters
36+
----------
37+
run_id : str
38+
The ID of the run to retriece artifacts from
39+
category : typing.Literal["input", "output", "code"] | None, optional
40+
The category of artifacts to return, by default all artifacts are returned
41+
42+
Returns
43+
-------
44+
typing.Generator[tuple[str, FileArtifact | ObjectArtifact], None, None]
45+
The artifacts
46+
47+
Yields
48+
------
49+
Iterator[typing.Generator[tuple[str, FileArtifact | ObjectArtifact], None, None]]
50+
identifier for artifact
51+
the artifact itself as a class instance
52+
53+
Raises
54+
------
55+
ObjectNotFoundError
56+
Raised if artifacts could not be found for that run
57+
"""
58+
_temp = ArtifactBase(**kwargs)
59+
_url = URL(_temp._user_config.server.url) / f"runs/{run_id}/artifacts"
60+
_response = sv_get(
61+
url=f"{_url}", params={"category": category}, headers=_temp._headers
62+
)
63+
_json_response = get_json_from_response(
64+
expected_type=list,
65+
response=_response,
66+
expected_status=[http.HTTPStatus.OK, http.HTTPStatus.NOT_FOUND],
67+
scenario=f"Retrieval of artifacts for run '{run_id}'",
68+
)
69+
70+
if _response.status_code == http.HTTPStatus.NOT_FOUND or not _json_response:
71+
raise ObjectNotFoundError(
72+
_temp._label, category, extra=f"for run '{run_id}'"
73+
)
74+
75+
for _entry in _json_response:
76+
_id = _entry.pop("id")
77+
yield (
78+
_id,
79+
Artifact(_local=True, _read_only=True, identifier=_id, **_entry),
80+
)
81+
2682
@classmethod
2783
def from_name(
2884
cls, run_id: str, name: str, **kwargs
@@ -99,21 +155,9 @@ def get(
99155
if (_data := _json_response.get("data")) is None:
100156
raise RuntimeError(f"Expected key 'data' for retrieval of {_label}s")
101157

102-
_out_dict: dict[str, FileArtifact | ObjectArtifact] = {}
103-
104158
for _entry in _data:
105159
_id = _entry.pop("id")
106-
if _entry["original_path"]:
107-
yield (
108-
_id,
109-
FileArtifact(
110-
_local=True, _read_only=True, identifier=_id, **_entry
111-
),
112-
)
113-
else:
114-
yield (
115-
_id,
116-
ObjectArtifact(
117-
_local=True, _read_only=True, identifier=_id, **_entry
118-
),
119-
)
160+
yield (
161+
_id,
162+
Artifact(_local=True, _read_only=True, identifier=_id, **_entry),
163+
)

simvue/api/objects/artifact/file.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,15 @@ def new(
5252

5353
if _mime_type not in get_mimetypes():
5454
raise ValueError(f"Invalid MIME type '{mime_type}' specified")
55-
file_path = pathlib.Path(file_path)
56-
_file_size = file_path.stat().st_size
57-
_file_orig_path = file_path.expanduser().absolute()
58-
_file_checksum = calculate_sha256(f"{file_path}", is_file=True)
5955

60-
kwargs.pop("original_path", None)
61-
kwargs.pop("size", None)
62-
kwargs.pop("checksum", None)
56+
if _file_orig_path := kwargs.pop("original_path", None):
57+
_file_size = kwargs.pop("size")
58+
_file_checksum = kwargs.pop("checksum")
59+
else:
60+
file_path = pathlib.Path(file_path)
61+
_file_size = file_path.stat().st_size
62+
_file_orig_path = file_path.expanduser().absolute()
63+
_file_checksum = calculate_sha256(f"{file_path}", is_file=True)
6364

6465
_artifact = FileArtifact(
6566
name=name,

simvue/api/objects/run.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import typing
1212
import pydantic
1313
import datetime
14+
import time
1415

1516
try:
1617
from typing import Self
@@ -257,6 +258,19 @@ def created(self) -> datetime.datetime | None:
257258
datetime.datetime.strptime(_created, DATETIME_FORMAT) if _created else None
258259
)
259260

261+
@created.setter
262+
@write_only
263+
@pydantic.validate_call
264+
def created(self, created: datetime.datetime) -> None:
265+
self._staging["created"] = created.strftime(DATETIME_FORMAT)
266+
267+
@property
268+
@staging_check
269+
def runtime(self) -> datetime.datetime | None:
270+
"""Retrieve created datetime for the run"""
271+
_runtime: str | None = self._get_attribute("runtime")
272+
return time.strptime(_runtime, "%H:%M:%S.%f") if _runtime else None
273+
260274
@property
261275
@staging_check
262276
def started(self) -> datetime.datetime | None:

simvue/client.py

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import contextlib
1010
import json
1111
import logging
12-
import os
1312
import pathlib
1413
import typing
1514
import http
@@ -45,12 +44,9 @@
4544
def _download_artifact_to_file(
4645
artifact: Artifact, output_dir: pathlib.Path | None
4746
) -> None:
48-
try:
49-
_file_name = os.path.basename(artifact.name)
50-
except AttributeError:
51-
_file_name = os.path.basename(artifact)
52-
_output_file = (output_dir or pathlib.Path.cwd()).joinpath(_file_name)
53-
47+
_output_file = (output_dir or pathlib.Path.cwd()).joinpath(artifact.name)
48+
# If this is a hierarchical structure being downloaded, need to create directories
49+
_output_file.parent.mkdir(parents=True, exist_ok=True)
5450
with _output_file.open("wb") as out_f:
5551
for content in artifact.download_content():
5652
out_f.write(content)
@@ -565,34 +561,27 @@ def get_artifacts_as_files(
565561
run_id: str,
566562
category: typing.Literal["input", "output", "code"] | None = None,
567563
output_dir: pydantic.DirectoryPath | None = None,
568-
startswith: str | None = None,
569-
contains: str | None = None,
570-
endswith: str | None = None,
571564
) -> None:
572565
"""Retrieve artifacts from the given run as a set of files
573566
574567
Parameters
575568
----------
576569
run_id : str
577570
the unique identifier for the run
571+
category : typing.Literal["input", "output", "code"] |
572+
the type of files to retrieve
578573
output_dir : str | None, optional
579574
location to download files to, the default of None will download
580575
them to the current working directory
581-
startswith : str, optional
582-
only download artifacts with this prefix in their name, by default None
583-
contains : str, optional
584-
only download artifacts containing this term in their name, by default None
585-
endswith : str, optional
586-
only download artifacts ending in this term in their name, by default None
587576
588577
Raises
589578
------
590579
RuntimeError
591580
if there was a failure retrieving artifacts from the server
592581
"""
593-
_artifacts: typing.Generator[tuple[str, Artifact], None, None] = Artifact.get(
594-
runs=json.dumps([run_id]), category=category
595-
) # type: ignore
582+
_artifacts: typing.Generator[tuple[str, Artifact], None, None] = (
583+
Artifact.from_run(run_id=run_id, category=category)
584+
)
596585

597586
with ThreadPoolExecutor(CONCURRENT_DOWNLOADS) as executor:
598587
futures = [

simvue/factory/dispatch/queued.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
QUEUE_SIZE = 10000
2222

2323
logger = logging.getLogger(__name__)
24-
logger.setLevel(logging.DEBUG)
2524

2625

2726
class QueuedDispatcher(threading.Thread, DispatcherBaseClass):

simvue/run.py

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import typing
2626
import warnings
2727
import uuid
28-
28+
import randomname
2929
import click
3030
import psutil
3131

@@ -252,19 +252,12 @@ def _handle_exception_throw(
252252
else f"An exception was thrown: {_exception_thrown}"
253253
)
254254

255-
self.log_event(_event_msg)
256-
self.set_status("terminated" if _is_terminated else "failed")
257-
258255
# If the dispatcher has already been aborted then this will
259256
# fail so just continue without the event
260257
with contextlib.suppress(RuntimeError):
261-
self.log_event(f"{_exception_thrown}: {value}")
258+
self.log_event(_event_msg)
262259

263-
if not traceback:
264-
return
265-
266-
with contextlib.suppress(RuntimeError):
267-
self.log_event(f"Traceback: {traceback}")
260+
self.set_status("terminated" if _is_terminated else "failed")
268261

269262
def __exit__(
270263
self,
@@ -470,12 +463,13 @@ def _start(self, reconnect: bool = False) -> bool:
470463

471464
logger.debug("Starting run")
472465

466+
self._start_time = time.time()
467+
473468
if self._sv_obj and self._sv_obj.status != "running":
474469
self._sv_obj.status = self._status
470+
self._sv_obj.started = self._start_time
475471
self._sv_obj.commit()
476472

477-
self._start_time = time.time()
478-
479473
if self._pid == 0:
480474
self._pid = os.getpid()
481475

@@ -655,6 +649,8 @@ def init(
655649
if name and not re.match(r"^[a-zA-Z0-9\-\_\s\/\.:]+$", name):
656650
self._error("specified name is invalid")
657651
return False
652+
elif not name and self._user_config.run.mode == "offline":
653+
name = randomname.get_name()
658654

659655
self._name = name
660656

@@ -695,6 +691,7 @@ def init(
695691
self._sv_obj.metadata = (metadata or {}) | git_info(os.getcwd()) | environment()
696692
self._sv_obj.heartbeat_timeout = timeout
697693
self._sv_obj.alerts = []
694+
self._sv_obj.created = time.time()
698695
self._sv_obj.notifications = notification
699696

700697
if self._status == "running":
@@ -724,7 +721,7 @@ def init(
724721
fg="green" if self._term_color else None,
725722
)
726723
click.secho(
727-
f"[simvue] Monitor in the UI at {self._user_config.server.url}/dashboard/runs/run/{self._id}",
724+
f"[simvue] Monitor in the UI at {self._user_config.server.url.rsplit('/api', 1)[0]}/dashboard/runs/run/{self._id}",
728725
bold=self._term_color,
729726
fg="green" if self._term_color else None,
730727
)
@@ -1469,7 +1466,7 @@ def set_status(
14691466
) -> bool:
14701467
"""Set run status
14711468
1472-
status to assign to this run
1469+
status to assign to this run once finished
14731470
14741471
Parameters
14751472
----------
@@ -1489,6 +1486,7 @@ def set_status(
14891486

14901487
if self._sv_obj:
14911488
self._sv_obj.status = status
1489+
self._sv_obj.endtime = time.time()
14921490
self._sv_obj.commit()
14931491
return True
14941492

@@ -1641,9 +1639,7 @@ def add_alerts(
16411639
if names and not ids:
16421640
try:
16431641
if alerts := Alert.get(offline=self._user_config.run.mode == "offline"):
1644-
for alert in alerts:
1645-
if alert[1].name in names:
1646-
ids.append(alert[1].id)
1642+
ids += [id for id, alert in alerts if alert.name in names]
16471643
else:
16481644
self._error("No existing alerts")
16491645
return False

simvue/utilities.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ def validate_timestamp(timestamp):
358358
Validate a user-provided timestamp
359359
"""
360360
try:
361-
datetime.datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S.%f")
361+
datetime.datetime.strptime(timestamp, DATETIME_FORMAT)
362362
except ValueError:
363363
return False
364364

tests/functional/test_client.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,21 @@ def test_get_artifacts_as_files(
145145
create_test_run[1]["run_id"], category=category, output_dir=tempd
146146
)
147147
files = [os.path.basename(i) for i in glob.glob(os.path.join(tempd, "*"))]
148-
if not category or category == "input":
149-
assert create_test_run[1]["file_1"] in files
150-
if not category or category == "output":
151-
assert create_test_run[1]["file_2"] in files
152-
if not category or category == "code":
153-
assert create_test_run[1]["file_3"] in files
148+
149+
if not category:
150+
expected_files = ["file_1", "file_2", "file_3"]
151+
elif category == "input":
152+
expected_files = ["file_1"]
153+
elif category == "output":
154+
expected_files = ["file_2"]
155+
elif category == "code":
156+
expected_files = ["file_3"]
157+
158+
for file in ["file_1", "file_2", "file_3"]:
159+
if file in expected_files:
160+
assert create_test_run[1][file] in files
161+
else:
162+
assert create_test_run[1][file] not in files
154163

155164

156165
@pytest.mark.dependency

0 commit comments

Comments
 (0)