Skip to content

Commit 9de939c

Browse files
committed
Merge branch 'v2.0' into wk9874/offline_fixes
2 parents 1a28c76 + 3639a4f commit 9de939c

File tree

11 files changed

+177
-54
lines changed

11 files changed

+177
-54
lines changed

simvue/api/objects/base.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,14 @@ def __init__(
165165
)
166166
)
167167

168-
self._headers: dict[str, str] = {
169-
"Authorization": f"Bearer {self._user_config.server.token.get_secret_value()}",
170-
"User-Agent": _user_agent or f"Simvue Python client {__version__}",
171-
}
168+
self._headers: dict[str, str] = (
169+
{
170+
"Authorization": f"Bearer {self._user_config.server.token.get_secret_value()}",
171+
"User-Agent": _user_agent or f"Simvue Python client {__version__}",
172+
}
173+
if not self._offline
174+
else {}
175+
)
172176

173177
self._params: dict[str, str] = {}
174178

simvue/api/objects/metrics.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def __init__(
3737
def new(
3838
cls, *, run: str, offline: bool = False, metrics: list[MetricSet], **kwargs
3939
):
40-
"""Create a new Events entry on the Simvue server"""
40+
"""Create a new Metrics entry on the Simvue server"""
4141
return Metrics(
4242
run=run,
4343
metrics=[metric.model_dump() for metric in metrics],
@@ -51,27 +51,23 @@ def get(
5151
cls,
5252
metrics: list[str],
5353
xaxis: typing.Literal["timestamp", "step", "time"],
54+
runs: list[str],
5455
*,
5556
count: pydantic.PositiveInt | None = None,
5657
offset: pydantic.PositiveInt | None = None,
5758
**kwargs,
5859
) -> typing.Generator[MetricSet, None, None]:
5960
_class_instance = cls(_read_only=True, _local=True)
60-
if (
61-
_data := cls._get_all_objects(
62-
count,
63-
offset,
64-
metrics=json.dumps(metrics),
65-
xaxis=xaxis,
66-
**kwargs,
67-
).get("data")
68-
) is None:
69-
raise RuntimeError(
70-
f"Expected key 'data' for retrieval of {_class_instance.__class__.__name__.lower()}s"
71-
)
72-
73-
for _entry in _data:
74-
yield MetricSet(**_entry)
61+
_data = cls._get_all_objects(
62+
count,
63+
offset,
64+
metrics=json.dumps(metrics),
65+
runs=json.dumps(runs),
66+
xaxis=xaxis,
67+
**kwargs,
68+
)
69+
# TODO: Temp fix, just return the dictionary. Not sure what format we really want this in...
70+
return _data
7571

7672
@pydantic.validate_call
7773
def span(self, run_ids: list[str]) -> dict[str, int | float]:

simvue/api/objects/run.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,14 +215,16 @@ def heartbeat_timeout(self, time_seconds: int | None) -> None:
215215

216216
@property
217217
@staging_check
218-
def notifications(self) -> typing.Literal["none", "email"]:
219-
return self._get_attribute("notifications")
218+
def notifications(self) -> typing.Literal["none", "all", "error", "lost"]:
219+
return self._get_attribute("notifications")["state"]
220220

221221
@notifications.setter
222222
@write_only
223223
@pydantic.validate_call
224-
def notifications(self, notifications: typing.Literal["none", "email"]) -> None:
225-
self._staging["notifications"] = notifications
224+
def notifications(
225+
self, notifications: typing.Literal["none", "all", "error", "lost"]
226+
) -> None:
227+
self._staging["notifications"] = {"state": notifications}
226228

227229
@property
228230
@staging_check

simvue/config/parameters.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,23 @@
2121

2222

2323
class ServerSpecifications(pydantic.BaseModel):
24-
url: pydantic.AnyHttpUrl
25-
token: pydantic.SecretStr
24+
url: pydantic.AnyHttpUrl | None
25+
token: pydantic.SecretStr | None
2626

2727
@pydantic.field_validator("url")
2828
@classmethod
29-
def url_to_api_url(cls, v: typing.Any) -> str:
29+
def url_to_api_url(cls, v: typing.Any) -> str | None:
30+
if not v:
31+
return
3032
if f"{v}".endswith("/api"):
3133
return f"{v}"
3234
_url = URL(f"{v}") / "api"
3335
return f"{_url}"
3436

3537
@pydantic.field_validator("token")
36-
def check_token(cls, v: typing.Any) -> str:
38+
def check_token(cls, v: typing.Any) -> str | None:
39+
if not v:
40+
return
3741
value = v.get_secret_value()
3842
if not (expiry := get_expiry(value)):
3943
raise AssertionError("Failed to parse Simvue token - invalid token form")

simvue/config/user.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,10 @@ def fetch(
212212

213213
_run_mode = mode or _config_dict["run"].get("mode") or "online"
214214

215-
if not _server_url:
215+
if not _server_url and _run_mode != "offline":
216216
raise RuntimeError("No server URL was specified")
217217

218-
if not _server_token:
218+
if not _server_token and _run_mode != "offline":
219219
raise RuntimeError("No server token was specified")
220220

221221
_config_dict["server"]["token"] = _server_token

simvue/run.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
import psutil
3131

3232
from simvue.api.objects.alert.fetch import Alert
33-
from simvue.api.objects.folder import Folder, get_folder_from_path
34-
from simvue.exception import ObjectNotFoundError, SimvueRunError
33+
from simvue.api.objects.folder import Folder
34+
from simvue.exception import SimvueRunError
3535
from simvue.utilities import prettify_pydantic
3636

3737

@@ -184,9 +184,13 @@ def __init__(
184184
if self._user_config.metrics.resources_metrics_interval < 1
185185
else self._user_config.metrics.resources_metrics_interval
186186
)
187-
self._headers: dict[str, str] = {
188-
"Authorization": f"Bearer {self._user_config.server.token.get_secret_value()}"
189-
}
187+
self._headers: dict[str, str] = (
188+
{
189+
"Authorization": f"Bearer {self._user_config.server.token.get_secret_value()}"
190+
}
191+
if mode != "offline"
192+
else {}
193+
)
190194
self._sv_obj: RunObject | None = None
191195
self._pid: int | None = 0
192196
self._shutdown_event: threading.Event | None = None
@@ -411,7 +415,9 @@ def _create_dispatch_callback(
411415
if self._user_config.run.mode == "online" and not self._id:
412416
raise RuntimeError("Expected identifier for run")
413417

414-
if not self._user_config.server.url or not self._sv_obj:
418+
if (
419+
self._user_config.run.mode != "offline" and not self._user_config.server.url
420+
) or not self._sv_obj:
415421
raise RuntimeError("Cannot commence dispatch, run not initialised")
416422

417423
def _dispatch_callback(
@@ -459,7 +465,7 @@ def _start(self, reconnect: bool = False) -> bool:
459465

460466
self._start_time = time.time()
461467

462-
if self._sv_obj:
468+
if self._sv_obj and self._sv_obj.status != "running":
463469
self._sv_obj.status = self._status
464470
self._sv_obj.started = self._start_time
465471
self._sv_obj.commit()
@@ -558,6 +564,7 @@ def init(
558564
folder: typing.Annotated[
559565
str, pydantic.Field(None, pattern=FOLDER_REGEX)
560566
] = None,
567+
notification: typing.Literal["none", "all", "error", "lost"] = "none",
561568
running: bool = True,
562569
retention_period: str | None = None,
563570
timeout: int | None = 180,
@@ -579,6 +586,9 @@ def init(
579586
description of the run, by default None
580587
folder : str, optional
581588
folder within which to store the run, by default "/"
589+
notification: typing.Literal["none", "all", "error", "lost"], optional
590+
whether to notify the user by email upon completion of the run if
591+
the run is in the specified state, by default "none"
582592
running : bool, optional
583593
whether to set the status as running or created, the latter implying
584594
the run will be commenced at a later time. Default is True.
@@ -614,13 +624,10 @@ def init(
614624

615625
self._term_color = not no_color
616626

617-
try:
618-
self._folder = get_folder_from_path(path=folder)
619-
except ObjectNotFoundError:
620-
self._folder = Folder.new(
621-
path=folder, offline=self._user_config.run.mode == "offline"
622-
)
623-
self._folder.commit() # type: ignore
627+
self._folder = Folder.new(
628+
path=folder, offline=self._user_config.run.mode == "offline"
629+
)
630+
self._folder.commit() # type: ignore
624631

625632
if isinstance(visibility, str) and visibility not in ("public", "tenant"):
626633
self._error(
@@ -631,7 +638,9 @@ def init(
631638
self._error("invalid mode specified, must be online, offline or disabled")
632639
return False
633640

634-
if not self._user_config.server.token or not self._user_config.server.url:
641+
if self._user_config.run.mode != "offline" and (
642+
not self._user_config.server.token or not self._user_config.server.url
643+
):
635644
self._error(
636645
"Unable to get URL and token from environment variables or config file"
637646
)
@@ -683,6 +692,7 @@ def init(
683692
self._sv_obj.heartbeat_timeout = timeout
684693
self._sv_obj.alerts = []
685694
self._sv_obj.created = time.time()
695+
self._sv_obj.notifications = notification
686696

687697
if self._status == "running":
688698
self._sv_obj.system = get_system()

simvue/utilities.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,17 @@ def find_first_instance_of_file(
5050
if isinstance(file_names, str):
5151
file_names = [file_names]
5252

53-
for root, _, files in os.walk(os.getcwd(), topdown=False):
54-
for file_name in file_names:
55-
if file_name in files:
56-
return pathlib.Path(root).joinpath(file_name)
53+
for file_name in file_names:
54+
_user_file = pathlib.Path.cwd().joinpath(file_name)
55+
if _user_file.exists():
56+
return _user_file
5757

5858
# If the user is running on different mounted volume or outside
5959
# of their user space then the above will not return the file
6060
if check_user_space:
6161
for file_name in file_names:
62-
if os.path.exists(_user_file := pathlib.Path.home().joinpath(file_name)):
62+
_user_file = pathlib.Path.home().joinpath(file_name)
63+
if _user_file.exists():
6364
return _user_file
6465

6566
return None

tests/functional/test_dispatch.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,10 @@ def callback(___: list[typing.Any], _: str, args=check_dict, var=variable) -> No
6767
event.set()
6868

6969
dispatcher.join()
70+
time.sleep(0.1)
7071

7172
for variable in variables:
72-
assert check_dict[variable]["counter"] >= 2 if overload_buffer else 1, f"Check of counter for dispatcher '{variable}' failed with count = {check_dict[variable]['counter']}"
73+
assert check_dict[variable]["counter"] >= (2 if overload_buffer else 1), f"Check of counter for dispatcher '{variable}' failed with count = {check_dict[variable]['counter']}"
7374
assert time.time() - start_time < time_threshold
7475

7576

tests/unit/test_events.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from simvue.api.objects import Events, Folder, Run
99
from simvue.models import DATETIME_FORMAT
10+
from simvue.sender import sender
1011

1112
@pytest.mark.api
1213
@pytest.mark.online
@@ -30,3 +31,39 @@ def test_events_creation_online() -> None:
3031
_run.delete()
3132
_folder.delete(recursive=True, delete_runs=True, runs_only=False)
3233

34+
@pytest.mark.api
35+
@pytest.mark.offline
36+
def test_events_creation_offline() -> None:
37+
_uuid: str = f"{uuid.uuid4()}".split("-")[0]
38+
_folder_name = f"/simvue_unit_testing/{_uuid}"
39+
_folder = Folder.new(path=_folder_name, offline=True)
40+
_run = Run.new(folder=_folder_name, offline=True)
41+
_folder.commit()
42+
_run.commit()
43+
_timestamp = datetime.datetime.now().strftime(DATETIME_FORMAT)
44+
_events = Events.new(
45+
run=_run.id,
46+
events=[
47+
{"message": "This is a test!", "timestamp": _timestamp}
48+
],
49+
offline=True
50+
)
51+
_events.commit()
52+
with _events._local_staging_file.open() as in_f:
53+
_local_data = json.load(in_f)
54+
55+
assert _local_data.get("run") == _run.id
56+
assert _local_data.get("events")[0].get("message") == "This is a test!"
57+
assert _local_data.get("events")[0].get("timestamp") == _timestamp
58+
59+
_id_mapping = sender(_events._local_staging_file.parents[1], 1, 10, ["folders", "runs", "events"])
60+
time.sleep(1)
61+
62+
# Get online version of events
63+
_online_events = Events(_id_mapping.get(_events.id))
64+
_event_content = next(_online_events.get(run_id=_id_mapping.get(_run.id)))
65+
assert _event_content.message == "This is a test!"
66+
assert _event_content.timestamp == _timestamp
67+
68+
_run.delete()
69+
_folder.delete(recursive=True, delete_runs=True, runs_only=False)

tests/unit/test_metrics.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import uuid
77

88
from simvue.api.objects import Metrics, Folder, Run
9+
from simvue.models import DATETIME_FORMAT
10+
from simvue.sender import sender
911

1012
@pytest.mark.api
1113
@pytest.mark.online
@@ -38,9 +40,60 @@ def test_metrics_creation_online() -> None:
3840
)
3941
assert _metrics.to_dict()
4042
_metrics.commit()
41-
assert _metrics.get(metrics=["x", "y", "z"], xaxis="step")
43+
assert _metrics.get(metrics=["x", "y", "z"], xaxis="step", runs=[_run.id])
4244
assert _metrics.span(run_ids=[_run.id])
4345
assert _metrics.names(run_ids=[_run.id])
4446
_run.delete()
4547
_folder.delete(recursive=True, delete_runs=True, runs_only=False)
4648

49+
@pytest.mark.api
50+
@pytest.mark.offline
51+
def test_metrics_creation_offline() -> None:
52+
_uuid: str = f"{uuid.uuid4()}".split("-")[0]
53+
_folder_name = f"/simvue_unit_testing/{_uuid}"
54+
_folder = Folder.new(path=_folder_name, offline=True)
55+
_run = Run.new(name="hello", folder=_folder_name, offline=True)
56+
_folder.commit()
57+
_run.commit()
58+
59+
_values = {
60+
"x": 1,
61+
"y": 2.0,
62+
"z": True
63+
}
64+
_time: int = 1
65+
_step: int = 1
66+
_timestamp = datetime.datetime.now().strftime(DATETIME_FORMAT)
67+
_metrics = Metrics.new(
68+
run=_run.id,
69+
metrics=[
70+
{
71+
"timestamp": _timestamp,
72+
"time": _time,
73+
"step": _step,
74+
"values": _values,
75+
}
76+
],
77+
offline=True
78+
)
79+
_metrics.commit()
80+
with _metrics._local_staging_file.open() as in_f:
81+
_local_data = json.load(in_f)
82+
83+
assert _local_data.get("run") == _run.id
84+
assert _local_data.get("metrics")[0].get("values") == _values
85+
assert _local_data.get("metrics")[0].get("timestamp") == _timestamp
86+
assert _local_data.get("metrics")[0].get("step") == _step
87+
assert _local_data.get("metrics")[0].get("time") == _time
88+
89+
_id_mapping = sender(_metrics._local_staging_file.parents[1], 1, 10, ["folders", "runs", "metrics"])
90+
time.sleep(1)
91+
92+
# Get online version of metrics
93+
_online_metrics = Metrics(_id_mapping.get(_metrics.id))
94+
_data = _online_metrics.get(metrics=["x", "y", "z"], runs=[_id_mapping.get(_run.id)], xaxis="step")
95+
assert sorted(_online_metrics.names(run_ids=[_id_mapping.get(_run.id)])) == sorted(_values.keys())
96+
assert _data.get(_id_mapping.get(_run.id)).get('y')[0].get('value') == 2.0
97+
assert _data.get(_id_mapping.get(_run.id)).get('y')[0].get('step') == 1
98+
_run.delete()
99+
_folder.delete(recursive=True, delete_runs=True, runs_only=False)

0 commit comments

Comments
 (0)