Skip to content

Commit 1fac4cb

Browse files
committed
Merge branch 'v2.0' into wk9874/alert_deduplication
2 parents f1a1e69 + b608c5e commit 1fac4cb

File tree

15 files changed

+261
-66
lines changed

15 files changed

+261
-66
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ repos:
2323
args: [--branch, main, --branch, dev]
2424
- id: check-added-large-files
2525
- repo: https://github.com/astral-sh/ruff-pre-commit
26-
rev: v0.9.4
26+
rev: v0.9.6
2727
hooks:
2828
- id: ruff
2929
args: [ --fix, --exit-non-zero-on-fix, "--ignore=C901" ]

poetry.lock

Lines changed: 17 additions & 3 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ dependencies = [
5353
"psutil (>=6.1.1,<7.0.0)",
5454
"tenacity (>=9.0.0,<10.0.0)",
5555
"typing-extensions (>=4.12.2,<5.0.0) ; python_version < \"3.11\"",
56+
"deepmerge (>=2.0,<3.0)",
5657
]
5758

5859
[project.urls]

simvue/api/objects/base.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import msgpack
1818
import pydantic
1919

20+
from simvue.utilities import staging_merger
2021
from simvue.config.user import SimvueConfiguration
2122
from simvue.exception import ObjectNotFoundError
2223
from simvue.version import __version__
@@ -164,10 +165,14 @@ def __init__(
164165
)
165166
)
166167

167-
self._headers: dict[str, str] = {
168-
"Authorization": f"Bearer {self._user_config.server.token.get_secret_value()}",
169-
"User-Agent": _user_agent or f"Simvue Python client {__version__}",
170-
}
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+
)
171176

172177
self._params: dict[str, str] = {}
173178

@@ -527,7 +532,8 @@ def _cache(self) -> None:
527532
with self._local_staging_file.open() as in_f:
528533
_local_data = json.load(in_f)
529534

530-
_local_data |= self._staging
535+
staging_merger.merge(_local_data, self._staging)
536+
531537
with self._local_staging_file.open("w", encoding="utf-8") as out_f:
532538
json.dump(_local_data, out_f, indent=2)
533539

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
@@ -214,14 +214,16 @@ def heartbeat_timeout(self, time_seconds: int | None) -> None:
214214

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

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

226228
@property
227229
@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
@@ -418,7 +422,9 @@ def _create_dispatch_callback(
418422
if self._user_config.run.mode == "online" and not self._id:
419423
raise RuntimeError("Expected identifier for run")
420424

421-
if not self._user_config.server.url or not self._sv_obj:
425+
if (
426+
self._user_config.run.mode != "offline" and not self._user_config.server.url
427+
) or not self._sv_obj:
422428
raise RuntimeError("Cannot commence dispatch, run not initialised")
423429

424430
def _dispatch_callback(
@@ -464,7 +470,7 @@ def _start(self, reconnect: bool = False) -> bool:
464470

465471
logger.debug("Starting run")
466472

467-
if self._sv_obj:
473+
if self._sv_obj and self._sv_obj.status != "running":
468474
self._sv_obj.status = self._status
469475
self._sv_obj.commit()
470476

@@ -564,6 +570,7 @@ def init(
564570
folder: typing.Annotated[
565571
str, pydantic.Field(None, pattern=FOLDER_REGEX)
566572
] = None,
573+
notification: typing.Literal["none", "all", "error", "lost"] = "none",
567574
running: bool = True,
568575
retention_period: str | None = None,
569576
timeout: int | None = 180,
@@ -585,6 +592,9 @@ def init(
585592
description of the run, by default None
586593
folder : str, optional
587594
folder within which to store the run, by default "/"
595+
notification: typing.Literal["none", "all", "error", "lost"], optional
596+
whether to notify the user by email upon completion of the run if
597+
the run is in the specified state, by default "none"
588598
running : bool, optional
589599
whether to set the status as running or created, the latter implying
590600
the run will be commenced at a later time. Default is True.
@@ -620,13 +630,10 @@ def init(
620630

621631
self._term_color = not no_color
622632

623-
try:
624-
self._folder = get_folder_from_path(path=folder)
625-
except ObjectNotFoundError:
626-
self._folder = Folder.new(
627-
path=folder, offline=self._user_config.run.mode == "offline"
628-
)
629-
self._folder.commit() # type: ignore
633+
self._folder = Folder.new(
634+
path=folder, offline=self._user_config.run.mode == "offline"
635+
)
636+
self._folder.commit() # type: ignore
630637

631638
if isinstance(visibility, str) and visibility not in ("public", "tenant"):
632639
self._error(
@@ -637,7 +644,9 @@ def init(
637644
self._error("invalid mode specified, must be online, offline or disabled")
638645
return False
639646

640-
if not self._user_config.server.token or not self._user_config.server.url:
647+
if self._user_config.run.mode != "offline" and (
648+
not self._user_config.server.token or not self._user_config.server.url
649+
):
641650
self._error(
642651
"Unable to get URL and token from environment variables or config file"
643652
)
@@ -686,6 +695,7 @@ def init(
686695
self._sv_obj.metadata = (metadata or {}) | git_info(os.getcwd()) | environment()
687696
self._sv_obj.heartbeat_timeout = timeout
688697
self._sv_obj.alerts = []
698+
self._sv_obj.notifications = notification
689699

690700
if self._status == "running":
691701
self._sv_obj.system = get_system()

simvue/utilities.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
import os
1212
import pathlib
1313
import typing
14-
1514
import jwt
15+
from deepmerge import Merger
1616

1717
from datetime import timezone
1818
from simvue.models import DATETIME_FORMAT
@@ -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
@@ -395,3 +396,18 @@ def get_mimetype_for_file(file_path: pathlib.Path) -> str:
395396
"""Return MIME type for the given file"""
396397
_guess, *_ = mimetypes.guess_type(file_path)
397398
return _guess or "application/octet-stream"
399+
400+
401+
# Create a new Merge strategy for merging local file and staging attributes
402+
staging_merger = Merger(
403+
# pass in a list of tuple, with the
404+
# strategies you are looking to apply
405+
# to each type.
406+
[(list, ["override"]), (dict, ["merge"]), (set, ["union"])],
407+
# next, choose the fallback strategies,
408+
# applied to all other types:
409+
["override"],
410+
# finally, choose the strategies in
411+
# the case where the types conflict:
412+
["override"],
413+
)

0 commit comments

Comments
 (0)