From 599c82fe2d2edbd645b1d4fac7b47c13cc6e761f Mon Sep 17 00:00:00 2001 From: Matt Field Date: Fri, 13 Dec 2024 16:32:20 +0000 Subject: [PATCH 1/5] Added alert validator --- simvue/models.py | 102 ++++++++++++++++++++++++++++++++++++++++++++++- simvue/run.py | 8 +++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/simvue/models.py b/simvue/models.py index 235acd04..cb5b58c7 100644 --- a/simvue/models.py +++ b/simvue/models.py @@ -1,5 +1,5 @@ -from typing import Annotated, Dict, List, Optional, Union -from pydantic import BaseModel, Field, StringConstraints, PositiveInt +from typing import Annotated, Dict, List, Optional, Union, Literal +from pydantic import BaseModel, Field, StringConstraints, PositiveInt, ValidationInfo, field_validator FOLDER_REGEX: str = r"^/.*" NAME_REGEX: str = r"^[a-zA-Z0-9\-\_\s\/\.:]+$" @@ -19,3 +19,101 @@ class RunInput(BaseModel): folder: str = Field(pattern=FOLDER_REGEX) status: Optional[str] = None ttl: Optional[PositiveInt] = None + +def check_input( + value_to_check: Union[str, float, int, None], + validation_info: ValidationInfo, + name_of_parameter: str, + required_when_name: str, + required_when_values: list + ): + """Checks that alert fields are correctly defined in cases where parameters are only required if another parameter is set. + + Parameters + ---------- + value_to_check : str | float | int | None + The value entered for the field being validated + other_values : dict + The values given for all other fields defined in the validator up to this point + name_of_parameter : str + The name of the field being validated + required_when_name : str + The name of the parameter which defines whether the field is required + required_when_values : list + The values of the parameter above which mean that the field is required + + Returns + ------- + value_to_check : str | float | int | None + The validated value + + Raises + ------ + ValueError + Raised if the parameter IS required but has NOT been provided, or if the parameter is NOT required but HAS been provided + """ + other_values = validation_info.data + if (other_values.get(required_when_name) in required_when_values) and value_to_check is None: + raise ValueError(f"'{name_of_parameter}' must be provided for alerts using '{required_when_name} = {other_values.get(required_when_name)}'.") + elif (other_values.get(required_when_name) not in required_when_values) and value_to_check is not None: + raise ValueError(f"'{name_of_parameter}' must not be provided for alerts using '{required_when_name} = {other_values.get(required_when_name)}'.") + return value_to_check + +class AlertValidator(BaseModel, extra='forbid'): + source: Literal["metrics", "events"] + frequency: PositiveInt + notification: Optional[Literal["none", "email"]] = Field(default="none") + description: Optional[str] = Field(default=None) + # For event based alerts: + pattern: Optional[str] = Field(default=None, validate_default=True) + # For metric based alerts: + rule: Optional[Literal["is above", "is below", "is outside range", "is inside range"]] = Field(default=None, validate_default=True) + metric: Optional[str] = Field(default=None, validate_default=True) + window: Optional[PositiveInt] = Field(default=None, validate_default=True) + # For 'is above' or 'is below': + threshold: Optional[Union[int, float]] = Field(default=None, validate_default=True) + # For 'is outside range' or 'is inside range' + range_low: Optional[Union[int, float]] = Field(default=None, validate_default=True) + range_high: Optional[Union[int, float]] = Field(default=None, validate_default=True) + aggregation: Optional[Literal["average", "sum", "at least one", "all"]] = Field(default="average", validate_default=True) + trigger_abort: Optional[bool] = Field(default=None, validate_default=True) + + @field_validator("pattern") + def check_pattern(cls, v, validation_info): + """Checks that pattern is specified if the alert source is events.""" + return check_input(v, validation_info, "pattern", "source", ["events"]) + + @field_validator("rule") + def check_rule(cls, v, validation_info): + """Checks that rule is specified if the alert source is metrics.""" + return check_input(v, validation_info, "rule", "source", ["metrics"]) + + @field_validator("metric") + def check_metric(cls, v, validation_info): + """Checks that metric is specified if the alert source is metrics.""" + return check_input(v, validation_info, "metric", "source", ["metrics"]) + + @field_validator("aggregation") + def check_aggregation(cls, v, validation_info): + """Checks that aggregation is specified if the alert source is metrics.""" + return check_input(v, validation_info, "aggregation", "source", ["metrics"]) + + @field_validator("window") + def check_window(cls, v, validation_info): + """Checks that window is specified if the alert source is metrics.""" + return check_input(v, validation_info, "window", "source", ["metrics"]) + + @field_validator("threshold") + def check_threshold(cls, v, validation_info): + """Checks that threshold is specified if the alert rule is 'is above' or 'is below'.""" + return check_input(v, validation_info, "threshold", "rule", ["is above", "is below"]) + + @field_validator("range_low") + def check_range_low(cls, v, validation_info): + """Checks that range_low is specified if the alert rule is 'is outside range' or 'is inside range'.""" + return check_input(v, validation_info, "range_low", "rule", ["is outside range", "is inside range"]) + + @field_validator("range_high") + def check_range_high(cls, v, validation_info): + """Checks that range_high is specified if the alert rule is 'is outside range' or 'is inside range'.""" + return check_input(v, validation_info, "range_high", "rule", ["is outside range", "is inside range"]) \ No newline at end of file diff --git a/simvue/run.py b/simvue/run.py index 7fec18aa..10867df7 100644 --- a/simvue/run.py +++ b/simvue/run.py @@ -39,7 +39,7 @@ from .executor import Executor from .factory.proxy import Simvue from .metrics import get_gpu_metrics, get_process_cpu, get_process_memory -from .models import RunInput, FOLDER_REGEX, NAME_REGEX, MetricKeyString +from .models import RunInput, AlertValidator, FOLDER_REGEX, NAME_REGEX, MetricKeyString from .serialization import serialize_object from .system import get_system from .metadata import git_info, environment @@ -1851,6 +1851,12 @@ def create_alert( "description": description, "abort": trigger_abort, } + + try: + AlertValidator(**alert_definition, notification=notification, source=source, description=description, abort=trigger_abort) + except ValidationError as err: + self._error(f"{err}") + return None # Check if the alert already exists alert_id: typing.Optional[str] = None From 6406c021cff42083626c13347214cc770758678e Mon Sep 17 00:00:00 2001 From: Matt Field Date: Mon, 16 Dec 2024 12:34:57 +0000 Subject: [PATCH 2/5] Added validator model --- simvue/models.py | 12 +++++++++--- simvue/run.py | 7 ++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/simvue/models.py b/simvue/models.py index cb5b58c7..ea0ed181 100644 --- a/simvue/models.py +++ b/simvue/models.py @@ -60,8 +60,9 @@ def check_input( return value_to_check class AlertValidator(BaseModel, extra='forbid'): - source: Literal["metrics", "events"] - frequency: PositiveInt + name: Optional[str] = Field(None, pattern=NAME_REGEX) + source: Literal["metrics", "events", "user"] + frequency: Optional[PositiveInt] = Field(default=None, validate_default=True) notification: Optional[Literal["none", "email"]] = Field(default="none") description: Optional[str] = Field(default=None) # For event based alerts: @@ -75,8 +76,13 @@ class AlertValidator(BaseModel, extra='forbid'): # For 'is outside range' or 'is inside range' range_low: Optional[Union[int, float]] = Field(default=None, validate_default=True) range_high: Optional[Union[int, float]] = Field(default=None, validate_default=True) - aggregation: Optional[Literal["average", "sum", "at least one", "all"]] = Field(default="average", validate_default=True) + aggregation: Optional[Literal["average", "sum", "at least one", "all"]] = Field(default="average") trigger_abort: Optional[bool] = Field(default=None, validate_default=True) + + @field_validator("frequency") + def check_frequency(cls, v, validation_info): + """Checks that frequency is specified if the alert source is metrics or events.""" + return check_input(v, validation_info, "frequency", "source", ["metrics", "events"]) @field_validator("pattern") def check_pattern(cls, v, validation_info): diff --git a/simvue/run.py b/simvue/run.py index 10867df7..6c5aeaa2 100644 --- a/simvue/run.py +++ b/simvue/run.py @@ -1853,7 +1853,12 @@ def create_alert( } try: - AlertValidator(**alert_definition, notification=notification, source=source, description=description, abort=trigger_abort) + to_validate = alert + to_validate["trigger_abort"] = to_validate.pop("abort") + if definition := to_validate.pop("alert", None): + to_validate = {**to_validate, **definition} + AlertValidator(**to_validate) + except ValidationError as err: self._error(f"{err}") return None From 6f1e6983edc1504420b974ebcaefae936f01e35c Mon Sep 17 00:00:00 2001 From: Matt Field Date: Mon, 16 Dec 2024 12:37:59 +0000 Subject: [PATCH 3/5] Removed if statements --- simvue/run.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/simvue/run.py b/simvue/run.py index 6c5aeaa2..95cad91a 100644 --- a/simvue/run.py +++ b/simvue/run.py @@ -1812,18 +1812,6 @@ def create_alert( self._error("Cannot add alert, run not initialised") return None - if rule in ("is below", "is above") and threshold is None: - self._error("threshold must be defined for the specified alert type") - return None - - if rule in ("is outside range", "is inside range") and ( - range_low is None or range_high is None - ): - self._error( - "range_low and range_high must be defined for the specified alert type" - ) - return None - alert_definition = {} if source == "metrics": From d2bd06b77f1f7568821a750463fef73d0292ab75 Mon Sep 17 00:00:00 2001 From: Matt Field Date: Mon, 16 Dec 2024 14:59:26 +0000 Subject: [PATCH 4/5] Add test for alert validator --- tests/unit/test_validators.py | 156 ++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/unit/test_validators.py diff --git a/tests/unit/test_validators.py b/tests/unit/test_validators.py new file mode 100644 index 00000000..27308a6d --- /dev/null +++ b/tests/unit/test_validators.py @@ -0,0 +1,156 @@ +import pytest +import pydantic +from simvue.models import AlertValidator +invalid_alerts = [ + # Invalid 'name' kwarg + { + "name": "test&failed", + "source": "metrics", + "rule": "is below", + "frequency": 1, + "window": 1, + "threshold": 0.8, + }, + # Missing required 'metric' kwarg + { + "name": "test_alert", + "source": "metrics", + "rule": "is below", + "frequency": 1, + "window": 1, + "threshold": 0.8, + }, + # Cannot have 'pattern' kwarg when source is metrics + { + "name": "test_alert", + "source": "metrics", + "pattern": "wrong", + "rule": "is below", + "metric": "accuracy", + "frequency": 1, + "window": 1, + "threshold": 0.8, + }, + # Invalid 'source' input + { + "name": "test_alert", + "source": "alerts", + "rule": "is below", + "metric": "accuracy", + "frequency": 1, + "window": 1, + "threshold": 0.8, + }, + # Invalid type for 'frequency' + { + "name": "test_alert", + "source": "metrics", + "rule": "is below", + "metric": "accuracy", + "frequency": "one", + "window": 1, + "threshold": 0.8, + }, + # Unexpected kwarg 'new' + { + "name": "test_alert", + "source": "metrics", + "rule": "is below", + "metric": "accuracy", + "frequency": "one", + "window": 1, + "threshold": 0.8, + "new": "hi" + }, + # Shouldn't specify aggregation when source is events + { + "name": "test_alert", + "source": "events", + "frequency": 1, + "pattern": "test pattern", + "aggregation": "sum", + }, + # Shouldn't specify frequency (or any other things) when source is user + { + "name": "test_alert", + "source": "user", + "frequency": 1, + }, +] + +valid_alerts = [ + # Valid metric based alert, no optional inputs specified + { + "name": "test_alert", + "source": "metrics", + "metric": "my_fraction", + "rule": "is below", + "frequency": 1, + "window": 1, + "threshold": 0.8, + }, + # Valid metric based range alert, no optional inputs specified + { + "name": "test_alert", + "source": "metrics", + "metric": "my_fraction", + "rule": "is outside range", + "frequency": 1, + "window": 1, + "range_low": 0.8, + "range_high": 0.9 + }, + # Valid metric based alert, all optional inputs specified + { + "name": "test_alert", + "source": "metrics", + "metric": "my_fraction", + "rule": "is below", + "frequency": 1, + "window": 1, + "threshold": 0.8, + "description": "My test alert", + "aggregation": "all", + "notification": "email", + "trigger_abort": True + }, + # Valid events based alert, no optional inputs specified + { + "name": "test_alert", + "source": "events", + "frequency": 1, + "pattern": "Look for this pattern!", + }, + # Valid events based alert, all optional inputs specified + { + "name": "test_alert", + "source": "events", + "frequency": 1, + "pattern": "Look for this pattern!", + "description": "My test alert", + "notification": "email", + "trigger_abort": True + }, + # Valid user alert, no optional inputs specified + { + "name": "test_alert", + "source": "user", + }, + # Valid user alert, all optional inputs specified + { + "name": "test_alert", + "source": "user", + "description": "My test alert", + "notification": "email", + "trigger_abort": True + }, +] + +@pytest.mark.parametrize("alert_definitions", invalid_alerts) +def test_invalid_alert_configs(alert_definitions): + with pytest.raises(pydantic.ValidationError): + AlertValidator(**alert_definitions) + +@pytest.mark.parametrize("alert_definitions", valid_alerts) +def test_valid_alert_configs(alert_definitions): + AlertValidator(**alert_definitions) \ No newline at end of file From 22198fe1661d0e154a6f3b9c4f6d64ef47be09d7 Mon Sep 17 00:00:00 2001 From: Matt Field Date: Mon, 16 Dec 2024 15:12:15 +0000 Subject: [PATCH 5/5] Added pre-commit to pyproject.toml and ran hooks --- poetry.lock | 185 ++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + simvue/models.py | 91 ++++++++++++++++------- simvue/run.py | 4 +- 4 files changed, 248 insertions(+), 33 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0fe45bc8..3aacd9ec 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -142,6 +142,17 @@ files = [ [package.dependencies] pycparser = "*" +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + [[package]] name = "charset-normalizer" version = "3.4.0" @@ -473,7 +484,6 @@ files = [ {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, - {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, @@ -484,7 +494,6 @@ files = [ {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, - {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, @@ -541,6 +550,17 @@ files = [ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] +[[package]] +name = "distlib" +version = "0.3.9" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -590,6 +610,22 @@ cli = ["yaspin"] fastapi = ["fastapi", "makefun (>=1.14.0,<2.0.0)"] flask = ["flask"] +[[package]] +name = "filelock" +version = "3.16.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] + [[package]] name = "fire" version = "0.7.0" @@ -779,6 +815,20 @@ files = [ [package.dependencies] pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} +[[package]] +name = "identify" +version = "2.6.3" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"}, + {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.10" @@ -1201,6 +1251,17 @@ files = [ {file = "msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e"}, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + [[package]] name = "numpy" version = "2.2.0" @@ -1465,6 +1526,22 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa typing = ["typing-extensions"] xmp = ["defusedxml"] +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + [[package]] name = "plotly" version = "5.24.1" @@ -1495,6 +1572,24 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pre-commit" +version = "4.0.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, + {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "prometheus-client" version = "0.21.1" @@ -1918,6 +2013,68 @@ files = [ {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + [[package]] name = "questionary" version = "2.0.1" @@ -2349,6 +2506,26 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "virtualenv" +version = "20.28.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +files = [ + {file = "virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0"}, + {file = "virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + [[package]] name = "wcwidth" version = "0.2.13" @@ -2380,4 +2557,4 @@ plot = ["matplotlib", "plotly"] [metadata] lock-version = "2.0" python-versions = "^3.10,<3.14" -content-hash = "a0a75aaf22c306b7c3cebd3da3dff3cc31bcb370a51664a2138c513980789d80" +content-hash = "d282cbe5dbe45bf5f0853221860f39bffbbf8141a6bc7b2e1d8766b08620a544" diff --git a/pyproject.toml b/pyproject.toml index 1f01f87d..446fa5ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ pytest-mock = "^3.14.0" pytest-sugar = "^1.0.0" pytest-xdist = "^3.6.1" jinja2 = "^3.1.4" +pre-commit = "^4.0.1" [build-system] requires = ["poetry-core"] diff --git a/simvue/models.py b/simvue/models.py index ea0ed181..2ff1a5eb 100644 --- a/simvue/models.py +++ b/simvue/models.py @@ -1,5 +1,12 @@ from typing import Annotated, Dict, List, Optional, Union, Literal -from pydantic import BaseModel, Field, StringConstraints, PositiveInt, ValidationInfo, field_validator +from pydantic import ( + BaseModel, + Field, + StringConstraints, + PositiveInt, + ValidationInfo, + field_validator, +) FOLDER_REGEX: str = r"^/.*" NAME_REGEX: str = r"^[a-zA-Z0-9\-\_\s\/\.:]+$" @@ -19,14 +26,15 @@ class RunInput(BaseModel): folder: str = Field(pattern=FOLDER_REGEX) status: Optional[str] = None ttl: Optional[PositiveInt] = None - + + def check_input( - value_to_check: Union[str, float, int, None], - validation_info: ValidationInfo, - name_of_parameter: str, - required_when_name: str, - required_when_values: list - ): + value_to_check: Union[str, float, int, None], + validation_info: ValidationInfo, + name_of_parameter: str, + required_when_name: str, + required_when_values: list, +): """Checks that alert fields are correctly defined in cases where parameters are only required if another parameter is set. Parameters @@ -53,13 +61,22 @@ def check_input( Raised if the parameter IS required but has NOT been provided, or if the parameter is NOT required but HAS been provided """ other_values = validation_info.data - if (other_values.get(required_when_name) in required_when_values) and value_to_check is None: - raise ValueError(f"'{name_of_parameter}' must be provided for alerts using '{required_when_name} = {other_values.get(required_when_name)}'.") - elif (other_values.get(required_when_name) not in required_when_values) and value_to_check is not None: - raise ValueError(f"'{name_of_parameter}' must not be provided for alerts using '{required_when_name} = {other_values.get(required_when_name)}'.") + if ( + other_values.get(required_when_name) in required_when_values + ) and value_to_check is None: + raise ValueError( + f"'{name_of_parameter}' must be provided for alerts using '{required_when_name} = {other_values.get(required_when_name)}'." + ) + elif ( + other_values.get(required_when_name) not in required_when_values + ) and value_to_check is not None: + raise ValueError( + f"'{name_of_parameter}' must not be provided for alerts using '{required_when_name} = {other_values.get(required_when_name)}'." + ) return value_to_check -class AlertValidator(BaseModel, extra='forbid'): + +class AlertValidator(BaseModel, extra="forbid"): name: Optional[str] = Field(None, pattern=NAME_REGEX) source: Literal["metrics", "events", "user"] frequency: Optional[PositiveInt] = Field(default=None, validate_default=True) @@ -68,7 +85,9 @@ class AlertValidator(BaseModel, extra='forbid'): # For event based alerts: pattern: Optional[str] = Field(default=None, validate_default=True) # For metric based alerts: - rule: Optional[Literal["is above", "is below", "is outside range", "is inside range"]] = Field(default=None, validate_default=True) + rule: Optional[ + Literal["is above", "is below", "is outside range", "is inside range"] + ] = Field(default=None, validate_default=True) metric: Optional[str] = Field(default=None, validate_default=True) window: Optional[PositiveInt] = Field(default=None, validate_default=True) # For 'is above' or 'is below': @@ -76,34 +95,38 @@ class AlertValidator(BaseModel, extra='forbid'): # For 'is outside range' or 'is inside range' range_low: Optional[Union[int, float]] = Field(default=None, validate_default=True) range_high: Optional[Union[int, float]] = Field(default=None, validate_default=True) - aggregation: Optional[Literal["average", "sum", "at least one", "all"]] = Field(default="average") + aggregation: Optional[Literal["average", "sum", "at least one", "all"]] = Field( + default="average" + ) trigger_abort: Optional[bool] = Field(default=None, validate_default=True) - + @field_validator("frequency") def check_frequency(cls, v, validation_info): """Checks that frequency is specified if the alert source is metrics or events.""" - return check_input(v, validation_info, "frequency", "source", ["metrics", "events"]) - + return check_input( + v, validation_info, "frequency", "source", ["metrics", "events"] + ) + @field_validator("pattern") def check_pattern(cls, v, validation_info): """Checks that pattern is specified if the alert source is events.""" return check_input(v, validation_info, "pattern", "source", ["events"]) - + @field_validator("rule") def check_rule(cls, v, validation_info): """Checks that rule is specified if the alert source is metrics.""" return check_input(v, validation_info, "rule", "source", ["metrics"]) - + @field_validator("metric") def check_metric(cls, v, validation_info): """Checks that metric is specified if the alert source is metrics.""" return check_input(v, validation_info, "metric", "source", ["metrics"]) - + @field_validator("aggregation") def check_aggregation(cls, v, validation_info): """Checks that aggregation is specified if the alert source is metrics.""" return check_input(v, validation_info, "aggregation", "source", ["metrics"]) - + @field_validator("window") def check_window(cls, v, validation_info): """Checks that window is specified if the alert source is metrics.""" @@ -112,14 +135,28 @@ def check_window(cls, v, validation_info): @field_validator("threshold") def check_threshold(cls, v, validation_info): """Checks that threshold is specified if the alert rule is 'is above' or 'is below'.""" - return check_input(v, validation_info, "threshold", "rule", ["is above", "is below"]) - + return check_input( + v, validation_info, "threshold", "rule", ["is above", "is below"] + ) + @field_validator("range_low") def check_range_low(cls, v, validation_info): """Checks that range_low is specified if the alert rule is 'is outside range' or 'is inside range'.""" - return check_input(v, validation_info, "range_low", "rule", ["is outside range", "is inside range"]) - + return check_input( + v, + validation_info, + "range_low", + "rule", + ["is outside range", "is inside range"], + ) + @field_validator("range_high") def check_range_high(cls, v, validation_info): """Checks that range_high is specified if the alert rule is 'is outside range' or 'is inside range'.""" - return check_input(v, validation_info, "range_high", "rule", ["is outside range", "is inside range"]) \ No newline at end of file + return check_input( + v, + validation_info, + "range_high", + "rule", + ["is outside range", "is inside range"], + ) diff --git a/simvue/run.py b/simvue/run.py index 95cad91a..e82c0ec5 100644 --- a/simvue/run.py +++ b/simvue/run.py @@ -1839,14 +1839,14 @@ def create_alert( "description": description, "abort": trigger_abort, } - + try: to_validate = alert to_validate["trigger_abort"] = to_validate.pop("abort") if definition := to_validate.pop("alert", None): to_validate = {**to_validate, **definition} AlertValidator(**to_validate) - + except ValidationError as err: self._error(f"{err}") return None