Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Change Log

# Unreleased

- Allow `%` in metric names.
- Fix use of paths as artifact names for Windows systems.
- Remove option for storage of full system path for security.

## [v2.5.10](https://github.com/simvue-io/python-api/releases/tag/v2.5.10) - 2026-08-10

- Fix case where shell cannot be found for `add_process`.
Expand Down
820 changes: 461 additions & 359 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion simvue/api/objects/artifact/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,13 @@ def from_name(
headers=_config.headers,
verify=_config.server_verify,
)
if _response.status_code == http.HTTPStatus.NOT_FOUND:
return None

_json_response = get_json_from_response(
expected_type=list,
response=_response,
expected_status=[http.HTTPStatus.OK, http.HTTPStatus.NOT_FOUND],
expected_status=[http.HTTPStatus.OK],
scenario=f"Retrieval of artifact '{name}' for run '{run_id}'",
)

Expand Down
2 changes: 1 addition & 1 deletion simvue/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def _execute_process(
) -> tuple[subprocess.Popen, threading.Thread | None]:
thread_out = None

logger.debug(f"Launching process '{' '.join(command)}'.")
logger.debug("Launching process '%s'.", " ".join(command))

with (
pathlib.Path(f"{runner_name}_{proc_id}.err").open("w", encoding="utf-8") as err,
Expand Down
62 changes: 40 additions & 22 deletions simvue/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import types
import typing
import uuid
import warnings

import click
import humanfriendly
Expand All @@ -35,7 +36,11 @@
from simvue.api.objects.folder import Folder
from simvue.api.objects.grids import GridMetrics
from simvue.exception import ObjectDispatchError, ObjectNotFoundError, SimvueRunError
from simvue.utilities import prettify_pydantic
from simvue.utilities import (
get_file_artifact_storage_name,
prettify_pydantic,
skip_if_failed,
)

from .api.objects import (
Events,
Expand Down Expand Up @@ -67,9 +72,6 @@
validate_timestamp,
)
from .system import get_system
from .utilities import (
skip_if_failed,
)

try:
from typing import Self
Expand Down Expand Up @@ -278,7 +280,8 @@ def _handle_exception_throw(
)
if exc_type:
click.secho(
f"[simvue] Operation failed with {exc_type.__name__}: {value}.\n{_event_msg}",
f"[simvue] Operation failed with {exc_type.__name__}: "
+ f"{value}.\n{_event_msg}",
fg="red" if self._term_color else None,
bold=self._term_color,
)
Expand Down Expand Up @@ -1499,22 +1502,24 @@ def _add_metrics_to_dispatch(
return True

if not self._sv_obj or not self._dispatcher:
self._error("Cannot log metrics, run not initialised", join_on_fail)
self._error(
"Cannot log metrics, run not initialised", join_threads=join_on_fail
)
return False

if not self._active:
self._error("Run is not active", join_on_fail)
self._error("Run is not active", join_threads=join_on_fail)
return False

if self._status != "running":
self._error(
"Cannot log metrics when not in the running state",
join_on_fail,
join_threads=join_on_fail,
)
return False

if isinstance(timestamp, str) and not validate_timestamp(timestamp):
self._error("Invalid timestamp format", join_on_fail)
self._error("Invalid timestamp format", join_threads=join_on_fail)
return False

_data: dict[str, typing.Any] = {
Expand Down Expand Up @@ -1554,22 +1559,24 @@ def _add_tensors_to_dispatch(
return True

if not self._sv_obj or not self._dispatcher:
self._error("Cannot log tensors, run not initialised", join_on_fail)
self._error(
"Cannot log tensors, run not initialised", join_threads=join_on_fail
)
return False

if not self._active:
self._error("Run is not active", join_on_fail)
self._error("Run is not active", join_threads=join_on_fail)
return False

if self._status != "running":
self._error(
"Cannot log tensors when not in the running state",
join_on_fail,
join_threads=join_on_fail,
)
return False

if isinstance(timestamp, str) and not validate_timestamp(timestamp):
self._error("Invalid timestamp format", join_on_fail)
self._error("Invalid timestamp format", join_threads=join_on_fail)
return False

for tensor, array in tensors.items():
Expand Down Expand Up @@ -1927,6 +1934,7 @@ def save_file(
category: typing.Literal["input", "output", "code"],
file_type: str | None = None,
preserve_path: bool = False,
preserve_path_relative_to: typing.Literal["cwd", "git"] | None = None,
snapshot: bool = False,
name: typing.Annotated[str, pydantic.Field(pattern=NAME_REGEX)] | None = None,
metadata: dict[str, typing.Any] | None = None,
Expand All @@ -1945,7 +1953,11 @@ def save_file(
file_type : str, optional
the MIME file type else this is deduced, by default None
preserve_path : bool, optional
whether to preserve the path during storage, by default False
(DEPRECATED) whether to preserve the path during storage, by default False
preserve_path_relative_to : Literal['cwd', 'git'] | None, optional
preserve the file name path relative to either the current
working directory 'cwd', or the closest identified Git project
root 'git'. Default is None, do not preserve path.
snapshot : bool, optional
whether to take a snapshot of the file before uploading, by default False
name : str, optional
Expand All @@ -1959,6 +1971,19 @@ def save_file(
whether the upload was successful

"""
if preserve_path:
warnings.warn(
"Argument 'preserve_path' will be deprecated in Simvue Python API "
+ "v2.6, use 'preserve_path_relative_to' instead. Naively assumining "
+ "option 'cwd' for argument 'preserve_path_relative_to'.",
FutureWarning,
stacklevel=2,
)
preserve_path_relative_to = "cwd"

_stored_file_name = get_file_artifact_storage_name(
preserve_path_relative_to=preserve_path_relative_to, file_path=file_path
)
if not self._sv_obj or not self.id:
self._error("Cannot save files, run not initialised")
return False
Expand All @@ -1967,17 +1992,10 @@ def save_file(
self._error("Cannot upload output files for runs in the created state")
return False

stored_file_name: str = f"{file_path}"

if preserve_path and stored_file_name.startswith("./"):
stored_file_name = stored_file_name[2:]
elif not preserve_path:
stored_file_name = file_path.name

try:
# Register file
_artifact = FileArtifact.new(
name=name or stored_file_name,
name=name or _stored_file_name,
storage=self._storage_id,
file_path=file_path,
offline=self.mode == "offline",
Expand Down
64 changes: 64 additions & 0 deletions simvue/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import mimetypes
import os
import pathlib
import platform
import typing

import jwt
Expand Down Expand Up @@ -417,3 +418,66 @@ def get_mimetype_for_file(file_path: pathlib.Path) -> str:
# the case where the types conflict:
["override"],
)


def get_file_artifact_storage_name(
preserve_path_relative_to: typing.Literal["git", "cwd"] | None,
file_path: pathlib.Path,
) -> str:
"""Get the default name for a file artifact from the file path.

For security the full path on the file system is not stored, instead
a relative path is used with respect to either the current working directory
or the Git project root directory.

Parameters
----------
preserve_path_relative_to : Literal['git', 'cwd'] | None
get relative path as name either relative to the Git project root, or the
current working directory. If None, just use the file name.
file_path : pathlib.Path
the path of the target file to save.

Returns
-------
str
the name for the artifact.

Raises
------
RuntimeError
If 'git' is selected as the root, and the file is not part of a Git project.
"""
_relative_path: pathlib.Path | None = None
_stored_file_name: str | None = None
_name_search_path: pathlib.Path = file_path

# Windows Paths are not compatible so need to convert them
if platform.system() == "Windows":
_windows_path = pathlib.PureWindowsPath(file_path)
_stored_file_name = _windows_path.as_posix()
_name_search_path = pathlib.Path(_stored_file_name)

if preserve_path_relative_to == "git":
_git_directory: pathlib.Path | None = find_first_instance_of_file(".git")
if not _git_directory:
raise RuntimeError(
f"Cannot save file '{file_path}' with path "
+ "preservation set to mode 'git', no Git project found."
)
_relative_path = _name_search_path.resolve().relative_to(
_git_directory.parent.resolve()
)
_stored_file_name = f"{_relative_path}"
elif preserve_path_relative_to == "cwd":
_relative_path = _name_search_path.resolve().relative_to(
pathlib.Path.cwd().resolve()
)
_stored_file_name = f"{_relative_path}"
else:
_stored_file_name = file_path.name

if _stored_file_name and _stored_file_name.startswith("./"):
_stored_file_name = _stored_file_name[2:]

return _stored_file_name
Loading
Loading