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

## [v2.4.1](https://github.com/simvue-io/python-api/releases/tag/v2.4.1) - 2026-03-31

- Moved to using `threading.Event` as termination trigger events and added deprecation notice for `multiprocessing.Event`.
- Fixed creation of duplicate alerts.
- Fixed case where alert specifications being `0` led to incorrect falsification.

## [v2.4.0](https://github.com/simvue-io/python-api/releases/tag/v2.4.0) - 2026-03-23

- Added search for Simvue configuration within the parent file hierarchy of the current directory.
Expand Down
9 changes: 2 additions & 7 deletions examples/Geant4/geant4_simvue.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,11 @@
monitoring the yield of key particles of interest
"""

import multiparser
import multiparser.parsing.file as mp_file_parse
import threading
import simvue
import uproot
import multiprocessing
import typing
import click
import pathlib
import os
import tempfile

from particle import Particle

Expand Down Expand Up @@ -77,7 +72,7 @@ def root_file_parser(

for i in range(events):
# Create new multiprocessing Trigger which will register when the simulation is complete
_trigger = multiprocessing.Event()
_trigger = threading.Event()

if i % 10 == 0:
click.secho(
Expand Down
85 changes: 41 additions & 44 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,38 +1,36 @@
[project]
name = "simvue"
version = "2.4.0"
version = "2.4.1"
description = "Simulation tracking and monitoring"
authors = [
{name = "Simvue Development Team", email = "info@simvue.io"}
]
authors = [{ name = "Simvue Development Team", email = "info@simvue.io" }]
license = "Apache v2"
requires-python = ">=3.10,<3.15"
readme = "README.md"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: Apache Software License",
"Natural Language :: English",
"Operating System :: Unix",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering",
"Topic :: System :: Monitoring",
"Topic :: Utilities",
"Typing :: Typed"
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: Apache Software License",
"Natural Language :: English",
"Operating System :: Unix",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering",
"Topic :: System :: Monitoring",
"Topic :: Utilities",
"Typing :: Typed",
]

keywords = [
"tracking",
"monitoring",
"metrics",
"alerting",
"metrics-gathering"
"tracking",
"monitoring",
"metrics",
"alerting",
"metrics-gathering",
]
dependencies = [
"requests (>=2.32.3,<3.0.0)",
Expand Down Expand Up @@ -93,26 +91,25 @@ extend-exclude = ["tests", "examples", "notebooks"]

[tool.pytest.ini_options]
addopts = "-p no:warnings --no-cov -n 0"
testpaths = [
"tests"
]
testpaths = ["tests"]
markers = [
"eco: tests for emission metrics",
"client: tests of Simvue client",
"dispatch: test data dispatcher",
"run: test the simvue Run class",
"utilities: test simvue utilities module",
"scenario: test scenarios",
"executor: tests of executors",
"config: tests of simvue configuration",
"api: tests of RestAPI functionality",
"unix: tests for UNIX systems only",
"metadata: tests of metadata gathering functions",
"online: tests for online functionality",
"offline: tests for offline functionality",
"local: tests of functionality which do not involve a server or writing to an offline cache file",
"object_retrieval: tests relating to retrieval of objects from the server",
"object_removal: tests relating to removal of objects from the server",
"cli: Sender CLI tests",
"eco: tests for emission metrics",
"client: tests of Simvue client",
"dispatch: test data dispatcher",
"run: test the simvue Run class",
"utilities: test simvue utilities module",
"scenario: test scenarios",
"executor: tests of executors",
"config: tests of simvue configuration",
"api: tests of RestAPI functionality",
"unix: tests for UNIX systems only",
"metadata: tests of metadata gathering functions",
"online: tests for online functionality",
"offline: tests for offline functionality",
"local: tests of functionality which do not involve a server or writing to an offline cache file",
"object_retrieval: tests relating to retrieval of objects from the server",
"object_removal: tests relating to removal of objects from the server",
]

[tool.interrogate]
Expand Down
39 changes: 38 additions & 1 deletion simvue/api/objects/alert/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
from simvue.api.url import URL
from simvue.models import NAME_REGEX, DATETIME_FORMAT

try:
from typing import override
except ImportError:
from typing_extensions import override # noqa: UP035


class AlertBase(SimvueObject):
"""Class for interfacing with Simvue alerts
Expand All @@ -23,7 +28,7 @@ class AlertBase(SimvueObject):
"""

@classmethod
def new(cls, **kwargs):
def new(cls, read_only: bool = False, **kwargs):
"""Create a new alert"""
pass

Expand All @@ -38,6 +43,38 @@ def __init__(self, identifier: str | None = None, **kwargs) -> None:
"status",
]

def _compare_objects(self, other: "AlertBase") -> bool:
return all(
[
self.name == other.name,
self.description == other.description,
self.source == other.source,
self.notification == other.notification,
]
)

@override
def __eq__(self, other: "AlertBase") -> bool:
"""Check if alerts are the same."""

# Need to ensure objects are read-only for this
# operation as we do not want staging to alter
_self_is_read_only: bool = self._read_only
_other_is_read_only: bool = other._read_only
self.read_only(True)
other.read_only(True)

_comparison = self._compare_objects(other)

# Restore to write allowed unless the input object
# was read-only to begin with
if not _self_is_read_only:
self.read_only(False, clear_staged=False)
if not _other_is_read_only:
other.read_only(False, clear_staged=False)

return _comparison

def compare(self, other: "AlertBase") -> bool:
"""Compare this alert to another"""
return type(self) is type(other) and self.name == other.name
Expand Down
16 changes: 10 additions & 6 deletions simvue/api/objects/alert/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
import pydantic

try:
from typing import Self
from typing import Self, override
except ImportError:
from typing_extensions import Self
from typing_extensions import Self, override
from simvue.api.objects.base import write_only
from .base import AlertBase, staging_check
from simvue.models import NAME_REGEX
Expand Down Expand Up @@ -109,6 +109,13 @@ def new(
_alert._params = {"deduplicate": True}
return _alert

@override
def _compare_objects(self, other: "AlertBase") -> bool:
"""Compare Events Alerts."""
if not isinstance(other, EventsAlert):
return False
return super()._compare_objects(other) and self.alert == other.alert


class EventAlertDefinition:
"""Event alert definition sub-class"""
Expand All @@ -117,11 +124,8 @@ def __init__(self, alert: EventsAlert) -> None:
"""Initialise an alert definition with its parent alert"""
self._sv_obj = alert

def compare(self, other: "EventAlertDefinition") -> bool:
def __eq__(self, other: "EventAlertDefinition") -> bool:
"""Compare this definition with that of another EventAlert"""
if not isinstance(other, EventAlertDefinition):
return False

return all(
[
self.frequency == other.frequency,
Expand Down
43 changes: 28 additions & 15 deletions simvue/api/objects/alert/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
Aggregate = typing.Literal["average", "sum", "at least one", "all"]
Rule = typing.Literal["is above", "is below", "is inside range", "is outside range"]

try:
from typing import override
except ImportError:
from typing_extensions import override # noqa: UP035


class MetricsThresholdAlert(AlertBase):
"""
Expand Down Expand Up @@ -134,6 +139,12 @@ def new(

return _alert

@override
def _compare_objects(self, other: "AlertBase") -> bool:
if not isinstance(other, MetricsThresholdAlert):
return False
return super()._compare_objects(other) and self.alert == other.alert


class MetricsRangeAlert(AlertBase):
"""
Expand Down Expand Up @@ -169,9 +180,12 @@ def __init__(self, identifier: str | None = None, **kwargs) -> None:
"range_high",
]

def compare(self, other: "MetricsRangeAlert") -> bool:
@override
def _compare_objects(self, other: "AlertBase") -> bool:
"""Compare two MetricRangeAlerts"""
return self.alert.compare(other) if super().compare(other) else False
if not isinstance(other, MetricsRangeAlert):
return False
return super()._compare_objects(other) and self.alert == other.alert

@classmethod
@pydantic.validate_call
Expand Down Expand Up @@ -258,7 +272,7 @@ def __init__(self, alert: MetricsRangeAlert) -> None:
"""Initialise definition with target alert"""
self._sv_obj = alert

def compare(self, other: "MetricsAlertDefinition") -> bool:
def __eq__(self, other: "MetricsAlertDefinition") -> bool:
"""Compare a MetricsAlertDefinition with another"""
return all(
[
Expand All @@ -272,7 +286,7 @@ def compare(self, other: "MetricsAlertDefinition") -> bool:
@property
def aggregation(self) -> Aggregate:
"""Retrieve the aggregation strategy for this alert"""
if not (_aggregation := self._sv_obj.get_alert().get("aggregation")):
if (_aggregation := self._sv_obj.get_alert().get("aggregation")) is None:
raise RuntimeError(
"Expected key 'aggregation' in alert definition retrieval"
)
Expand All @@ -281,14 +295,14 @@ def aggregation(self) -> Aggregate:
@property
def rule(self) -> Rule:
"""Retrieve the rule for this alert"""
if not (_rule := self._sv_obj.get_alert().get("rule")):
if (_rule := self._sv_obj.get_alert().get("rule")) is None:
raise RuntimeError("Expected key 'rule' in alert definition retrieval")
return _rule

@property
def window(self) -> int:
"""Retrieve the aggregation window for this alert"""
if not (_window := self._sv_obj.get_alert().get("window")):
if (_window := self._sv_obj.get_alert().get("window")) is None:
raise RuntimeError("Expected key 'window' in alert definition retrieval")
return _window

Expand All @@ -315,32 +329,31 @@ def frequency(self, frequency: int) -> None:
class MetricThresholdAlertDefinition(MetricsAlertDefinition):
"""Alert definition for metric threshold alerts"""

def compare(self, other: "MetricThresholdAlertDefinition") -> bool:
def __eq__(self, other: "MetricThresholdAlertDefinition") -> bool:
"""Compare this MetricThresholdAlertDefinition with another"""
if not isinstance(other, MetricThresholdAlertDefinition):
if not super().__eq__(other):
return False

return all([super().compare(other), self.threshold == other.threshold])
return self.threshold == other.threshold

@property
def threshold(self) -> float:
"""Retrieve the threshold value for this alert"""
if not (threshold_l := self._sv_obj.get_alert().get("threshold")):
if (threshold_l := self._sv_obj.get_alert().get("threshold")) is None:
raise RuntimeError("Expected key 'threshold' in alert definition retrieval")
return threshold_l


class MetricRangeAlertDefinition(MetricsAlertDefinition):
"""Alert definition for metric range alerts"""

def compare(self, other: "MetricRangeAlertDefinition") -> bool:
def __eq__(self, other: "MetricRangeAlertDefinition") -> bool:
"""Compare a MetricRangeAlertDefinition with another"""
if not isinstance(other, MetricRangeAlertDefinition):
if not super().__eq__(other):
return False

return all(
[
super().compare(other),
self.range_high == other.range_high,
self.range_low == other.range_low,
]
Expand All @@ -349,14 +362,14 @@ def compare(self, other: "MetricRangeAlertDefinition") -> bool:
@property
def range_low(self) -> float:
"""Retrieve the lower limit for metric range"""
if not (range_l := self._sv_obj.get_alert().get("range_low")):
if (range_l := self._sv_obj.get_alert().get("range_low")) is None:
raise RuntimeError("Expected key 'range_low' in alert definition retrieval")
return range_l

@property
def range_high(self) -> float:
"""Retrieve upper limit for metric range"""
if not (range_u := self._sv_obj.get_alert().get("range_high")):
if (range_u := self._sv_obj.get_alert().get("range_high")) is None:
raise RuntimeError(
"Expected key 'range_high' in alert definition retrieval"
)
Expand Down
Loading
Loading