From e426e795b527bb377fd7cb69195066d380f2d2b6 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 25 Aug 2026 12:08:21 -0400 Subject: [PATCH 1/5] feat: implement rules customization unit tests using feature files --- pyproject.toml | 1 + tests/conftest.py | 63 + .../features/alert_rule_customization.feature | 146 +++ tests/test_rules_customization.py | 1137 ++++++++--------- tests/test_rules_customization_schema.py | 188 +++ uv.lock | 449 +++++-- 6 files changed, 1311 insertions(+), 673 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/features/alert_rule_customization.feature create mode 100644 tests/test_rules_customization_schema.py diff --git a/pyproject.toml b/pyproject.toml index 8d4dc40..34f5f3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dev = [ # https://github.com/pypa/setuptools/issues/5174 "setuptools<82", "pytest", + "pytest-bdd", "pytest-cov", "deepdiff", "fs", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a14ea7c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,63 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""Shared pytest fixtures for cos-lib tests.""" + +import pytest + + +@pytest.fixture +def sample_alerts(): + """Relation alerts dict in the same shape as MetricsConsumer.alerts.""" + return { + "app-1": { + "groups": [ + { + "name": "group_a", + "rules": [ + { + "alert": "HighLatency", + "expr": "latency > 100", + "for": "10m", + "labels": {"severity": "critical", "juju_application": "app-1"}, + "annotations": {"summary": "latency is high"}, + }, + { + "alert": "LowThroughput", + "expr": "throughput < 10", + "for": "5m", + "labels": {"severity": "warning"}, + }, + { + "record": "job:latency:mean5m", + "expr": "avg(latency)", + "labels": {"severity": "warning"}, + }, + ], + }, + { + "name": "group_b", + "rules": [ + {"alert": "HostDown", "expr": "up < 1"}, + ], + }, + ] + }, + "app-2": { + "groups": [ + { + "name": "group_c", + "rules": [ + {"alert": "OtherAlert", "expr": "x > 0"}, + ], + } + ] + }, + } + + +def find_rule(alerts, identifier, group_name, rule_name, *, by_record=False): + """Return a single rule from an alerts dict, raising if not found.""" + key = "record" if by_record else "alert" + groups = alerts[identifier]["groups"] + group = next(g for g in groups if g["name"] == group_name) + return next(rule for rule in group["rules"] if rule.get(key) == rule_name) diff --git a/tests/features/alert_rule_customization.feature b/tests/features/alert_rule_customization.feature new file mode 100644 index 0000000..d4303c0 --- /dev/null +++ b/tests/features/alert_rule_customization.feature @@ -0,0 +1,146 @@ +Feature: Alert rule customization + As a COS admin + I want to customize relation-derived alert rules via a YAML config + So that I can remove, patch, or add rules without modifying charm code + + Background: + Given a set of relation alerts from two apps + + # --------------------------------------------------------------------------- + # Remove + # --------------------------------------------------------------------------- + + Scenario: Remove an alert by name + When I apply a customization that removes alert "LowThroughput" + Then alert "LowThroughput" is absent from the result + And alert "HighLatency" is present in the result + And the recording rule "job:latency:mean5m" is present in the result + + Scenario: Remove an entire group by group name drops everything including recording rules + When I apply a customization that removes group "group_a" + Then group "group_a" is absent from identifier "app-1" + And group "group_b" is present in identifier "app-1" + + Scenario: Remove with group and another selector only removes matching alerting rules + When I apply a customization that removes alerts in group "group_a" with alert name "LowThroughput" + Then alert "LowThroughput" is absent from the result + And alert "HighLatency" is present in the result + And the recording rule "job:latency:mean5m" is present in the result + + Scenario: Remove by label value + When I apply a customization that removes alerts with label "severity" equal to "warning" + Then alert "LowThroughput" is absent from the result + And alert "HighLatency" is present in the result + And the recording rule "job:latency:mean5m" is present in the result + + Scenario: Remove by annotation value + When I apply a customization that removes alerts with annotation "summary" equal to "latency is high" + Then alert "HighLatency" is absent from the result + And alert "LowThroughput" is present in the result + + Scenario: Remove by juju topology label + When I apply a customization that removes alerts with label "juju_application" equal to "app-1" + Then alert "HighLatency" is absent from the result + And alert "LowThroughput" is present in the result + + Scenario: Multiple remove entries are OR'd + When I apply a customization that removes alert "HighLatency" and alert "OtherAlert" + Then alert "HighLatency" is absent from the result + And alert "OtherAlert" is absent from the result + And alert "LowThroughput" is present in the result + And alert "HostDown" is present in the result + + Scenario: Removing the only rule in a group prunes the empty group + When I apply a customization that removes alert "HostDown" + Then group "group_b" is absent from identifier "app-1" + And group "group_a" is present in identifier "app-1" + + Scenario: Removing all rules from an identifier drops the identifier entirely + When I apply a customization that removes alert "OtherAlert" + Then identifier "app-2" is absent from the result + And identifier "app-1" is present in the result + + # --------------------------------------------------------------------------- + # Patch + # --------------------------------------------------------------------------- + + Scenario: Patch updates the for duration of a matching alert + When I apply a customization that patches alert "HighLatency" setting for to "30m" + Then alert "HighLatency" has for equal to "30m" + And alert "LowThroughput" has for equal to "5m" + + Scenario: Patch replaces the alert name + When I apply a customization that patches alert "HighLatency" setting alert name to "RenamedLatency" + Then alert "RenamedLatency" is present in the result + And alert "HighLatency" is absent from the result + + Scenario: Patch replaces the expression + When I apply a customization that patches alert "HostDown" setting expr to "up == 0" + Then alert "HostDown" has expr equal to "up == 0" + + Scenario: Patch overwrites an existing label and adds a new one leaving others untouched + When I apply a customization that patches alert "HighLatency" setting label "severity" to "page" and adding label "extra" as "added" + Then alert "HighLatency" has label "severity" equal to "page" + And alert "HighLatency" has label "extra" equal to "added" + And alert "HighLatency" has label "juju_application" equal to "app-1" + + Scenario: Patch updates a juju topology label + When I apply a customization that patches alert "HighLatency" setting label "juju_application" to "other-app" + Then alert "HighLatency" has label "juju_application" equal to "other-app" + + Scenario: Patch merges annotations + When I apply a customization that patches alert "HighLatency" setting annotation "summary" to "new summary" and adding annotation "description" as "new description" + Then alert "HighLatency" has annotation "summary" equal to "new summary" + And alert "HighLatency" has annotation "description" equal to "new description" + + Scenario: Patch does not affect recording rules + When I apply a customization that patches all rules in group "group_a" setting expr to "hacked" + Then the recording rule "job:latency:mean5m" has expr equal to "avg(latency)" + And alert "HighLatency" has expr equal to "hacked" + + Scenario: Patch matches by label value across rules + When I apply a customization that patches alerts with label "severity" equal to "warning" setting label "severity" to "critical" + Then alert "LowThroughput" has label "severity" equal to "critical" + And the recording rule "job:latency:mean5m" has label "severity" equal to "warning" + + # --------------------------------------------------------------------------- + # Add + # --------------------------------------------------------------------------- + + Scenario: Add inserts groups under the fixed key custom_alert_rules + When I apply a customization that adds a group named "my-custom-alerts" with alert "MyAlert" + Then identifier "custom_alert_rules" is present in the result + And group "my-custom-alerts" is present in identifier "custom_alert_rules" + And alert "MyAlert" is present in the result + And identifier "app-1" is present in the result + And identifier "app-2" is present in the result + + Scenario: Added rules receive no topology injection + When I apply a customization that adds a group named "my-custom-alerts" with alert "MyAlert" and expr 'up{juju_model="prod"} == 0' + Then alert "MyAlert" has expr equal to 'up{juju_model="prod"} == 0' + And alert "MyAlert" has no labels + + Scenario: Added rules are deep copied so mutations do not affect subsequent apply calls + When I apply the same customization twice with an add block + And I mutate the alert name in the first result + Then the second result still contains alert "MyAlert" + + # --------------------------------------------------------------------------- + # Apply semantics + # --------------------------------------------------------------------------- + + Scenario: The original input is not mutated by apply + When I apply a customization that removes alert "LowThroughput" and patches alert "HighLatency" + Then the original input is unchanged + + Scenario: Operations are applied in order remove then patch then add + Given a rule named "GoneForever" and a rule named "Survivor" + When I apply a customization that removes "GoneForever", patches "Survivor" for to "2m", and adds a new "GoneForever" + Then alert "GoneForever" is absent from identifier "app" + And alert "Survivor" has for equal to "2m" + And identifier "custom_alert_rules" is present in the result + And the added alert "GoneForever" does not have a for field + + Scenario: The customization instance is reusable across different inputs + When I apply the same remove customization to two different inputs + Then alert "HostDown" is absent from both results diff --git a/tests/test_rules_customization.py b/tests/test_rules_customization.py index 9f0882c..9efb4f4 100644 --- a/tests/test_rules_customization.py +++ b/tests/test_rules_customization.py @@ -1,607 +1,576 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. +"""pytest-bdd step definitions for alert rule customization behavioural tests. + +Feature file: tests/features/alert_rule_customization.feature +Schema/validation tests: tests/test_rules_customization_schema.py +""" import copy -import unittest + +import pytest +from pytest_bdd import given, parsers, scenario, scenarios, then, when from cosl.rules_customization import ( CUSTOM_ALERT_RULES_KEY, AlertRulesCustomization, - AlertRulesCustomizationError, ) +from conftest import find_rule +scenarios("features/alert_rule_customization.feature") -def _sample_alerts(): - """Relation alerts dict in the same shape as MetricsConsumer.alerts.""" - return { - "app-1": { - "groups": [ - { - "name": "group_a", - "rules": [ - { - "alert": "HighLatency", - "expr": "latency > 100", - "for": "10m", - "labels": {"severity": "critical", "juju_application": "app-1"}, - "annotations": {"summary": "latency is high"}, - }, - { - "alert": "LowThroughput", - "expr": "throughput < 10", - "for": "5m", - "labels": {"severity": "warning"}, - }, - { - "record": "job:latency:mean5m", - "expr": "avg(latency)", - "labels": {"severity": "warning"}, - }, - ], - }, - { - "name": "group_b", - "rules": [ - {"alert": "HostDown", "expr": "up < 1"}, - ], - }, - ] - }, - "app-2": { + +# --------------------------------------------------------------------------- +# Shared context fixture — carries state between Given/When/Then steps +# --------------------------------------------------------------------------- + + +@pytest.fixture +def ctx(): + """Mutable context dict shared across steps within a scenario.""" + return {} + + +# --------------------------------------------------------------------------- +# Given +# --------------------------------------------------------------------------- + + +@given("a set of relation alerts from two apps") +def given_sample_alerts(ctx, sample_alerts): + ctx["alerts"] = sample_alerts + ctx["original"] = copy.deepcopy(sample_alerts) + + +@given("a rule named \"GoneForever\" and a rule named \"Survivor\"") +def given_gone_forever_and_survivor(ctx): + ctx["alerts"] = { + "app": { "groups": [ { - "name": "group_c", + "name": "g", "rules": [ - {"alert": "OtherAlert", "expr": "x > 0"}, + {"alert": "GoneForever", "expr": "x", "for": "10m"}, + {"alert": "Survivor", "expr": "y", "for": "10m"}, ], } ] - }, + } } + ctx["original"] = copy.deepcopy(ctx["alerts"]) + + +# --------------------------------------------------------------------------- +# When — Remove +# --------------------------------------------------------------------------- + + +@when('I apply a customization that removes alert "LowThroughput"') +def when_remove_low_throughput(ctx): + config = """ +remove: + - where: + alert: LowThroughput +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes group "group_a"') +def when_remove_group_a(ctx): + config = """ +remove: + - where: + group: group_a +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alerts in group "group_a" with alert name "LowThroughput"') +def when_remove_group_a_low_throughput(ctx): + config = """ +remove: + - where: + group: group_a + alert: LowThroughput +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alerts with label "severity" equal to "warning"') +def when_remove_by_severity_warning(ctx): + config = """ +remove: + - where: + labels: + severity: warning +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alerts with annotation "summary" equal to "latency is high"') +def when_remove_by_annotation(ctx): + config = """ +remove: + - where: + annotations: + summary: latency is high +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alerts with label "juju_application" equal to "app-1"') +def when_remove_by_juju_application(ctx): + config = """ +remove: + - where: + labels: + juju_application: app-1 +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alert "HighLatency" and alert "OtherAlert"') +def when_remove_two_alerts(ctx): + config = """ +remove: + - where: + alert: HighLatency + - where: + alert: OtherAlert +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alert "HostDown"') +def when_remove_host_down(ctx): + config = """ +remove: + - where: + alert: HostDown +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alert "OtherAlert"') +def when_remove_other_alert(ctx): + config = """ +remove: + - where: + alert: OtherAlert +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +# --------------------------------------------------------------------------- +# When — Patch +# --------------------------------------------------------------------------- + + +@when('I apply a customization that patches alert "HighLatency" setting for to "30m"') +def when_patch_for(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + for: 30m +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that patches alert "HighLatency" setting alert name to "RenamedLatency"') +def when_patch_alert_name(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + alert: RenamedLatency +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that patches alert "HostDown" setting expr to "up == 0"') +def when_patch_expr(ctx): + config = """ +patch: + - where: + alert: HostDown + set: + expr: up == 0 +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that patches alert "HighLatency" setting label "severity" to "page" and adding label "extra" as "added"') +def when_patch_labels(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + labels: + severity: page + extra: added +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that patches alert "HighLatency" setting label "juju_application" to "other-app"') +def when_patch_juju_label(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + labels: + juju_application: other-app +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that patches alert "HighLatency" setting annotation "summary" to "new summary" and adding annotation "description" as "new description"') +def when_patch_annotations(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + annotations: + summary: new summary + description: new description +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that patches all rules in group "group_a" setting expr to "hacked"') +def when_patch_group_expr(ctx): + config = """ +patch: + - where: + group: group_a + set: + expr: hacked +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that patches alerts with label "severity" equal to "warning" setting label "severity" to "critical"') +def when_patch_by_label(ctx): + config = """ +patch: + - where: + labels: + severity: warning + set: + labels: + severity: critical +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +# --------------------------------------------------------------------------- +# When — Add +# --------------------------------------------------------------------------- + + +@when('I apply a customization that adds a group named "my-custom-alerts" with alert "MyAlert"') +def when_add_group(ctx): + config = """ +add: + groups: + - name: my-custom-alerts + rules: + - alert: MyAlert + expr: up{juju_model="prod"} == 0 + for: 5m +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when("I apply a customization that adds a group named \"my-custom-alerts\" with alert \"MyAlert\" and expr 'up{juju_model=\"prod\"} == 0'") +def when_add_group_check_expr(ctx): + config = """ +add: + groups: + - name: my-custom-alerts + rules: + - alert: MyAlert + expr: 'up{juju_model="prod"} == 0' +""" + ctx["customization"] = AlertRulesCustomization.from_yaml(config) + ctx["result"] = ctx["customization"].apply(ctx["alerts"]) + + +@when("I apply the same customization twice with an add block") +def when_apply_twice_add(ctx): + config = """ +add: + groups: + - name: my-custom-alerts + rules: + - alert: MyAlert + expr: up == 0 +""" + ctx["customization"] = AlertRulesCustomization.from_yaml(config) + ctx["result1"] = ctx["customization"].apply(ctx["alerts"]) + ctx["result2"] = ctx["customization"].apply(ctx["alerts"]) + + +@when("I mutate the alert name in the first result") +def when_mutate_first_result(ctx): + ctx["result1"][CUSTOM_ALERT_RULES_KEY]["groups"][0]["rules"][0]["alert"] = "Mangled" + + +# --------------------------------------------------------------------------- +# When — Apply semantics +# --------------------------------------------------------------------------- + + +@when("I apply a customization that removes alert \"LowThroughput\" and patches alert \"HighLatency\"") +def when_remove_and_patch(ctx): + config = """ +remove: + - where: + alert: LowThroughput +patch: + - where: + alert: HighLatency + set: + for: 1h + labels: + severity: page +""" + AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + ctx["result"] = ctx["alerts"] # we check that the original is unchanged + + +@when('I apply a customization that removes "GoneForever", patches "Survivor" for to "2m", and adds a new "GoneForever"') +def when_order_of_operations(ctx): + config = """ +remove: + - where: + alert: GoneForever +patch: + - where: + alert: GoneForever + set: + for: 1m + - where: + alert: Survivor + set: + for: 2m +add: + groups: + - name: added + rules: + - alert: GoneForever + expr: up +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when("I apply the same remove customization to two different inputs") +def when_reuse_customization(ctx): + config = """ +remove: + - where: + alert: HostDown +""" + customization = AlertRulesCustomization.from_yaml(config) + ctx["result1"] = customization.apply(ctx["alerts"]) + other = { + "other": { + "groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}] + } + } + ctx["result2"] = customization.apply(other) -def _find_rule(alerts, identifier, group_name, rule_name, *, by_record=False): - """Fetch a single rule from an alerts dict for assertions.""" - key = "record" if by_record else "alert" - groups = alerts[identifier]["groups"] - group = next(g for g in groups if g["name"] == group_name) - return next(rule for rule in group["rules"] if rule.get(key) == rule_name) - - -class TestFromYamlValidation(unittest.TestCase): - def test_invalid_yaml_raises(self): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml("remove: [unclosed") - - def test_non_mapping_top_level_raises(self): - for config in ("- a\n- b", "42", '"just a string"'): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml(config) - - def test_unknown_top_level_key_raises(self): - config = """ - remove: - - where: - alert: Foo - destroy: - - where: - alert: Foo - """ - with self.assertRaisesRegex(AlertRulesCustomizationError, "top-level"): - AlertRulesCustomization.from_yaml(config) - - def test_remove_missing_where_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "remove"): - AlertRulesCustomization.from_yaml("remove:\n - alert: Foo") - - def test_remove_empty_where_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' must not be empty"): - AlertRulesCustomization.from_yaml("remove:\n - where: {}") - - def test_remove_unknown_where_key_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' keys"): - AlertRulesCustomization.from_yaml("remove:\n - where:\n expr: up < 1") - - def test_patch_missing_where_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "patch"): - AlertRulesCustomization.from_yaml("patch:\n - set:\n for: 5m") - - def test_patch_missing_set_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "patch"): - AlertRulesCustomization.from_yaml("patch:\n - where:\n alert: Foo") - - def test_patch_empty_where_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' must not be empty"): - AlertRulesCustomization.from_yaml("patch:\n - where: {}\n set:\n for: 5m") - - def test_patch_unknown_where_key_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' keys"): - AlertRulesCustomization.from_yaml( - "patch:\n - where:\n record: some:record\n set:\n expr: up" - ) - - def test_patch_unknown_set_key_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "'set' keys"): - AlertRulesCustomization.from_yaml( - "patch:\n - where:\n alert: Foo\n set:\n duration: 5m" - ) - - def test_add_without_groups_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "'add'"): - AlertRulesCustomization.from_yaml("add:\n rules:\n - alert: Foo\n expr: up") - - def test_add_groups_not_a_list_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "'add.groups' must be a list"): - AlertRulesCustomization.from_yaml("add:\n groups:\n name: my-group\n rules: []") - - def test_add_group_missing_name_or_rules_raises(self): - for group in ("rules: []", "name: my-group"): - with self.assertRaisesRegex(AlertRulesCustomizationError, "add.groups"): - AlertRulesCustomization.from_yaml(f"add:\n groups:\n - {group}") - - def test_add_malformed_values_raise(self): - cases = { - "add not a mapping": "add: groups", - "group not a mapping": "add:\n groups:\n - just-a-string", - "name not a string": "add:\n groups:\n - name: [1]\n rules: []", - "rules not a list": "add:\n groups:\n - name: my-group\n rules: nope", - } - for case, config in cases.items(): - with self.subTest(case): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml(config) - - def test_operations_not_a_list_raises(self): - for key in ("remove", "patch"): - with self.assertRaisesRegex(AlertRulesCustomizationError, f"'{key}'"): - AlertRulesCustomization.from_yaml(f"{key}: not-a-list") - - def test_malformed_operation_entries_raise(self): - cases = { - "where not a mapping": "remove:\n - where: nope", - "where.alert not a string": "remove:\n - where:\n alert: [1, 2]", - "where.labels not a mapping": "remove:\n - where:\n labels: severity", - "set not a mapping": "patch:\n - where:\n alert: Foo\n set: nope", - "set.expr not a string": ( - "patch:\n - where:\n alert: Foo\n set:\n expr: {a: b}" - ), - "set.labels not a mapping": ( - "patch:\n - where:\n alert: Foo\n set:\n labels: x" - ), - "remove entry not a mapping": "remove:\n - just-a-string", - "patch entry not a mapping": "patch:\n - just-a-string", - } - for case, config in cases.items(): - with self.subTest(case): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml(config) - - -class TestNoOpConfigs(unittest.TestCase): - def _assert_noop(self, config_string): - sample = _sample_alerts() - result = AlertRulesCustomization.from_yaml(config_string).apply(sample) - self.assertEqual(result, sample) - - def test_empty_config_is_noop(self): - self._assert_noop("") - - def test_whitespace_config_is_noop(self): - self._assert_noop(" \n\t ") - - def test_none_parsing_config_is_noop(self): - # yaml.safe_load of these strings returns None or empty structures. - self._assert_noop("~") - self._assert_noop("# just a comment") - self._assert_noop("{}") - - def test_zero_match_remove_is_noop(self): - config = """ - remove: - - where: - alert: NoSuchAlert - - where: - labels: - nope: nothing - """ - self._assert_noop(config) - - def test_zero_match_patch_is_noop(self): - config = """ - patch: - - where: - alert: NoSuchAlert - set: - for: 1m - """ - self._assert_noop(config) - - -class TestRemove(unittest.TestCase): - def _apply(self, config): - return AlertRulesCustomization.from_yaml(config).apply(_sample_alerts()) - - def test_remove_by_alert_name(self): - config = """ - remove: - - where: - alert: LowThroughput - """ - result = self._apply(config) - - group_names = [g["name"] for g in result["app-1"]["groups"]] - self.assertEqual(group_names, ["group_a", "group_b"]) - rule_names = [r.get("alert") for r in result["app-1"]["groups"][0]["rules"]] - self.assertEqual(rule_names, ["HighLatency", None]) # record remains - - def test_remove_by_group_only_drops_entire_group_including_recording_rules(self): - config = """ - remove: - - where: - group: group_a - """ - result = self._apply(config) - - group_names = [g["name"] for g in result["app-1"]["groups"]] - self.assertEqual(group_names, ["group_b"]) - - def test_remove_group_with_other_selector_keeps_recording_rules(self): - config = """ - remove: - - where: - group: group_a - alert: LowThroughput - """ - result = self._apply(config) - - group = next(g for g in result["app-1"]["groups"] if g["name"] == "group_a") - rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] - # Only the matching alerting rule is removed; the rest survives. - self.assertEqual(rule_names, ["HighLatency", "job:latency:mean5m"]) - - def test_remove_by_alert_and_labels_combined(self): - config_matching = """ - remove: - - where: - alert: HighLatency - labels: - severity: critical - """ - result = self._apply(config_matching) - self.assertNotIn("HighLatency", str(result)) - - # Same alert but non-matching label value: nothing removed. - config_not_matching = """ - remove: - - where: - alert: HighLatency - labels: - severity: warning - """ - result = self._apply(config_not_matching) - self.assertIn("HighLatency", str(result)) - - def test_remove_by_group_and_labels_combined(self): - config = """ - remove: - - where: - group: group_a - labels: - severity: warning - """ - result = self._apply(config) - - group = next(g for g in result["app-1"]["groups"] if g["name"] == "group_a") - rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] - # Both the alerting rule and the recording rule carry severity=warning, - # but only the alerting rule may be removed (group selector is combined). - self.assertEqual(rule_names, ["HighLatency", "job:latency:mean5m"]) - - def test_remove_by_annotations(self): - config = """ - remove: - - where: - annotations: - summary: latency is high - """ - result = self._apply(config) - - self.assertNotIn("HighLatency", str(result)) - self.assertIn("LowThroughput", str(result)) - - def test_remove_multiple_entries_are_ored(self): - config = """ - remove: - - where: - alert: HighLatency - - where: - alert: OtherAlert - """ - result = self._apply(config) - - self.assertNotIn("HighLatency", str(result)) - self.assertNotIn("OtherAlert", str(result)) - self.assertIn("LowThroughput", str(result)) - self.assertIn("HostDown", str(result)) - - def test_remove_prunes_empty_groups_and_drops_empty_identifiers(self): - config_pruning_group = """ - remove: - - where: - alert: HostDown - """ - result = self._apply(config_pruning_group) - # group_b became empty and was pruned; app-1 keeps group_a only. - self.assertEqual([g["name"] for g in result["app-1"]["groups"]], ["group_a"]) - self.assertIn("app-2", result) - - config_dropping_identifier = """ - remove: - - where: - alert: OtherAlert - """ - result = self._apply(config_dropping_identifier) - # app-2's only group became empty, so app-2 was dropped entirely. - self.assertNotIn("app-2", result) - self.assertIn("app-1", result) - - def test_remove_preserves_recording_rules_when_group_not_sole_selector(self): - config = """ - remove: - - where: - labels: - severity: warning - """ - result = self._apply(config) - - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - self.assertEqual(record["expr"], "avg(latency)") - - -class TestPatch(unittest.TestCase): - def _apply(self, config): - return AlertRulesCustomization.from_yaml(config).apply(_sample_alerts()) - - def test_patch_updates_for(self): - config = """ - patch: - - where: - alert: HighLatency - set: - for: 30m - """ - result = self._apply(config) - - self.assertEqual(_find_rule(result, "app-1", "group_a", "HighLatency")["for"], "30m") - # Untouched rule keeps its original value. - self.assertEqual(_find_rule(result, "app-1", "group_a", "LowThroughput")["for"], "5m") - - def test_patch_replaces_alert_name(self): - config = """ - patch: - - where: - alert: HighLatency - set: - alert: RenamedLatency - """ - result = self._apply(config) - - renamed = _find_rule(result, "app-1", "group_a", "RenamedLatency") - self.assertEqual(renamed["expr"], "latency > 100") - - def test_patch_replaces_expr(self): - config = """ - patch: - - where: - alert: HostDown - set: - expr: up == 0 - """ - result = self._apply(config) - - self.assertEqual(_find_rule(result, "app-1", "group_b", "HostDown")["expr"], "up == 0") - - def test_patch_merges_labels(self): - config = """ - patch: - - where: - alert: HighLatency - set: - labels: - severity: page - extra: added - """ - result = self._apply(config) - - labels = _find_rule(result, "app-1", "group_a", "HighLatency")["labels"] - # existing key overwritten, new key added, other keys untouched - self.assertEqual(labels["severity"], "page") - self.assertEqual(labels["extra"], "added") - self.assertEqual(labels["juju_application"], "app-1") - - def test_patch_merges_annotations(self): - config = """ - patch: - - where: - alert: HighLatency - set: - annotations: - summary: new summary - description: new description - """ - result = self._apply(config) - - annotations = _find_rule(result, "app-1", "group_a", "HighLatency")["annotations"] - self.assertEqual(annotations["summary"], "new summary") - self.assertEqual(annotations["description"], "new description") - - def test_patch_skips_recording_rules(self): - config = """ - patch: - - where: - group: group_a - set: - expr: hacked - """ - result = self._apply(config) - - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - self.assertEqual(record["expr"], "avg(latency)") - # Alerting rules in the same group were patched. - self.assertEqual(_find_rule(result, "app-1", "group_a", "HighLatency")["expr"], "hacked") - - def test_patch_matches_on_labels(self): - config = """ - patch: - - where: - labels: - severity: warning - set: - labels: - severity: critical - """ - result = self._apply(config) - - self.assertEqual( - _find_rule(result, "app-1", "group_a", "LowThroughput")["labels"]["severity"], - "critical", - ) - # The recording rule also has severity=warning but must not be patched. - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - self.assertEqual(record["labels"]["severity"], "warning") - # The alert in the other app is unaffected. - self.assertEqual(_find_rule(result, "app-2", "group_c", "OtherAlert")["expr"], "x > 0") - - -class TestAdd(unittest.TestCase): - _config = """ - add: - groups: - - name: my-custom-alerts - rules: - - alert: MyAlert - expr: up{juju_model="prod"} == 0 - for: 5m - """ - - def test_add_inserts_groups_under_fixed_key(self): - sample = _sample_alerts() - result = AlertRulesCustomization.from_yaml(self._config).apply(sample) - - custom = result[CUSTOM_ALERT_RULES_KEY] - self.assertEqual(custom["groups"][0]["name"], "my-custom-alerts") - self.assertEqual(custom["groups"][0]["rules"][0]["alert"], "MyAlert") - # Existing identifiers are left untouched. - self.assertEqual(set(result), set(sample) | {CUSTOM_ALERT_RULES_KEY}) - - def test_added_rules_receive_no_topology_injection(self): - result = AlertRulesCustomization.from_yaml(self._config).apply(_sample_alerts()) - rule = result[CUSTOM_ALERT_RULES_KEY]["groups"][0]["rules"][0] - self.assertEqual(rule["expr"], 'up{juju_model="prod"} == 0') - self.assertNotIn("labels", rule) - - def test_added_rules_are_deep_copied(self): - customization = AlertRulesCustomization.from_yaml(self._config) - result = customization.apply({}) - result[CUSTOM_ALERT_RULES_KEY]["groups"][0]["rules"][0]["alert"] = "Mangled" - # A subsequent apply() must not be affected by mutations of a previous output. - fresh = customization.apply({})[CUSTOM_ALERT_RULES_KEY]["groups"][0]["rules"][0] - self.assertEqual(fresh["alert"], "MyAlert") - - -class TestApplySemantics(unittest.TestCase): - def test_input_is_not_mutated(self): - config = """ - remove: - - where: - alert: LowThroughput - patch: - - where: - alert: HighLatency - set: - for: 1h - labels: - severity: page - add: - groups: - - name: added - rules: - - alert: Added - expr: up - """ - sample = _sample_alerts() - snapshot = copy.deepcopy(sample) - AlertRulesCustomization.from_yaml(config).apply(sample) - self.assertEqual(sample, snapshot) - - def test_order_of_operations_is_remove_then_patch_then_add(self): - config = """ - remove: - - where: - alert: GoneForever - patch: - - where: - alert: GoneForever - set: - for: 1m - - where: - alert: Survivor - set: - for: 2m - add: - groups: - - name: added - rules: - - alert: GoneForever - expr: up - """ - relation_alerts = { - "app": { - "groups": [ - { - "name": "g", - "rules": [ - {"alert": "GoneForever", "expr": "x", "for": "10m"}, - {"alert": "Survivor", "expr": "y", "for": "10m"}, - ], - } - ] - } - } - result = AlertRulesCustomization.from_yaml(config).apply(relation_alerts) - - rules = result["app"]["groups"][0]["rules"] - # Removed despite a patch entry targeting it; patch applied to the survivor; - # the added rule lands under the fixed key, untouched by remove/patch. - self.assertEqual([r["alert"] for r in rules], ["Survivor"]) - self.assertEqual(rules[0]["for"], "2m") - added = result[CUSTOM_ALERT_RULES_KEY]["groups"][0]["rules"][0] - self.assertEqual(added["alert"], "GoneForever") - self.assertNotIn("for", added) # the patch entry did not leak into the added rule - - def test_apply_is_reusable_across_inputs(self): - config = """ - remove: - - where: - alert: HostDown - """ - customization = AlertRulesCustomization.from_yaml(config) - - result_1 = customization.apply(_sample_alerts()) - self.assertNotIn("HostDown", str(result_1["app-1"])) - self.assertIn("app-2", result_1) - - other_relation_alerts = { - "other": { - "groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}] - } - } - result_2 = customization.apply(other_relation_alerts) - self.assertEqual(result_2, {}) +# --------------------------------------------------------------------------- +# Then — Presence / absence of alerts +# --------------------------------------------------------------------------- + + +@then(parsers.parse('alert "{alert_name}" is absent from the result')) +def then_alert_absent(ctx, alert_name): + assert alert_name not in str(ctx["result"]) + + +@then(parsers.parse('alert "{alert_name}" is present in the result')) +def then_alert_present(ctx, alert_name): + assert alert_name in str(ctx["result"]) + + +@then(parsers.parse('the recording rule "{record_name}" is present in the result')) +def then_recording_rule_present(ctx, record_name): + assert record_name in str(ctx["result"]) + + +# --------------------------------------------------------------------------- +# Then — Presence / absence of groups and identifiers +# --------------------------------------------------------------------------- + + +@then(parsers.parse('group "{group_name}" is absent from identifier "{identifier}"')) +def then_group_absent(ctx, group_name, identifier): + result = ctx["result"] + if identifier not in result: + return # identifier itself gone, group certainly absent + group_names = [g["name"] for g in result[identifier].get("groups", [])] + assert group_name not in group_names + + +@then(parsers.parse('group "{group_name}" is present in identifier "{identifier}"')) +def then_group_present(ctx, group_name, identifier): + result = ctx["result"] + assert identifier in result + group_names = [g["name"] for g in result[identifier].get("groups", [])] + assert group_name in group_names + + +@then(parsers.parse('identifier "{identifier}" is absent from the result')) +def then_identifier_absent(ctx, identifier): + assert identifier not in ctx["result"] + + +@then(parsers.parse('identifier "{identifier}" is present in the result')) +def then_identifier_present(ctx, identifier): + assert identifier in ctx["result"] + + +# --------------------------------------------------------------------------- +# Then — Rule field assertions +# --------------------------------------------------------------------------- + + +@then(parsers.parse('alert "{alert_name}" has for equal to "{value}"')) +def then_alert_for(ctx, alert_name, value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + assert found["for"] == value, f"expected for={value!r}, got {found.get('for')!r}" + + +@then(parsers.parse("alert \"{alert_name}\" has expr equal to '{value}'")) +def then_alert_expr_single_quoted(ctx, alert_name, value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" + + +@then(parsers.parse('alert "{alert_name}" has expr equal to "{value}"')) +def then_alert_expr(ctx, alert_name, value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" + + +@then(parsers.parse('alert "{alert_name}" has label "{label_key}" equal to "{label_value}"')) +def then_alert_label(ctx, alert_name, label_key, label_value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + labels = found.get("labels", {}) + assert labels.get(label_key) == label_value, ( + f"expected label {label_key}={label_value!r}, got {labels.get(label_key)!r}" + ) + + +@then(parsers.parse('alert "{alert_name}" has annotation "{ann_key}" equal to "{ann_value}"')) +def then_alert_annotation(ctx, alert_name, ann_key, ann_value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + annotations = found.get("annotations", {}) + assert annotations.get(ann_key) == ann_value, ( + f"expected annotation {ann_key}={ann_value!r}, got {annotations.get(ann_key)!r}" + ) + + +@then(parsers.parse('alert "{alert_name}" has no labels')) +def then_alert_no_labels(ctx, alert_name): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + assert not found.get("labels"), f"expected no labels, got {found.get('labels')!r}" + + +@then(parsers.parse('the recording rule "{record_name}" has expr equal to "{value}"')) +def then_recording_rule_expr(ctx, record_name, value): + result = ctx["result"] + found = _find_record_anywhere(result, record_name) + assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" + + +@then(parsers.parse('the recording rule "{record_name}" has label "{label_key}" equal to "{label_value}"')) +def then_recording_rule_label(ctx, record_name, label_key, label_value): + result = ctx["result"] + found = _find_record_anywhere(result, record_name) + labels = found.get("labels", {}) + assert labels.get(label_key) == label_value + + +# --------------------------------------------------------------------------- +# Then — Apply semantics +# --------------------------------------------------------------------------- + + +@then("the original input is unchanged") +def then_original_unchanged(ctx): + assert ctx["alerts"] == ctx["original"] + + +@then(parsers.parse('alert "{alert_name}" is absent from identifier "{identifier}"')) +def then_alert_absent_from_identifier(ctx, alert_name, identifier): + result = ctx["result"] + if identifier not in result: + return + assert alert_name not in str(result[identifier]) + + +@then('alert "Survivor" has for equal to "2m"') +def then_survivor_for(ctx): + rules = ctx["result"]["app"]["groups"][0]["rules"] + survivor = next(r for r in rules if r.get("alert") == "Survivor") + assert survivor["for"] == "2m" + + +@then('the added alert "GoneForever" does not have a for field') +def then_added_gone_forever_no_for(ctx): + added = ctx["result"][CUSTOM_ALERT_RULES_KEY]["groups"][0]["rules"][0] + assert added["alert"] == "GoneForever" + assert "for" not in added + + +@then("the second result still contains alert \"MyAlert\"") +def then_second_result_has_my_alert(ctx): + rule = ctx["result2"][CUSTOM_ALERT_RULES_KEY]["groups"][0]["rules"][0] + assert rule["alert"] == "MyAlert" + + +@then("alert \"HostDown\" is absent from both results") +def then_host_down_absent_both(ctx): + assert "HostDown" not in str(ctx["result1"]) + assert "HostDown" not in str(ctx["result2"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + - # And the first input's result is unchanged by the second call. - self.assertIn("app-1", result_1) +def _find_alert_anywhere(result, alert_name): + """Search all identifiers/groups for an alerting rule by name.""" + for rule_file in result.values(): + for group in rule_file.get("groups", []): + for rule in group.get("rules", []): + if rule.get("alert") == alert_name: + return rule + raise AssertionError(f"Alert {alert_name!r} not found in result") -if __name__ == "__main__": - unittest.main() +def _find_record_anywhere(result, record_name): + """Search all identifiers/groups for a recording rule by name.""" + for rule_file in result.values(): + for group in rule_file.get("groups", []): + for rule in group.get("rules", []): + if rule.get("record") == record_name: + return rule + raise AssertionError(f"Recording rule {record_name!r} not found in result") diff --git a/tests/test_rules_customization_schema.py b/tests/test_rules_customization_schema.py new file mode 100644 index 0000000..c1b1fee --- /dev/null +++ b/tests/test_rules_customization_schema.py @@ -0,0 +1,188 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""Schema validation tests for AlertRulesCustomization.from_yaml(). + +Covers invalid YAML, unknown top-level keys, malformed operation entries, and +no-op configs. Behavioural tests (remove/patch/add/apply semantics) live in +test_rules_customization.py backed by tests/features/alert_rule_customization.feature. +""" + +import unittest + +from cosl.rules_customization import ( + AlertRulesCustomization, + AlertRulesCustomizationError, +) + + +def _sample_alerts(): + return { + "app-1": { + "groups": [ + { + "name": "group_a", + "rules": [ + { + "alert": "HighLatency", + "expr": "latency > 100", + "for": "10m", + "labels": {"severity": "critical", "juju_application": "app-1"}, + "annotations": {"summary": "latency is high"}, + }, + ], + }, + ] + } + } + + +class TestFromYamlValidation(unittest.TestCase): + def test_invalid_yaml_raises(self): + with self.assertRaises(AlertRulesCustomizationError): + AlertRulesCustomization.from_yaml("remove: [unclosed") + + def test_non_mapping_top_level_raises(self): + for config in ("- a\n- b", "42", '"just a string"'): + with self.assertRaises(AlertRulesCustomizationError): + AlertRulesCustomization.from_yaml(config) + + def test_unknown_top_level_key_raises(self): + config = """ + remove: + - where: + alert: Foo + destroy: + - where: + alert: Foo + """ + with self.assertRaisesRegex(AlertRulesCustomizationError, "top-level"): + AlertRulesCustomization.from_yaml(config) + + def test_remove_missing_where_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "remove"): + AlertRulesCustomization.from_yaml("remove:\n - alert: Foo") + + def test_remove_empty_where_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' must not be empty"): + AlertRulesCustomization.from_yaml("remove:\n - where: {}") + + def test_remove_unknown_where_key_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' keys"): + AlertRulesCustomization.from_yaml("remove:\n - where:\n expr: up < 1") + + def test_patch_missing_where_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "patch"): + AlertRulesCustomization.from_yaml("patch:\n - set:\n for: 5m") + + def test_patch_missing_set_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "patch"): + AlertRulesCustomization.from_yaml("patch:\n - where:\n alert: Foo") + + def test_patch_empty_where_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' must not be empty"): + AlertRulesCustomization.from_yaml("patch:\n - where: {}\n set:\n for: 5m") + + def test_patch_unknown_where_key_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' keys"): + AlertRulesCustomization.from_yaml( + "patch:\n - where:\n record: some:record\n set:\n expr: up" + ) + + def test_patch_unknown_set_key_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "'set' keys"): + AlertRulesCustomization.from_yaml( + "patch:\n - where:\n alert: Foo\n set:\n duration: 5m" + ) + + def test_add_without_groups_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "'add'"): + AlertRulesCustomization.from_yaml("add:\n rules:\n - alert: Foo\n expr: up") + + def test_add_groups_not_a_list_raises(self): + with self.assertRaisesRegex(AlertRulesCustomizationError, "'add.groups' must be a list"): + AlertRulesCustomization.from_yaml("add:\n groups:\n name: my-group\n rules: []") + + def test_add_group_missing_name_or_rules_raises(self): + for group in ("rules: []", "name: my-group"): + with self.assertRaisesRegex(AlertRulesCustomizationError, "add.groups"): + AlertRulesCustomization.from_yaml(f"add:\n groups:\n - {group}") + + def test_add_malformed_values_raise(self): + cases = { + "add not a mapping": "add: groups", + "group not a mapping": "add:\n groups:\n - just-a-string", + "name not a string": "add:\n groups:\n - name: [1]\n rules: []", + "rules not a list": "add:\n groups:\n - name: my-group\n rules: nope", + } + for case, config in cases.items(): + with self.subTest(case): + with self.assertRaises(AlertRulesCustomizationError): + AlertRulesCustomization.from_yaml(config) + + def test_operations_not_a_list_raises(self): + for key in ("remove", "patch"): + with self.assertRaisesRegex(AlertRulesCustomizationError, f"'{key}'"): + AlertRulesCustomization.from_yaml(f"{key}: not-a-list") + + def test_malformed_operation_entries_raise(self): + cases = { + "where not a mapping": "remove:\n - where: nope", + "where.alert not a string": "remove:\n - where:\n alert: [1, 2]", + "where.labels not a mapping": "remove:\n - where:\n labels: severity", + "set not a mapping": "patch:\n - where:\n alert: Foo\n set: nope", + "set.expr not a string": ( + "patch:\n - where:\n alert: Foo\n set:\n expr: {a: b}" + ), + "set.labels not a mapping": ( + "patch:\n - where:\n alert: Foo\n set:\n labels: x" + ), + "remove entry not a mapping": "remove:\n - just-a-string", + "patch entry not a mapping": "patch:\n - just-a-string", + } + for case, config in cases.items(): + with self.subTest(case): + with self.assertRaises(AlertRulesCustomizationError): + AlertRulesCustomization.from_yaml(config) + + +class TestNoOpConfigs(unittest.TestCase): + def _assert_noop(self, config_string): + sample = _sample_alerts() + result = AlertRulesCustomization.from_yaml(config_string).apply(sample) + self.assertEqual(result, sample) + + def test_empty_config_is_noop(self): + self._assert_noop("") + + def test_whitespace_config_is_noop(self): + self._assert_noop(" \n\t ") + + def test_none_parsing_config_is_noop(self): + self._assert_noop("~") + self._assert_noop("# just a comment") + self._assert_noop("{}") + + def test_zero_match_remove_is_noop(self): + config = """ + remove: + - where: + alert: NoSuchAlert + - where: + labels: + nope: nothing + """ + self._assert_noop(config) + + def test_zero_match_patch_is_noop(self): + config = """ + patch: + - where: + alert: NoSuchAlert + set: + for: 1m + """ + self._assert_noop(config) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index e20c5c5..c4ee230 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.10'", @@ -36,13 +36,13 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "mypy-extensions", marker = "python_full_version < '3.9'" }, - { name = "packaging", marker = "python_full_version < '3.9'" }, - { name = "pathspec", version = "0.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" } }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec", version = "0.12.1", source = { registry = "https://pypi.org/simple" } }, + { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" } }, + { name = "tomli" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/b0/46fb0d4e00372f4a86a6f8efa3cb193c9f64863615e39010b1477e010578/black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f", size = 644810, upload-time = "2024-08-02T17:43:18.405Z" } wheels = [ @@ -77,14 +77,14 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "mypy-extensions", marker = "python_full_version == '3.9.*'" }, - { name = "packaging", marker = "python_full_version == '3.9.*'" }, - { name = "pathspec", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pytokens", marker = "python_full_version == '3.9.*'" }, - { name = "tomli", marker = "python_full_version == '3.9.*'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" } }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec", version = "1.1.1", source = { registry = "https://pypi.org/simple" } }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pytokens" }, + { name = "tomli" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } wheels = [ @@ -123,14 +123,14 @@ resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "click", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "mypy-extensions", marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pathspec", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytokens", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "click", version = "8.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec", version = "1.1.1", source = { registry = "https://pypi.org/simple" } }, + { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ @@ -171,7 +171,7 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ @@ -186,7 +186,7 @@ resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ @@ -229,7 +229,7 @@ wheels = [ [[package]] name = "cosl" -version = "1.10.2" +version = "1.11.3" source = { editable = "." } dependencies = [ { name = "diskcache" }, @@ -261,6 +261,8 @@ dev = [ { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pytest", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-bdd", version = "7.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest-bdd", version = "8.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-cov", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "ruff" }, @@ -281,6 +283,7 @@ requires-dist = [ { name = "pydantic" }, { name = "pyright", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-bdd", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "pyyaml" }, { name = "ruff", marker = "extra == 'dev'" }, @@ -374,7 +377,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version < '3.9'" }, + { name = "tomli" }, ] [[package]] @@ -493,7 +496,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version == '3.9.*'" }, + { name = "tomli" }, ] [[package]] @@ -614,7 +617,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -634,7 +637,7 @@ name = "deprecated" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wrapt", marker = "python_full_version < '3.9'" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ @@ -655,8 +658,8 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' or python_full_version >= '3.11'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -678,6 +681,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261, upload-time = "2022-05-02T09:25:52.363Z" }, ] +[[package]] +name = "gherkin-official" +version = "29.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/d8/7a28537efd7638448f7512a0cce011d4e3bf1c7f4794ad4e9c87b3f1e98e/gherkin_official-29.0.0.tar.gz", hash = "sha256:dbea32561158f02280d7579d179b019160d072ce083197625e2f80a6776bb9eb", size = 32303, upload-time = "2024-08-12T09:41:09.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/fc/b86c22ad3b18d8324a9d6fe5a3b55403291d2bf7572ba6a16efa5aa88059/gherkin_official-29.0.0-py3-none-any.whl", hash = "sha256:26967b0d537a302119066742669e0e8b663e632769330be675457ae993e1d1bc", size = 37085, upload-time = "2024-08-12T09:41:07.954Z" }, +] + [[package]] name = "importlib-metadata" version = "8.5.0" @@ -686,7 +698,7 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ @@ -701,7 +713,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -733,6 +745,199 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +dependencies = [ + { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + +[[package]] +name = "markupsafe" +version = "2.1.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384, upload-time = "2024-02-02T16:31:22.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/54/ad5eb37bf9d51800010a74e4665425831a9db4e7c4e0fde4352e391e808e/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", size = 18206, upload-time = "2024-02-02T16:30:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4a/a4d49415e600bacae038c67f9fecc1d5433b9d3c71a4de6f33537b89654c/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", size = 14079, upload-time = "2024-02-02T16:30:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7b/85681ae3c33c385b10ac0f8dd025c30af83c78cec1c37a6aa3b55e67f5ec/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", size = 26620, upload-time = "2024-02-02T16:30:08.31Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/2b1b570f6b8b803cef5ac28fdf78c0da318916c7d2fe9402a84d591b394c/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", size = 25818, upload-time = "2024-02-02T16:30:09.577Z" }, + { url = "https://files.pythonhosted.org/packages/29/fe/a36ba8c7ca55621620b2d7c585313efd10729e63ef81e4e61f52330da781/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", size = 25493, upload-time = "2024-02-02T16:30:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/60/ae/9c60231cdfda003434e8bd27282b1f4e197ad5a710c14bee8bea8a9ca4f0/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", size = 30630, upload-time = "2024-02-02T16:30:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/65/dc/1510be4d179869f5dafe071aecb3f1f41b45d37c02329dfba01ff59e5ac5/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", size = 29745, upload-time = "2024-02-02T16:30:14.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/39/8d845dd7d0b0613d86e0ef89549bfb5f61ed781f59af45fc96496e897f3a/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", size = 30021, upload-time = "2024-02-02T16:30:16.032Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5c/356a6f62e4f3c5fbf2602b4771376af22a3b16efa74eb8716fb4e328e01e/MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", size = 16659, upload-time = "2024-02-02T16:30:17.079Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/acbf292615c65f0604a0c6fc402ce6d8c991276e16c80c46a8f758fbd30c/MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", size = 17213, upload-time = "2024-02-02T16:30:18.251Z" }, + { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219, upload-time = "2024-02-02T16:30:19.988Z" }, + { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098, upload-time = "2024-02-02T16:30:21.063Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014, upload-time = "2024-02-02T16:30:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220, upload-time = "2024-02-02T16:30:24.76Z" }, + { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756, upload-time = "2024-02-02T16:30:25.877Z" }, + { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988, upload-time = "2024-02-02T16:30:26.935Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718, upload-time = "2024-02-02T16:30:28.111Z" }, + { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317, upload-time = "2024-02-02T16:30:29.214Z" }, + { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670, upload-time = "2024-02-02T16:30:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224, upload-time = "2024-02-02T16:30:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215, upload-time = "2024-02-02T16:30:33.081Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069, upload-time = "2024-02-02T16:30:34.148Z" }, + { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452, upload-time = "2024-02-02T16:30:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462, upload-time = "2024-02-02T16:30:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869, upload-time = "2024-02-02T16:30:37.834Z" }, + { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906, upload-time = "2024-02-02T16:30:39.366Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296, upload-time = "2024-02-02T16:30:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038, upload-time = "2024-02-02T16:30:42.243Z" }, + { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572, upload-time = "2024-02-02T16:30:43.326Z" }, + { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127, upload-time = "2024-02-02T16:30:44.418Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ff/2c942a82c35a49df5de3a630ce0a8456ac2969691b230e530ac12314364c/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", size = 18192, upload-time = "2024-02-02T16:30:57.715Z" }, + { url = "https://files.pythonhosted.org/packages/4f/14/6f294b9c4f969d0c801a4615e221c1e084722ea6114ab2114189c5b8cbe0/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", size = 14072, upload-time = "2024-02-02T16:30:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/81/d4/fd74714ed30a1dedd0b82427c02fa4deec64f173831ec716da11c51a50aa/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", size = 26928, upload-time = "2024-02-02T16:30:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/50319665ce81bb10e90d1cf76f9e1aa269ea6f7fa30ab4521f14d122a3df/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", size = 26106, upload-time = "2024-02-02T16:31:01.582Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/f2b0f675635b05f6afd5ea03c094557bdb8622fa8e673387444fe8d8e787/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68", size = 25781, upload-time = "2024-02-02T16:31:02.71Z" }, + { url = "https://files.pythonhosted.org/packages/51/e0/393467cf899b34a9d3678e78961c2c8cdf49fb902a959ba54ece01273fb1/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", size = 30518, upload-time = "2024-02-02T16:31:04.392Z" }, + { url = "https://files.pythonhosted.org/packages/f6/02/5437e2ad33047290dafced9df741d9efc3e716b75583bbd73a9984f1b6f7/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", size = 29669, upload-time = "2024-02-02T16:31:05.53Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7d/968284145ffd9d726183ed6237c77938c021abacde4e073020f920e060b2/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", size = 29933, upload-time = "2024-02-02T16:31:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f3/ecb00fc8ab02b7beae8699f34db9357ae49d9f21d4d3de6f305f34fa949e/MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", size = 16656, upload-time = "2024-02-02T16:31:07.767Z" }, + { url = "https://files.pythonhosted.org/packages/92/21/357205f03514a49b293e214ac39de01fadd0970a6e05e4bf1ddd0ffd0881/MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", size = 17206, upload-time = "2024-02-02T16:31:08.843Z" }, + { url = "https://files.pythonhosted.org/packages/0f/31/780bb297db036ba7b7bbede5e1d7f1e14d704ad4beb3ce53fb495d22bc62/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", size = 18193, upload-time = "2024-02-02T16:31:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d77701bbef72892affe060cdacb7a2ed7fd68dae3b477a8642f15ad3b132/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", size = 14073, upload-time = "2024-02-02T16:31:11.442Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a7/1e558b4f78454c8a3a0199292d96159eb4d091f983bc35ef258314fe7269/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", size = 26486, upload-time = "2024-02-02T16:31:12.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5a/360da85076688755ea0cceb92472923086993e86b5613bbae9fbc14136b0/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", size = 25685, upload-time = "2024-02-02T16:31:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/6a/18/ae5a258e3401f9b8312f92b028c54d7026a97ec3ab20bfaddbdfa7d8cce8/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", size = 25338, upload-time = "2024-02-02T16:31:14.812Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cc/48206bd61c5b9d0129f4d75243b156929b04c94c09041321456fd06a876d/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", size = 30439, upload-time = "2024-02-02T16:31:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/d1/06/a41c112ab9ffdeeb5f77bc3e331fdadf97fa65e52e44ba31880f4e7f983c/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", size = 29531, upload-time = "2024-02-02T16:31:17.13Z" }, + { url = "https://files.pythonhosted.org/packages/02/8c/ab9a463301a50dab04d5472e998acbd4080597abc048166ded5c7aa768c8/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", size = 29823, upload-time = "2024-02-02T16:31:18.247Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/9bc18da763496b055d8e98ce476c8e718dcfd78157e17f555ce6dd7d0895/MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", size = 16658, upload-time = "2024-02-02T16:31:19.583Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f8/4da07de16f10551ca1f640c92b5f316f9394088b183c6a57183df6de5ae4/MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", size = 17211, upload-time = "2024-02-02T16:31:20.96Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -759,8 +964,8 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "deprecated", marker = "python_full_version < '3.9'" }, - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "deprecated" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/8d/1f5a45fbcb9a7d87809d460f09dc3399e3fbd31d7f3e14888345e9d29951/opentelemetry_api-1.33.1.tar.gz", hash = "sha256:1c6055fc0a2d3f23a50c7e17e16ef75ad489345fd3df1f8b8af7c0bbf8a109e8", size = 65002, upload-time = "2025-05-16T18:52:41.146Z" } wheels = [ @@ -775,8 +980,8 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ @@ -791,7 +996,7 @@ resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } wheels = [ @@ -807,12 +1012,12 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "opentelemetry-api", version = "1.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "opentelemetry-api", version = "1.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, { name = "opentelemetry-api", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pyyaml", marker = "python_full_version < '3.10'" }, - { name = "websocket-client", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pyyaml" }, + { name = "websocket-client", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/98/84789a5e15ad76e043301bbd70b4d39cffaa717ae6eecb662fd0dd0cc7af/ops-2.23.2.tar.gz", hash = "sha256:a69b0c5bc65ebd91720fc96459e81b969df851f3a6c9c855ee73de7004205987", size = 528074, upload-time = "2026-02-11T03:58:15.565Z" } @@ -822,7 +1027,7 @@ wheels = [ [package.optional-dependencies] testing = [ - { name = "ops-scenario", version = "7.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "ops-scenario", version = "7.23.2", source = { registry = "https://pypi.org/simple" } }, ] [[package]] @@ -833,9 +1038,9 @@ resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pyyaml", marker = "python_full_version >= '3.10'" }, - { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml" }, + { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/60/ad398d889fd03b1b4f950fad54ad4d0cf7a81fde21f9866a8139c8f03684/ops-3.7.1.tar.gz", hash = "sha256:1765bf6d1cff376ea27608542e183b055c89f2c5f54bca602072bcc817195abc", size = 582424, upload-time = "2026-05-28T04:13:43.906Z" } wheels = [ @@ -844,7 +1049,7 @@ wheels = [ [package.optional-dependencies] testing = [ - { name = "ops-scenario", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ops-scenario", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, ] [[package]] @@ -856,8 +1061,8 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "ops", version = "2.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "ops", version = "2.23.2", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ee/e7/1514b9c27b9364ec1d04791b0fb76d1a629c8cc73f90fbf6dd867457c914/ops_scenario-7.23.2.tar.gz", hash = "sha256:327dda8b3c871ccf16246ed4f8c7e093526af42093549976596cede58cbc8488", size = 70017, upload-time = "2026-02-11T03:58:16.553Z" } wheels = [ @@ -872,9 +1077,9 @@ resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pyyaml", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/4f/06/705b452b9145107978eb0400a1e751839ed1ec59dc152fa45a2e5fba0ea3/ops_scenario-8.7.1.tar.gz", hash = "sha256:25b24c7c612b8e089ad05f24a47c138999d1cc0a2683e0f7c74e0520c7bb6043", size = 78576, upload-time = "2026-05-28T04:13:45.292Z" } wheels = [ @@ -899,6 +1104,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "parse" +version = "1.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/f2/0b504486c2a5564798607d3860e48ed19c6443d5e9cc3ec61cc6b8b4ef58/parse-1.22.1.tar.gz", hash = "sha256:d3a4740ec3da338e2b258b2d69741b731eadfddca59e24a14bc4ee5fce38c911", size = 36970, upload-time = "2026-05-26T03:44:52.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/c5/7c16e99869e1f422629092cfd23e3b58e461988c3f9c36fd3624bb4142e6/parse-1.22.1-py2.py3-none-any.whl", hash = "sha256:20f0925a46f06602485ac90d751764d0697fd8455aaa97489ba8953a4b66de32", size = 20925, upload-time = "2026-05-26T03:44:51.156Z" }, +] + +[[package]] +name = "parse-type" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -993,9 +1220,9 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "annotated-types", marker = "python_full_version < '3.9'" }, - { name = "pydantic-core", version = "2.27.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "annotated-types" }, + { name = "pydantic-core", version = "2.27.2", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681, upload-time = "2025-01-24T01:42:12.693Z" } wheels = [ @@ -1011,10 +1238,10 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "annotated-types", marker = "python_full_version >= '3.9'" }, - { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "typing-inspection", marker = "python_full_version >= '3.9'" }, + { name = "annotated-types" }, + { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -1029,7 +1256,7 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" } wheels = [ @@ -1143,7 +1370,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -1299,12 +1526,12 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "packaging", marker = "python_full_version < '3.9'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } wheels = [ @@ -1319,13 +1546,13 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "packaging", marker = "python_full_version == '3.9.*'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pygments", marker = "python_full_version == '3.9.*'" }, - { name = "tomli", marker = "python_full_version == '3.9.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pygments" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -1340,19 +1567,63 @@ resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] +[[package]] +name = "pytest-bdd" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "mako", version = "1.3.12", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "parse" }, + { name = "parse-type" }, + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/9a/45c2aa5241513eb3169d9aa600933104a59dc12e8754ad63cf16dd8b9dcb/pytest_bdd-7.3.0.tar.gz", hash = "sha256:9dfeb1d8565d9548907f36a5a9e2c8e1e0cbac3b2724e17331b87386a19fbc16", size = 47435, upload-time = "2024-09-21T09:52:28.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/30/557b1cf7b951ad47d822f0495a248c0a18e6e12e4e3287bd63a85aeceb4b/pytest_bdd-7.3.0-py3-none-any.whl", hash = "sha256:168ede4a118e348feb70182590ee4a2f856e68dafe54a75a4e9203da37d4ade6", size = 42218, upload-time = "2024-09-21T09:52:26.404Z" }, +] + +[[package]] +name = "pytest-bdd" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "gherkin-official" }, + { name = "mako", version = "1.3.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mako", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging" }, + { name = "parse" }, + { name = "parse-type" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/2f/14c2e55372a5718a93b56aea48cd6ccc15d2d245364e516cd7b19bbd07ad/pytest_bdd-8.1.0.tar.gz", hash = "sha256:ef0896c5cd58816dc49810e8ff1d632f4a12019fb3e49959b2d349ffc1c9bfb5", size = 56147, upload-time = "2024-12-05T21:45:58.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/7d/1461076b0cc9a9e6fa8b51b9dea2677182ba8bc248d99d95ca321f2c666f/pytest_bdd-8.1.0-py3-none-any.whl", hash = "sha256:2124051e71a05ad7db15296e39013593f72ebf96796e1b023a40e5453c47e5fb", size = 49149, upload-time = "2024-12-05T21:45:56.184Z" }, +] + [[package]] name = "pytest-cov" version = "5.0.0" @@ -1361,8 +1632,8 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.9'" }, - { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"] }, + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" } wheels = [ @@ -1378,10 +1649,10 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version == '3.9.*'" }, + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, { name = "coverage", version = "7.14.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } @@ -1697,7 +1968,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ From 069816431f6ec99b5887101b7c1e5fc3bf5a3eac Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 25 Aug 2026 16:13:10 -0400 Subject: [PATCH 2/5] fix: lint --- tests/test_rules_customization.py | 69 ++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/tests/test_rules_customization.py b/tests/test_rules_customization.py index 9efb4f4..d198557 100644 --- a/tests/test_rules_customization.py +++ b/tests/test_rules_customization.py @@ -9,13 +9,12 @@ import copy import pytest -from pytest_bdd import given, parsers, scenario, scenarios, then, when +from pytest_bdd import given, parsers, scenarios, then, when from cosl.rules_customization import ( CUSTOM_ALERT_RULES_KEY, AlertRulesCustomization, ) -from conftest import find_rule scenarios("features/alert_rule_customization.feature") @@ -42,7 +41,7 @@ def given_sample_alerts(ctx, sample_alerts): ctx["original"] = copy.deepcopy(sample_alerts) -@given("a rule named \"GoneForever\" and a rule named \"Survivor\"") +@given('a rule named "GoneForever" and a rule named "Survivor"') def given_gone_forever_and_survivor(ctx): ctx["alerts"] = { "app": { @@ -85,7 +84,9 @@ def when_remove_group_a(ctx): ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) -@when('I apply a customization that removes alerts in group "group_a" with alert name "LowThroughput"') +@when( + 'I apply a customization that removes alerts in group "group_a" with alert name "LowThroughput"' +) def when_remove_group_a_low_throughput(ctx): config = """ remove: @@ -107,7 +108,9 @@ def when_remove_by_severity_warning(ctx): ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) -@when('I apply a customization that removes alerts with annotation "summary" equal to "latency is high"') +@when( + 'I apply a customization that removes alerts with annotation "summary" equal to "latency is high"' +) def when_remove_by_annotation(ctx): config = """ remove: @@ -178,7 +181,9 @@ def when_patch_for(ctx): ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) -@when('I apply a customization that patches alert "HighLatency" setting alert name to "RenamedLatency"') +@when( + 'I apply a customization that patches alert "HighLatency" setting alert name to "RenamedLatency"' +) def when_patch_alert_name(ctx): config = """ patch: @@ -202,7 +207,9 @@ def when_patch_expr(ctx): ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) -@when('I apply a customization that patches alert "HighLatency" setting label "severity" to "page" and adding label "extra" as "added"') +@when( + 'I apply a customization that patches alert "HighLatency" setting label "severity" to "page" and adding label "extra" as "added"' +) def when_patch_labels(ctx): config = """ patch: @@ -216,7 +223,9 @@ def when_patch_labels(ctx): ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) -@when('I apply a customization that patches alert "HighLatency" setting label "juju_application" to "other-app"') +@when( + 'I apply a customization that patches alert "HighLatency" setting label "juju_application" to "other-app"' +) def when_patch_juju_label(ctx): config = """ patch: @@ -229,7 +238,9 @@ def when_patch_juju_label(ctx): ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) -@when('I apply a customization that patches alert "HighLatency" setting annotation "summary" to "new summary" and adding annotation "description" as "new description"') +@when( + 'I apply a customization that patches alert "HighLatency" setting annotation "summary" to "new summary" and adding annotation "description" as "new description"' +) def when_patch_annotations(ctx): config = """ patch: @@ -255,7 +266,9 @@ def when_patch_group_expr(ctx): ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) -@when('I apply a customization that patches alerts with label "severity" equal to "warning" setting label "severity" to "critical"') +@when( + 'I apply a customization that patches alerts with label "severity" equal to "warning" setting label "severity" to "critical"' +) def when_patch_by_label(ctx): config = """ patch: @@ -288,7 +301,9 @@ def when_add_group(ctx): ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) -@when("I apply a customization that adds a group named \"my-custom-alerts\" with alert \"MyAlert\" and expr 'up{juju_model=\"prod\"} == 0'") +@when( + 'I apply a customization that adds a group named "my-custom-alerts" with alert "MyAlert" and expr \'up{juju_model="prod"} == 0\'' +) def when_add_group_check_expr(ctx): config = """ add: @@ -327,7 +342,7 @@ def when_mutate_first_result(ctx): # --------------------------------------------------------------------------- -@when("I apply a customization that removes alert \"LowThroughput\" and patches alert \"HighLatency\"") +@when('I apply a customization that removes alert "LowThroughput" and patches alert "HighLatency"') def when_remove_and_patch(ctx): config = """ remove: @@ -345,7 +360,9 @@ def when_remove_and_patch(ctx): ctx["result"] = ctx["alerts"] # we check that the original is unchanged -@when('I apply a customization that removes "GoneForever", patches "Survivor" for to "2m", and adds a new "GoneForever"') +@when( + 'I apply a customization that removes "GoneForever", patches "Survivor" for to "2m", and adds a new "GoneForever"' +) def when_order_of_operations(ctx): config = """ remove: @@ -380,9 +397,7 @@ def when_reuse_customization(ctx): customization = AlertRulesCustomization.from_yaml(config) ctx["result1"] = customization.apply(ctx["alerts"]) other = { - "other": { - "groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}] - } + "other": {"groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}]} } ctx["result2"] = customization.apply(other) @@ -470,9 +485,9 @@ def then_alert_label(ctx, alert_name, label_key, label_value): result = ctx["result"] found = _find_alert_anywhere(result, alert_name) labels = found.get("labels", {}) - assert labels.get(label_key) == label_value, ( - f"expected label {label_key}={label_value!r}, got {labels.get(label_key)!r}" - ) + assert ( + labels.get(label_key) == label_value + ), f"expected label {label_key}={label_value!r}, got {labels.get(label_key)!r}" @then(parsers.parse('alert "{alert_name}" has annotation "{ann_key}" equal to "{ann_value}"')) @@ -480,9 +495,9 @@ def then_alert_annotation(ctx, alert_name, ann_key, ann_value): result = ctx["result"] found = _find_alert_anywhere(result, alert_name) annotations = found.get("annotations", {}) - assert annotations.get(ann_key) == ann_value, ( - f"expected annotation {ann_key}={ann_value!r}, got {annotations.get(ann_key)!r}" - ) + assert ( + annotations.get(ann_key) == ann_value + ), f"expected annotation {ann_key}={ann_value!r}, got {annotations.get(ann_key)!r}" @then(parsers.parse('alert "{alert_name}" has no labels')) @@ -499,7 +514,11 @@ def then_recording_rule_expr(ctx, record_name, value): assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" -@then(parsers.parse('the recording rule "{record_name}" has label "{label_key}" equal to "{label_value}"')) +@then( + parsers.parse( + 'the recording rule "{record_name}" has label "{label_key}" equal to "{label_value}"' + ) +) def then_recording_rule_label(ctx, record_name, label_key, label_value): result = ctx["result"] found = _find_record_anywhere(result, record_name) @@ -539,13 +558,13 @@ def then_added_gone_forever_no_for(ctx): assert "for" not in added -@then("the second result still contains alert \"MyAlert\"") +@then('the second result still contains alert "MyAlert"') def then_second_result_has_my_alert(ctx): rule = ctx["result2"][CUSTOM_ALERT_RULES_KEY]["groups"][0]["rules"][0] assert rule["alert"] == "MyAlert" -@then("alert \"HostDown\" is absent from both results") +@then('alert "HostDown" is absent from both results') def then_host_down_absent_both(ctx): assert "HostDown" not in str(ctx["result1"]) assert "HostDown" not in str(ctx["result2"]) From 1fc1a4321d413e4a76ca6fa5f51c2e4e989ea311 Mon Sep 17 00:00:00 2001 From: Sina Date: Wed, 26 Aug 2026 12:08:44 -0400 Subject: [PATCH 3/5] fix: feature files --- tests/conftest.py | 279 +++++- .../features/alert_rule_customization.feature | 122 --- tests/features/patch.feature | 46 + tests/features/remove.feature | 57 ++ tests/features/remove_patch.feature | 20 + tests/test_rules_customization.py | 914 ------------------ tests/test_rules_customization_patch.py | 244 +++++ tests/test_rules_customization_remove.py | 315 ++++++ .../test_rules_customization_remove_patch.py | 149 +++ 9 files changed, 1109 insertions(+), 1037 deletions(-) delete mode 100644 tests/features/alert_rule_customization.feature create mode 100644 tests/features/patch.feature create mode 100644 tests/features/remove.feature create mode 100644 tests/features/remove_patch.feature delete mode 100644 tests/test_rules_customization.py create mode 100644 tests/test_rules_customization_patch.py create mode 100644 tests/test_rules_customization_remove.py create mode 100644 tests/test_rules_customization_remove_patch.py diff --git a/tests/conftest.py b/tests/conftest.py index a14ea7c..4b376ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,17 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Shared pytest fixtures for cos-lib tests.""" +"""Shared pytest fixtures, step definitions, and helpers for alert rule customization tests.""" + +import copy import pytest +from pytest_bdd import given, parsers, then + +from cosl.rules_customization import AlertRulesCustomization + +# --------------------------------------------------------------------------- +# Data fixtures +# --------------------------------------------------------------------------- @pytest.fixture @@ -55,9 +64,277 @@ def sample_alerts(): } +@pytest.fixture +def ctx(): + """Mutable context dict shared across steps within a scenario.""" + return {} + + +# --------------------------------------------------------------------------- +# Given steps (shared across feature files) +# --------------------------------------------------------------------------- + + +@given("a set of relation alerts from two apps") +def given_sample_alerts(ctx, sample_alerts): + ctx["alerts"] = sample_alerts + ctx["original"] = copy.deepcopy(sample_alerts) + + +@given('a rule named "GoneForever" and a rule named "Survivor"') +def given_gone_forever_and_survivor(ctx): + ctx["alerts"] = { + "app": { + "groups": [ + { + "name": "g", + "rules": [ + {"alert": "GoneForever", "expr": "x", "for": "10m"}, + {"alert": "Survivor", "expr": "y", "for": "10m"}, + ], + } + ] + } + } + ctx["original"] = copy.deepcopy(ctx["alerts"]) + + +# --------------------------------------------------------------------------- +# Then — Presence / absence of alerts +# --------------------------------------------------------------------------- + + +@then(parsers.parse('alert "{alert_name}" is absent from the result')) +def then_alert_absent(ctx, alert_name): + assert alert_name not in str(ctx["result"]) + + +@then(parsers.parse('alert "{alert_name}" is present in the result')) +def then_alert_present(ctx, alert_name): + assert alert_name in str(ctx["result"]) + + +@then(parsers.parse('the recording rule "{record_name}" is present in the result')) +def then_recording_rule_present(ctx, record_name): + assert record_name in str(ctx["result"]) + + +# --------------------------------------------------------------------------- +# Then — Presence / absence of groups and identifiers +# --------------------------------------------------------------------------- + + +@then(parsers.parse('group "{group_name}" is absent from identifier "{identifier}"')) +def then_group_absent(ctx, group_name, identifier): + result = ctx["result"] + if identifier not in result: + return + group_names = [g["name"] for g in result[identifier].get("groups", [])] + assert group_name not in group_names + + +@then(parsers.parse('group "{group_name}" is present in identifier "{identifier}"')) +def then_group_present(ctx, group_name, identifier): + result = ctx["result"] + assert identifier in result + group_names = [g["name"] for g in result[identifier].get("groups", [])] + assert group_name in group_names + + +@then(parsers.parse('identifier "{identifier}" is absent from the result')) +def then_identifier_absent(ctx, identifier): + assert identifier not in ctx["result"] + + +@then(parsers.parse('identifier "{identifier}" is present in the result')) +def then_identifier_present(ctx, identifier): + assert identifier in ctx["result"] + + +# --------------------------------------------------------------------------- +# Then — Rule field assertions +# --------------------------------------------------------------------------- + + +@then(parsers.parse('alert "{alert_name}" has for equal to "{value}"')) +def then_alert_for(ctx, alert_name, value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + assert found["for"] == value, f"expected for={value!r}, got {found.get('for')!r}" + + +@then(parsers.parse("alert \"{alert_name}\" has expr equal to '{value}'")) +def then_alert_expr_single_quoted(ctx, alert_name, value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" + + +@then(parsers.parse('alert "{alert_name}" has expr equal to "{value}"')) +def then_alert_expr(ctx, alert_name, value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" + + +@then(parsers.parse('alert "{alert_name}" has label "{label_key}" equal to "{label_value}"')) +def then_alert_label(ctx, alert_name, label_key, label_value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + labels = found.get("labels", {}) + assert ( + labels.get(label_key) == label_value + ), f"expected label {label_key}={label_value!r}, got {labels.get(label_key)!r}" + + +@then(parsers.parse('alert "{alert_name}" has annotation "{ann_key}" equal to "{ann_value}"')) +def then_alert_annotation(ctx, alert_name, ann_key, ann_value): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + annotations = found.get("annotations", {}) + assert ( + annotations.get(ann_key) == ann_value + ), f"expected annotation {ann_key}={ann_value!r}, got {annotations.get(ann_key)!r}" + + +@then(parsers.parse('alert "{alert_name}" has no labels')) +def then_alert_no_labels(ctx, alert_name): + result = ctx["result"] + found = _find_alert_anywhere(result, alert_name) + assert not found.get("labels"), f"expected no labels, got {found.get('labels')!r}" + + +@then(parsers.parse('the recording rule "{record_name}" has expr equal to "{value}"')) +def then_recording_rule_expr(ctx, record_name, value): + result = ctx["result"] + found = _find_record_anywhere(result, record_name) + assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" + + +@then( + parsers.parse( + 'the recording rule "{record_name}" has label "{label_key}" equal to "{label_value}"' + ) +) +def then_recording_rule_label(ctx, record_name, label_key, label_value): + result = ctx["result"] + found = _find_record_anywhere(result, record_name) + labels = found.get("labels", {}) + assert labels.get(label_key) == label_value + + +# --------------------------------------------------------------------------- +# Then — Apply semantics (shared across remove-patch file) +# --------------------------------------------------------------------------- + + +@then("the original input is unchanged") +def then_original_unchanged(ctx): + assert ctx["alerts"] == ctx["original"] + + +@then(parsers.parse('alert "{alert_name}" is absent from identifier "{identifier}"')) +def then_alert_absent_from_identifier(ctx, alert_name, identifier): + result = ctx["result"] + if identifier not in result: + return + assert alert_name not in str(result[identifier]) + + +@then(parsers.parse('alert "{alert_name}" in app has for equal to "{value}"')) +def then_alert_in_app_for(ctx, alert_name, value): + rules = ctx["result"]["app"]["groups"][0]["rules"] + found = next(r for r in rules if r.get("alert") == alert_name) + assert found["for"] == value + + +@then(parsers.parse('alert "{alert_name}" is absent from both results')) +def then_alert_absent_both(ctx, alert_name): + assert alert_name not in str(ctx["result1"]) + assert alert_name not in str(ctx["result2"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _find_alert_anywhere(result, alert_name): + """Search all identifiers/groups for an alerting rule by name.""" + for rule_file in result.values(): + for group in rule_file.get("groups", []): + for rule in group.get("rules", []): + if rule.get("alert") == alert_name: + return rule + raise AssertionError(f"Alert {alert_name!r} not found in result") + + +def _find_record_anywhere(result, record_name): + """Search all identifiers/groups for a recording rule by name.""" + for rule_file in result.values(): + for group in rule_file.get("groups", []): + for rule in group.get("rules", []): + if rule.get("record") == record_name: + return rule + raise AssertionError(f"Recording rule {record_name!r} not found in result") + + +def _sample_alerts(): + """Relation alerts dict in the same shape as MetricsConsumer.alerts.""" + return { + "app-1": { + "groups": [ + { + "name": "group_a", + "rules": [ + { + "alert": "HighLatency", + "expr": "latency > 100", + "for": "10m", + "labels": {"severity": "critical", "juju_application": "app-1"}, + "annotations": {"summary": "latency is high"}, + }, + { + "alert": "LowThroughput", + "expr": "throughput < 10", + "for": "5m", + "labels": {"severity": "warning"}, + }, + { + "record": "job:latency:mean5m", + "expr": "avg(latency)", + "labels": {"severity": "warning"}, + }, + ], + }, + { + "name": "group_b", + "rules": [ + {"alert": "HostDown", "expr": "up < 1"}, + ], + }, + ] + }, + "app-2": { + "groups": [ + { + "name": "group_c", + "rules": [ + {"alert": "OtherAlert", "expr": "x > 0"}, + ], + } + ] + }, + } + + def find_rule(alerts, identifier, group_name, rule_name, *, by_record=False): """Return a single rule from an alerts dict, raising if not found.""" key = "record" if by_record else "alert" groups = alerts[identifier]["groups"] group = next(g for g in groups if g["name"] == group_name) return next(rule for rule in group["rules"] if rule.get(key) == rule_name) + + +def _apply(config): + return AlertRulesCustomization.from_yaml(config).apply(_sample_alerts()) diff --git a/tests/features/alert_rule_customization.feature b/tests/features/alert_rule_customization.feature deleted file mode 100644 index 4790228..0000000 --- a/tests/features/alert_rule_customization.feature +++ /dev/null @@ -1,122 +0,0 @@ -Feature: Alert rule customization - As a COS admin - I want to customize relation-derived alert rules via a YAML config - So that I can remove or patch rules without modifying charm code - - Background: - Given a set of relation alerts from two apps - - # --------------------------------------------------------------------------- - # Remove - # --------------------------------------------------------------------------- - - Scenario: Remove an alert by name - When I apply a customization that removes alert "LowThroughput" - Then alert "LowThroughput" is absent from the result - And alert "HighLatency" is present in the result - And the recording rule "job:latency:mean5m" is present in the result - - Scenario: Remove an entire group by group name drops everything including recording rules - When I apply a customization that removes group "group_a" - Then group "group_a" is absent from identifier "app-1" - And group "group_b" is present in identifier "app-1" - - Scenario: Remove with group and another selector only removes matching alerting rules - When I apply a customization that removes alerts in group "group_a" with alert name "LowThroughput" - Then alert "LowThroughput" is absent from the result - And alert "HighLatency" is present in the result - And the recording rule "job:latency:mean5m" is present in the result - - Scenario: Remove by label value - When I apply a customization that removes alerts with label "severity" equal to "warning" - Then alert "LowThroughput" is absent from the result - And alert "HighLatency" is present in the result - And the recording rule "job:latency:mean5m" is present in the result - - Scenario: Remove by annotation value - When I apply a customization that removes alerts with annotation "summary" equal to "latency is high" - Then alert "HighLatency" is absent from the result - And alert "LowThroughput" is present in the result - - Scenario: Remove by juju topology label - When I apply a customization that removes alerts with label "juju_application" equal to "app-1" - Then alert "HighLatency" is absent from the result - And alert "LowThroughput" is present in the result - - Scenario: Multiple remove entries are OR'd - When I apply a customization that removes alert "HighLatency" and alert "OtherAlert" - Then alert "HighLatency" is absent from the result - And alert "OtherAlert" is absent from the result - And alert "LowThroughput" is present in the result - And alert "HostDown" is present in the result - - Scenario: Removing the only rule in a group prunes the empty group - When I apply a customization that removes alert "HostDown" - Then group "group_b" is absent from identifier "app-1" - And group "group_a" is present in identifier "app-1" - - Scenario: Removing all rules from an identifier drops the identifier entirely - When I apply a customization that removes alert "OtherAlert" - Then identifier "app-2" is absent from the result - And identifier "app-1" is present in the result - - # --------------------------------------------------------------------------- - # Patch - # --------------------------------------------------------------------------- - - Scenario: Patch updates the for duration of a matching alert - When I apply a customization that patches alert "HighLatency" setting for to "30m" - Then alert "HighLatency" has for equal to "30m" - And alert "LowThroughput" has for equal to "5m" - - Scenario: Patch replaces the alert name - When I apply a customization that patches alert "HighLatency" setting alert name to "RenamedLatency" - Then alert "RenamedLatency" is present in the result - And alert "HighLatency" is absent from the result - - Scenario: Patch replaces the expression - When I apply a customization that patches alert "HostDown" setting expr to "up == 0" - Then alert "HostDown" has expr equal to "up == 0" - - Scenario: Patch overwrites an existing label and adds a new one leaving others untouched - When I apply a customization that patches alert "HighLatency" setting label "severity" to "page" and adding label "extra" as "added" - Then alert "HighLatency" has label "severity" equal to "page" - And alert "HighLatency" has label "extra" equal to "added" - And alert "HighLatency" has label "juju_application" equal to "app-1" - - Scenario: Patch updates a juju topology label - When I apply a customization that patches alert "HighLatency" setting label "juju_application" to "other-app" - Then alert "HighLatency" has label "juju_application" equal to "other-app" - - Scenario: Patch merges annotations - When I apply a customization that patches alert "HighLatency" setting annotation "summary" to "new summary" and adding annotation "description" as "new description" - Then alert "HighLatency" has annotation "summary" equal to "new summary" - And alert "HighLatency" has annotation "description" equal to "new description" - - Scenario: Patch does not affect recording rules - When I apply a customization that patches all rules in group "group_a" setting expr to "hacked" - Then the recording rule "job:latency:mean5m" has expr equal to "avg(latency)" - And alert "HighLatency" has expr equal to "hacked" - - Scenario: Patch matches by label value across rules - When I apply a customization that patches alerts with label "severity" equal to "warning" setting label "severity" to "critical" - Then alert "LowThroughput" has label "severity" equal to "critical" - And the recording rule "job:latency:mean5m" has label "severity" equal to "warning" - - # --------------------------------------------------------------------------- - # Apply semantics - # --------------------------------------------------------------------------- - - Scenario: The original input is not mutated by apply - When I apply a customization that removes alert "LowThroughput" and patches alert "HighLatency" - Then the original input is unchanged - - Scenario: Operations are applied in order remove then patch - Given a rule named "GoneForever" and a rule named "Survivor" - When I apply a customization that removes "GoneForever" and patches "Survivor" for to "2m" - Then alert "GoneForever" is absent from identifier "app" - And alert "Survivor" has for equal to "2m" - - Scenario: The customization instance is reusable across different inputs - When I apply the same remove customization to two different inputs - Then alert "HostDown" is absent from both results diff --git a/tests/features/patch.feature b/tests/features/patch.feature new file mode 100644 index 0000000..a13e5c2 --- /dev/null +++ b/tests/features/patch.feature @@ -0,0 +1,46 @@ +Feature: Alert rule patch customization + As a COS admin + I want to modify existing alert rules via a YAML config + So that I can tweak thresholds, labels, and annotations + + Background: + Given a set of relation alerts from two apps + + Scenario: Patch updates the for duration of a matching alert + When I apply a customization that patches alert "HighLatency" setting for to "30m" + Then alert "HighLatency" has for equal to "30m" + And alert "LowThroughput" has for equal to "5m" + + Scenario: Patch replaces the alert name + When I apply a customization that patches alert "HighLatency" setting alert name to "RenamedLatency" + Then alert "RenamedLatency" is present in the result + And alert "HighLatency" is absent from the result + + Scenario: Patch replaces the expression + When I apply a customization that patches alert "HostDown" setting expr to "up == 0" + Then alert "HostDown" has expr equal to "up == 0" + + Scenario: Patch overwrites an existing label and adds a new one leaving others untouched + When I apply a customization that patches alert "HighLatency" setting label "severity" to "page" and adding label "extra" as "added" + Then alert "HighLatency" has label "severity" equal to "page" + And alert "HighLatency" has label "extra" equal to "added" + And alert "HighLatency" has label "juju_application" equal to "app-1" + + Scenario: Patch updates a juju topology label + When I apply a customization that patches alert "HighLatency" setting label "juju_application" to "other-app" + Then alert "HighLatency" has label "juju_application" equal to "other-app" + + Scenario: Patch merges annotations + When I apply a customization that patches alert "HighLatency" setting annotation "summary" to "new summary" and adding annotation "description" as "new description" + Then alert "HighLatency" has annotation "summary" equal to "new summary" + And alert "HighLatency" has annotation "description" equal to "new description" + + Scenario: Patch does not affect recording rules + When I apply a customization that patches all rules in group "group_a" setting expr to "hacked" + Then the recording rule "job:latency:mean5m" has expr equal to "avg(latency)" + And alert "HighLatency" has expr equal to "hacked" + + Scenario: Patch matches by label value across rules + When I apply a customization that patches alerts with label "severity" equal to "warning" setting label "severity" to "critical" + Then alert "LowThroughput" has label "severity" equal to "critical" + And the recording rule "job:latency:mean5m" has label "severity" equal to "warning" diff --git a/tests/features/remove.feature b/tests/features/remove.feature new file mode 100644 index 0000000..4ad5e6f --- /dev/null +++ b/tests/features/remove.feature @@ -0,0 +1,57 @@ +Feature: Alert rule remove customization + As a COS admin + I want to remove alert rules via a YAML config + So that I can drop irrelevant alerts + + Background: + Given a set of relation alerts from two apps + + Scenario: Remove an alert by name + When I apply a customization that removes alert "LowThroughput" + Then alert "LowThroughput" is absent from the result + And alert "HighLatency" is present in the result + And the recording rule "job:latency:mean5m" is present in the result + + Scenario: Remove an entire group by group name drops everything including recording rules + When I apply a customization that removes group "group_a" + Then group "group_a" is absent from identifier "app-1" + And group "group_b" is present in identifier "app-1" + + Scenario: Remove with group and another selector only removes matching alerting rules + When I apply a customization that removes alerts in group "group_a" with alert name "LowThroughput" + Then alert "LowThroughput" is absent from the result + And alert "HighLatency" is present in the result + And the recording rule "job:latency:mean5m" is present in the result + + Scenario: Remove by label value + When I apply a customization that removes alerts with label "severity" equal to "warning" + Then alert "LowThroughput" is absent from the result + And alert "HighLatency" is present in the result + And the recording rule "job:latency:mean5m" is present in the result + + Scenario: Remove by annotation value + When I apply a customization that removes alerts with annotation "summary" equal to "latency is high" + Then alert "HighLatency" is absent from the result + And alert "LowThroughput" is present in the result + + Scenario: Remove by juju topology label + When I apply a customization that removes alerts with label "juju_application" equal to "app-1" + Then alert "HighLatency" is absent from the result + And alert "LowThroughput" is present in the result + + Scenario: Multiple remove entries are OR'd + When I apply a customization that removes alert "HighLatency" and alert "OtherAlert" + Then alert "HighLatency" is absent from the result + And alert "OtherAlert" is absent from the result + And alert "LowThroughput" is present in the result + And alert "HostDown" is present in the result + + Scenario: Removing the only rule in a group prunes the empty group + When I apply a customization that removes alert "HostDown" + Then group "group_b" is absent from identifier "app-1" + And group "group_a" is present in identifier "app-1" + + Scenario: Removing all rules from an identifier drops the identifier entirely + When I apply a customization that removes alert "OtherAlert" + Then identifier "app-2" is absent from the result + And identifier "app-1" is present in the result diff --git a/tests/features/remove_patch.feature b/tests/features/remove_patch.feature new file mode 100644 index 0000000..79a30c6 --- /dev/null +++ b/tests/features/remove_patch.feature @@ -0,0 +1,20 @@ +Feature: Alert rule remove and patch interaction + As a COS admin + I want to combine remove and patch operations + So that I can rely on the correct ordering and immutability guarantees + + Scenario: The original input is not mutated by apply + Given a set of relation alerts from two apps + When I apply a customization that removes alert "LowThroughput" and patches alert "HighLatency" + Then the original input is unchanged + + Scenario: Operations are applied in order remove then patch + Given a rule named "GoneForever" and a rule named "Survivor" + When I apply a customization that removes "GoneForever" and patches "Survivor" for to "2m" + Then alert "GoneForever" is absent from identifier "app" + And alert "Survivor" has for equal to "2m" + + Scenario: The customization instance is reusable across different inputs + Given a set of relation alerts from two apps + When I apply the same remove customization to two different inputs + Then alert "HostDown" is absent from both results diff --git a/tests/test_rules_customization.py b/tests/test_rules_customization.py deleted file mode 100644 index 32cb3e1..0000000 --- a/tests/test_rules_customization.py +++ /dev/null @@ -1,914 +0,0 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. -"""pytest-bdd step definitions for alert rule customization behavioural tests. - -Feature file: tests/features/alert_rule_customization.feature -Schema/validation tests: tests/test_rules_customization_schema.py -""" - -import copy - -import pytest -from pytest_bdd import given, parsers, scenarios, then, when - -from cosl.rules_customization import ( - AlertRulesCustomization, -) - -scenarios("features/alert_rule_customization.feature") - - -# --------------------------------------------------------------------------- -# Shared context fixture — carries state between Given/When/Then steps -# --------------------------------------------------------------------------- - - -@pytest.fixture -def ctx(): - """Mutable context dict shared across steps within a scenario.""" - return {} - - -# --------------------------------------------------------------------------- -# Given -# --------------------------------------------------------------------------- - - -@given("a set of relation alerts from two apps") -def given_sample_alerts(ctx, sample_alerts): - ctx["alerts"] = sample_alerts - ctx["original"] = copy.deepcopy(sample_alerts) - - -@given('a rule named "GoneForever" and a rule named "Survivor"') -def given_gone_forever_and_survivor(ctx): - ctx["alerts"] = { - "app": { - "groups": [ - { - "name": "g", - "rules": [ - {"alert": "GoneForever", "expr": "x", "for": "10m"}, - {"alert": "Survivor", "expr": "y", "for": "10m"}, - ], - } - ] - } - } - ctx["original"] = copy.deepcopy(ctx["alerts"]) - - -# --------------------------------------------------------------------------- -# When — Remove -# --------------------------------------------------------------------------- - - -@when('I apply a customization that removes alert "LowThroughput"') -def when_remove_low_throughput(ctx): - config = """ -remove: - - where: - alert: LowThroughput -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when('I apply a customization that removes group "group_a"') -def when_remove_group_a(ctx): - config = """ -remove: - - where: - group: group_a -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when( - 'I apply a customization that removes alerts in group "group_a" with alert name "LowThroughput"' -) -def when_remove_group_a_low_throughput(ctx): - config = """ -remove: - - where: - group: group_a - alert: LowThroughput -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when('I apply a customization that removes alerts with label "severity" equal to "warning"') -def when_remove_by_severity_warning(ctx): - config = """ -remove: - - where: - labels: - severity: warning -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when( - 'I apply a customization that removes alerts with annotation "summary" equal to "latency is high"' -) -def when_remove_by_annotation(ctx): - config = """ -remove: - - where: - annotations: - summary: latency is high -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when('I apply a customization that removes alerts with label "juju_application" equal to "app-1"') -def when_remove_by_juju_application(ctx): - config = """ -remove: - - where: - labels: - juju_application: app-1 -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when('I apply a customization that removes alert "HighLatency" and alert "OtherAlert"') -def when_remove_two_alerts(ctx): - config = """ -remove: - - where: - alert: HighLatency - - where: - alert: OtherAlert -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when('I apply a customization that removes alert "HostDown"') -def when_remove_host_down(ctx): - config = """ -remove: - - where: - alert: HostDown -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when('I apply a customization that removes alert "OtherAlert"') -def when_remove_other_alert(ctx): - config = """ -remove: - - where: - alert: OtherAlert -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -# --------------------------------------------------------------------------- -# When — Patch -# --------------------------------------------------------------------------- - - -@when('I apply a customization that patches alert "HighLatency" setting for to "30m"') -def when_patch_for(ctx): - config = """ -patch: - - where: - alert: HighLatency - set: - for: 30m -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when( - 'I apply a customization that patches alert "HighLatency" setting alert name to "RenamedLatency"' -) -def when_patch_alert_name(ctx): - config = """ -patch: - - where: - alert: HighLatency - set: - alert: RenamedLatency -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when('I apply a customization that patches alert "HostDown" setting expr to "up == 0"') -def when_patch_expr(ctx): - config = """ -patch: - - where: - alert: HostDown - set: - expr: up == 0 -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when( - 'I apply a customization that patches alert "HighLatency" setting label "severity" to "page" and adding label "extra" as "added"' -) -def when_patch_labels(ctx): - config = """ -patch: - - where: - alert: HighLatency - set: - labels: - severity: page - extra: added -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when( - 'I apply a customization that patches alert "HighLatency" setting label "juju_application" to "other-app"' -) -def when_patch_juju_label(ctx): - config = """ -patch: - - where: - alert: HighLatency - set: - labels: - juju_application: other-app -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when( - 'I apply a customization that patches alert "HighLatency" setting annotation "summary" to "new summary" and adding annotation "description" as "new description"' -) -def when_patch_annotations(ctx): - config = """ -patch: - - where: - alert: HighLatency - set: - annotations: - summary: new summary - description: new description -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when('I apply a customization that patches all rules in group "group_a" setting expr to "hacked"') -def when_patch_group_expr(ctx): - config = """ -patch: - - where: - group: group_a - set: - expr: hacked -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when( - 'I apply a customization that patches alerts with label "severity" equal to "warning" setting label "severity" to "critical"' -) -def when_patch_by_label(ctx): - config = """ -patch: - - where: - labels: - severity: warning - set: - labels: - severity: critical -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -# --------------------------------------------------------------------------- -# When — Apply semantics -# --------------------------------------------------------------------------- - - -@when('I apply a customization that removes alert "LowThroughput" and patches alert "HighLatency"') -def when_remove_and_patch(ctx): - config = """ -remove: - - where: - alert: LowThroughput -patch: - - where: - alert: HighLatency - set: - for: 1h - labels: - severity: page -""" - AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - ctx["result"] = ctx["alerts"] # we check that the original is unchanged - - -@when( - 'I apply a customization that removes "GoneForever" and patches "Survivor" for to "2m"' -) -def when_order_of_operations(ctx): - config = """ -remove: - - where: - alert: GoneForever -patch: - - where: - alert: Survivor - set: - for: 2m -""" - ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -@when("I apply the same remove customization to two different inputs") -def when_reuse_customization(ctx): - config = """ -remove: - - where: - alert: HostDown -""" - customization = AlertRulesCustomization.from_yaml(config) - ctx["result1"] = customization.apply(ctx["alerts"]) - other = { - "other": {"groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}]} - } - ctx["result2"] = customization.apply(other) - - -# --------------------------------------------------------------------------- -# Then — Presence / absence of alerts -# --------------------------------------------------------------------------- - - -@then(parsers.parse('alert "{alert_name}" is absent from the result')) -def then_alert_absent(ctx, alert_name): - assert alert_name not in str(ctx["result"]) - - -@then(parsers.parse('alert "{alert_name}" is present in the result')) -def then_alert_present(ctx, alert_name): - assert alert_name in str(ctx["result"]) - - -@then(parsers.parse('the recording rule "{record_name}" is present in the result')) -def then_recording_rule_present(ctx, record_name): - assert record_name in str(ctx["result"]) - - -# --------------------------------------------------------------------------- -# Then — Presence / absence of groups and identifiers -# --------------------------------------------------------------------------- - - -@then(parsers.parse('group "{group_name}" is absent from identifier "{identifier}"')) -def then_group_absent(ctx, group_name, identifier): - result = ctx["result"] - if identifier not in result: - return # identifier itself gone, group certainly absent - group_names = [g["name"] for g in result[identifier].get("groups", [])] - assert group_name not in group_names - - -@then(parsers.parse('group "{group_name}" is present in identifier "{identifier}"')) -def then_group_present(ctx, group_name, identifier): - result = ctx["result"] - assert identifier in result - group_names = [g["name"] for g in result[identifier].get("groups", [])] - assert group_name in group_names - - -@then(parsers.parse('identifier "{identifier}" is absent from the result')) -def then_identifier_absent(ctx, identifier): - assert identifier not in ctx["result"] - - -@then(parsers.parse('identifier "{identifier}" is present in the result')) -def then_identifier_present(ctx, identifier): - assert identifier in ctx["result"] - - -# --------------------------------------------------------------------------- -# Then — Rule field assertions -# --------------------------------------------------------------------------- - - -@then(parsers.parse('alert "{alert_name}" has for equal to "{value}"')) -def then_alert_for(ctx, alert_name, value): - result = ctx["result"] - found = _find_alert_anywhere(result, alert_name) - assert found["for"] == value, f"expected for={value!r}, got {found.get('for')!r}" - - -@then(parsers.parse("alert \"{alert_name}\" has expr equal to '{value}'")) -def then_alert_expr_single_quoted(ctx, alert_name, value): - result = ctx["result"] - found = _find_alert_anywhere(result, alert_name) - assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" - - -@then(parsers.parse('alert "{alert_name}" has expr equal to "{value}"')) -def then_alert_expr(ctx, alert_name, value): - result = ctx["result"] - found = _find_alert_anywhere(result, alert_name) - assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" - - -@then(parsers.parse('alert "{alert_name}" has label "{label_key}" equal to "{label_value}"')) -def then_alert_label(ctx, alert_name, label_key, label_value): - result = ctx["result"] - found = _find_alert_anywhere(result, alert_name) - labels = found.get("labels", {}) - assert ( - labels.get(label_key) == label_value - ), f"expected label {label_key}={label_value!r}, got {labels.get(label_key)!r}" - - -@then(parsers.parse('alert "{alert_name}" has annotation "{ann_key}" equal to "{ann_value}"')) -def then_alert_annotation(ctx, alert_name, ann_key, ann_value): - result = ctx["result"] - found = _find_alert_anywhere(result, alert_name) - annotations = found.get("annotations", {}) - assert ( - annotations.get(ann_key) == ann_value - ), f"expected annotation {ann_key}={ann_value!r}, got {annotations.get(ann_key)!r}" - - -@then(parsers.parse('alert "{alert_name}" has no labels')) -def then_alert_no_labels(ctx, alert_name): - result = ctx["result"] - found = _find_alert_anywhere(result, alert_name) - assert not found.get("labels"), f"expected no labels, got {found.get('labels')!r}" - - -@then(parsers.parse('the recording rule "{record_name}" has expr equal to "{value}"')) -def then_recording_rule_expr(ctx, record_name, value): - result = ctx["result"] - found = _find_record_anywhere(result, record_name) - assert found["expr"] == value, f"expected expr={value!r}, got {found.get('expr')!r}" - - -@then( - parsers.parse( - 'the recording rule "{record_name}" has label "{label_key}" equal to "{label_value}"' - ) -) -def then_recording_rule_label(ctx, record_name, label_key, label_value): - result = ctx["result"] - found = _find_record_anywhere(result, record_name) - labels = found.get("labels", {}) - assert labels.get(label_key) == label_value - - -# --------------------------------------------------------------------------- -# Then — Apply semantics -# --------------------------------------------------------------------------- - - -@then("the original input is unchanged") -def then_original_unchanged(ctx): - assert ctx["alerts"] == ctx["original"] - - -@then(parsers.parse('alert "{alert_name}" is absent from identifier "{identifier}"')) -def then_alert_absent_from_identifier(ctx, alert_name, identifier): - result = ctx["result"] - if identifier not in result: - return - assert alert_name not in str(result[identifier]) - - -@then('alert "Survivor" has for equal to "2m"') -def then_survivor_for(ctx): - rules = ctx["result"]["app"]["groups"][0]["rules"] - survivor = next(r for r in rules if r.get("alert") == "Survivor") - assert survivor["for"] == "2m" - - -@then('alert "HostDown" is absent from both results') -def then_host_down_absent_both(ctx): - assert "HostDown" not in str(ctx["result1"]) - assert "HostDown" not in str(ctx["result2"]) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _find_alert_anywhere(result, alert_name): - """Search all identifiers/groups for an alerting rule by name.""" - for rule_file in result.values(): - for group in rule_file.get("groups", []): - for rule in group.get("rules", []): - if rule.get("alert") == alert_name: - return rule - raise AssertionError(f"Alert {alert_name!r} not found in result") - - -def _find_record_anywhere(result, record_name): - """Search all identifiers/groups for a recording rule by name.""" - for rule_file in result.values(): - for group in rule_file.get("groups", []): - for rule in group.get("rules", []): - if rule.get("record") == record_name: - return rule - raise AssertionError(f"Recording rule {record_name!r} not found in result") - - -# --------------------------------------------------------------------------- -# Behavioural assertions as plain pytest tests -# --------------------------------------------------------------------------- -# -# The feature-file scenarios above cover the happy paths. The tests below cover -# the detailed edge cases that are awkward to express in Gherkin, in the same -# style as the surrounding pytest suite. - - -def _sample_alerts(): - """Relation alerts dict in the same shape as MetricsConsumer.alerts.""" - return { - "app-1": { - "groups": [ - { - "name": "group_a", - "rules": [ - { - "alert": "HighLatency", - "expr": "latency > 100", - "for": "10m", - "labels": {"severity": "critical", "juju_application": "app-1"}, - "annotations": {"summary": "latency is high"}, - }, - { - "alert": "LowThroughput", - "expr": "throughput < 10", - "for": "5m", - "labels": {"severity": "warning"}, - }, - { - "record": "job:latency:mean5m", - "expr": "avg(latency)", - "labels": {"severity": "warning"}, - }, - ], - }, - { - "name": "group_b", - "rules": [ - {"alert": "HostDown", "expr": "up < 1"}, - ], - }, - ] - }, - "app-2": { - "groups": [ - { - "name": "group_c", - "rules": [ - {"alert": "OtherAlert", "expr": "x > 0"}, - ], - } - ] - }, - } - - -def _find_rule(alerts, identifier, group_name, rule_name, *, by_record=False): - """Fetch a single rule from an alerts dict for assertions.""" - key = "record" if by_record else "alert" - groups = alerts[identifier]["groups"] - group = next(g for g in groups if g["name"] == group_name) - return next(rule for rule in group["rules"] if rule.get(key) == rule_name) - - -def _apply(config): - return AlertRulesCustomization.from_yaml(config).apply(_sample_alerts()) - - -class TestRemove: - def test_remove_by_alert_name(self): - config = """ - remove: - - where: - alert: LowThroughput - """ - result = _apply(config) - - group_names = [g["name"] for g in result["app-1"]["groups"]] - assert group_names == ["group_a", "group_b"] - rule_names = [r.get("alert") for r in result["app-1"]["groups"][0]["rules"]] - assert rule_names == ["HighLatency", None] # record remains - - def test_remove_by_group_only_drops_entire_group_including_recording_rules(self): - config = """ - remove: - - where: - group: group_a - """ - result = _apply(config) - - group_names = [g["name"] for g in result["app-1"]["groups"]] - assert group_names == ["group_b"] - - def test_remove_group_with_other_selector_keeps_recording_rules(self): - config = """ - remove: - - where: - group: group_a - alert: LowThroughput - """ - result = _apply(config) - - group = next(g for g in result["app-1"]["groups"] if g["name"] == "group_a") - rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] - # Only the matching alerting rule is removed; the rest survives. - assert rule_names == ["HighLatency", "job:latency:mean5m"] - - def test_remove_by_alert_and_labels_combined(self): - config_matching = """ - remove: - - where: - alert: HighLatency - labels: - severity: critical - """ - result = _apply(config_matching) - assert "HighLatency" not in str(result) - - # Same alert but non-matching label value: nothing removed. - config_not_matching = """ - remove: - - where: - alert: HighLatency - labels: - severity: warning - """ - result = _apply(config_not_matching) - assert "HighLatency" in str(result) - - def test_remove_by_group_and_labels_combined(self): - config = """ - remove: - - where: - group: group_a - labels: - severity: warning - """ - result = _apply(config) - - group = next(g for g in result["app-1"]["groups"] if g["name"] == "group_a") - rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] - # Both the alerting rule and the recording rule carry severity=warning, - # but only the alerting rule may be removed (group selector is combined). - assert rule_names == ["HighLatency", "job:latency:mean5m"] - - def test_remove_by_annotations(self): - config = """ - remove: - - where: - annotations: - summary: latency is high - """ - result = _apply(config) - - assert "HighLatency" not in str(result) - assert "LowThroughput" in str(result) - - def test_remove_multiple_entries_are_ored(self): - config = """ - remove: - - where: - alert: HighLatency - - where: - alert: OtherAlert - """ - result = _apply(config) - - assert "HighLatency" not in str(result) - assert "OtherAlert" not in str(result) - assert "LowThroughput" in str(result) - assert "HostDown" in str(result) - - def test_remove_prunes_empty_groups_and_drops_empty_identifiers(self): - config_pruning_group = """ - remove: - - where: - alert: HostDown - """ - result = _apply(config_pruning_group) - # group_b became empty and was pruned; app-1 keeps group_a only. - assert [g["name"] for g in result["app-1"]["groups"]] == ["group_a"] - assert "app-2" in result - - config_dropping_identifier = """ - remove: - - where: - alert: OtherAlert - """ - result = _apply(config_dropping_identifier) - # app-2's only group became empty, so app-2 was dropped entirely. - assert "app-2" not in result - assert "app-1" in result - - def test_remove_preserves_recording_rules_when_group_not_sole_selector(self): - config = """ - remove: - - where: - labels: - severity: warning - """ - result = _apply(config) - - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - assert record["expr"] == "avg(latency)" - - -class TestPatch: - def test_patch_updates_for(self): - config = """ - patch: - - where: - alert: HighLatency - set: - for: 30m - """ - result = _apply(config) - - assert _find_rule(result, "app-1", "group_a", "HighLatency")["for"] == "30m" - # Untouched rule keeps its original value. - assert _find_rule(result, "app-1", "group_a", "LowThroughput")["for"] == "5m" - - def test_patch_replaces_alert_name(self): - config = """ - patch: - - where: - alert: HighLatency - set: - alert: RenamedLatency - """ - result = _apply(config) - - renamed = _find_rule(result, "app-1", "group_a", "RenamedLatency") - assert renamed["expr"] == "latency > 100" - - def test_patch_replaces_expr(self): - config = """ - patch: - - where: - alert: HostDown - set: - expr: up == 0 - """ - result = _apply(config) - - assert _find_rule(result, "app-1", "group_b", "HostDown")["expr"] == "up == 0" - - def test_patch_merges_labels(self): - config = """ - patch: - - where: - alert: HighLatency - set: - labels: - severity: page - extra: added - """ - result = _apply(config) - - labels = _find_rule(result, "app-1", "group_a", "HighLatency")["labels"] - # existing key overwritten, new key added, other keys untouched - assert labels["severity"] == "page" - assert labels["extra"] == "added" - assert labels["juju_application"] == "app-1" - - def test_patch_merges_annotations(self): - config = """ - patch: - - where: - alert: HighLatency - set: - annotations: - summary: new summary - description: new description - """ - result = _apply(config) - - annotations = _find_rule(result, "app-1", "group_a", "HighLatency")["annotations"] - assert annotations["summary"] == "new summary" - assert annotations["description"] == "new description" - - def test_patch_skips_recording_rules(self): - config = """ - patch: - - where: - group: group_a - set: - expr: hacked - """ - result = _apply(config) - - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - assert record["expr"] == "avg(latency)" - # Alerting rules in the same group were patched. - assert _find_rule(result, "app-1", "group_a", "HighLatency")["expr"] == "hacked" - - def test_patch_matches_on_labels(self): - config = """ - patch: - - where: - labels: - severity: warning - set: - labels: - severity: critical - """ - result = _apply(config) - - assert ( - _find_rule(result, "app-1", "group_a", "LowThroughput")["labels"]["severity"] - == "critical" - ) - # The recording rule also has severity=warning but must not be patched. - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - assert record["labels"]["severity"] == "warning" - # The alert in the other app is unaffected. - assert _find_rule(result, "app-2", "group_c", "OtherAlert")["expr"] == "x > 0" - - -class TestApplySemantics: - def test_input_is_not_mutated(self): - config = """ - remove: - - where: - alert: LowThroughput - patch: - - where: - alert: HighLatency - set: - for: 1h - labels: - severity: page - """ - sample = _sample_alerts() - snapshot = copy.deepcopy(sample) - AlertRulesCustomization.from_yaml(config).apply(sample) - assert sample == snapshot - - def test_order_of_operations_is_remove_then_patch(self): - config = """ - remove: - - where: - alert: GoneForever - patch: - - where: - alert: GoneForever - set: - for: 1m - - where: - alert: Survivor - set: - for: 2m - """ - relation_alerts = { - "app": { - "groups": [ - { - "name": "g", - "rules": [ - {"alert": "GoneForever", "expr": "x", "for": "10m"}, - {"alert": "Survivor", "expr": "y", "for": "10m"}, - ], - } - ] - } - } - result = AlertRulesCustomization.from_yaml(config).apply(relation_alerts) - - rules = result["app"]["groups"][0]["rules"] - # Removed despite a patch entry targeting it; patch applied to the survivor. - assert [r["alert"] for r in rules] == ["Survivor"] - assert rules[0]["for"] == "2m" - - def test_apply_is_reusable_across_inputs(self): - config = """ - remove: - - where: - alert: HostDown - """ - customization = AlertRulesCustomization.from_yaml(config) - - result_1 = customization.apply(_sample_alerts()) - assert "HostDown" not in str(result_1["app-1"]) - assert "app-2" in result_1 - - other_relation_alerts = { - "other": { - "groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}] - } - } - result_2 = customization.apply(other_relation_alerts) - assert result_2 == {} - - # And the first input's result is unchanged by the second call. - assert "app-1" in result_1 diff --git a/tests/test_rules_customization_patch.py b/tests/test_rules_customization_patch.py new file mode 100644 index 0000000..36bbf04 --- /dev/null +++ b/tests/test_rules_customization_patch.py @@ -0,0 +1,244 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""pytest-bdd step definitions for patch scenarios. + +Feature file: tests/features/patch.feature +""" + +from conftest import _apply +from conftest import find_rule as _find_rule +from pytest_bdd import scenarios, when + +from cosl.rules_customization import AlertRulesCustomization + +scenarios("features/patch.feature") + + +# --------------------------------------------------------------------------- +# When — Patch +# --------------------------------------------------------------------------- + + +@when('I apply a customization that patches alert "HighLatency" setting for to "30m"') +def when_patch_for(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + for: 30m +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when( + 'I apply a customization that patches alert "HighLatency" setting alert name to "RenamedLatency"' +) +def when_patch_alert_name(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + alert: RenamedLatency +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that patches alert "HostDown" setting expr to "up == 0"') +def when_patch_expr(ctx): + config = """ +patch: + - where: + alert: HostDown + set: + expr: up == 0 +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when( + 'I apply a customization that patches alert "HighLatency" setting label "severity" to "page" and adding label "extra" as "added"' +) +def when_patch_labels(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + labels: + severity: page + extra: added +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when( + 'I apply a customization that patches alert "HighLatency" setting label "juju_application" to "other-app"' +) +def when_patch_juju_label(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + labels: + juju_application: other-app +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when( + 'I apply a customization that patches alert "HighLatency" setting annotation "summary" to "new summary" and adding annotation "description" as "new description"' +) +def when_patch_annotations(ctx): + config = """ +patch: + - where: + alert: HighLatency + set: + annotations: + summary: new summary + description: new description +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that patches all rules in group "group_a" setting expr to "hacked"') +def when_patch_group_expr(ctx): + config = """ +patch: + - where: + group: group_a + set: + expr: hacked +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when( + 'I apply a customization that patches alerts with label "severity" equal to "warning" setting label "severity" to "critical"' +) +def when_patch_by_label(ctx): + config = """ +patch: + - where: + labels: + severity: warning + set: + labels: + severity: critical +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +# --------------------------------------------------------------------------- +# Plain pytest tests — edge cases for patch +# --------------------------------------------------------------------------- + + +class TestPatch: + def test_patch_updates_for(self): + config = """ + patch: + - where: + alert: HighLatency + set: + for: 30m + """ + result = _apply(config) + + assert _find_rule(result, "app-1", "group_a", "HighLatency")["for"] == "30m" + assert _find_rule(result, "app-1", "group_a", "LowThroughput")["for"] == "5m" + + def test_patch_replaces_alert_name(self): + config = """ + patch: + - where: + alert: HighLatency + set: + alert: RenamedLatency + """ + result = _apply(config) + + renamed = _find_rule(result, "app-1", "group_a", "RenamedLatency") + assert renamed["expr"] == "latency > 100" + + def test_patch_replaces_expr(self): + config = """ + patch: + - where: + alert: HostDown + set: + expr: up == 0 + """ + result = _apply(config) + + assert _find_rule(result, "app-1", "group_b", "HostDown")["expr"] == "up == 0" + + def test_patch_merges_labels(self): + config = """ + patch: + - where: + alert: HighLatency + set: + labels: + severity: page + extra: added + """ + result = _apply(config) + + labels = _find_rule(result, "app-1", "group_a", "HighLatency")["labels"] + assert labels["severity"] == "page" + assert labels["extra"] == "added" + assert labels["juju_application"] == "app-1" + + def test_patch_merges_annotations(self): + config = """ + patch: + - where: + alert: HighLatency + set: + annotations: + summary: new summary + description: new description + """ + result = _apply(config) + + annotations = _find_rule(result, "app-1", "group_a", "HighLatency")["annotations"] + assert annotations["summary"] == "new summary" + assert annotations["description"] == "new description" + + def test_patch_skips_recording_rules(self): + config = """ + patch: + - where: + group: group_a + set: + expr: hacked + """ + result = _apply(config) + + record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) + assert record["expr"] == "avg(latency)" + assert _find_rule(result, "app-1", "group_a", "HighLatency")["expr"] == "hacked" + + def test_patch_matches_on_labels(self): + config = """ + patch: + - where: + labels: + severity: warning + set: + labels: + severity: critical + """ + result = _apply(config) + + assert ( + _find_rule(result, "app-1", "group_a", "LowThroughput")["labels"]["severity"] + == "critical" + ) + record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) + assert record["labels"]["severity"] == "warning" + assert _find_rule(result, "app-2", "group_c", "OtherAlert")["expr"] == "x > 0" diff --git a/tests/test_rules_customization_remove.py b/tests/test_rules_customization_remove.py new file mode 100644 index 0000000..1e626d0 --- /dev/null +++ b/tests/test_rules_customization_remove.py @@ -0,0 +1,315 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""pytest-bdd step definitions for remove scenarios. + +Feature file: tests/features/remove.feature +""" + +from pytest_bdd import scenarios, when + +from cosl.rules_customization import AlertRulesCustomization + +scenarios("features/remove.feature") + + +# --------------------------------------------------------------------------- +# When — Remove +# --------------------------------------------------------------------------- + + +@when('I apply a customization that removes alert "LowThroughput"') +def when_remove_low_throughput(ctx): + config = """ +remove: + - where: + alert: LowThroughput +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes group "group_a"') +def when_remove_group_a(ctx): + config = """ +remove: + - where: + group: group_a +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when( + 'I apply a customization that removes alerts in group "group_a" with alert name "LowThroughput"' +) +def when_remove_group_a_low_throughput(ctx): + config = """ +remove: + - where: + group: group_a + alert: LowThroughput +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alerts with label "severity" equal to "warning"') +def when_remove_by_severity_warning(ctx): + config = """ +remove: + - where: + labels: + severity: warning +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when( + 'I apply a customization that removes alerts with annotation "summary" equal to "latency is high"' +) +def when_remove_by_annotation(ctx): + config = """ +remove: + - where: + annotations: + summary: latency is high +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alerts with label "juju_application" equal to "app-1"') +def when_remove_by_juju_application(ctx): + config = """ +remove: + - where: + labels: + juju_application: app-1 +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alert "HighLatency" and alert "OtherAlert"') +def when_remove_two_alerts(ctx): + config = """ +remove: + - where: + alert: HighLatency + - where: + alert: OtherAlert +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alert "HostDown"') +def when_remove_host_down(ctx): + config = """ +remove: + - where: + alert: HostDown +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when('I apply a customization that removes alert "OtherAlert"') +def when_remove_other_alert(ctx): + config = """ +remove: + - where: + alert: OtherAlert +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +# --------------------------------------------------------------------------- +# Plain pytest tests — edge cases for remove +# --------------------------------------------------------------------------- + + +def find_rule(alerts, identifier, group_name, rule_name, *, by_record=False): + """Fetch a single rule from an alerts dict for assertions.""" + key = "record" if by_record else "alert" + groups = alerts[identifier]["groups"] + group = next(g for g in groups if g["name"] == group_name) + return next(rule for rule in group["rules"] if rule.get(key) == rule_name) + + +def _sample_alerts(): + """Relation alerts dict in the same shape as MetricsConsumer.alerts.""" + return { + "app-1": { + "groups": [ + { + "name": "group_a", + "rules": [ + { + "alert": "HighLatency", + "expr": "latency > 100", + "for": "10m", + "labels": {"severity": "critical", "juju_application": "app-1"}, + "annotations": {"summary": "latency is high"}, + }, + { + "alert": "LowThroughput", + "expr": "throughput < 10", + "for": "5m", + "labels": {"severity": "warning"}, + }, + { + "record": "job:latency:mean5m", + "expr": "avg(latency)", + "labels": {"severity": "warning"}, + }, + ], + }, + { + "name": "group_b", + "rules": [ + {"alert": "HostDown", "expr": "up < 1"}, + ], + }, + ] + }, + "app-2": { + "groups": [ + { + "name": "group_c", + "rules": [ + {"alert": "OtherAlert", "expr": "x > 0"}, + ], + } + ] + }, + } + + +def _apply(config): + return AlertRulesCustomization.from_yaml(config).apply(_sample_alerts()) + + +class TestRemove: + def test_remove_by_alert_name(self): + config = """ + remove: + - where: + alert: LowThroughput + """ + result = _apply(config) + + group_names = [g["name"] for g in result["app-1"]["groups"]] + assert group_names == ["group_a", "group_b"] + rule_names = [r.get("alert") for r in result["app-1"]["groups"][0]["rules"]] + assert rule_names == ["HighLatency", None] + + def test_remove_by_group_only_drops_entire_group_including_recording_rules(self): + config = """ + remove: + - where: + group: group_a + """ + result = _apply(config) + + group_names = [g["name"] for g in result["app-1"]["groups"]] + assert group_names == ["group_b"] + + def test_remove_group_with_other_selector_keeps_recording_rules(self): + config = """ + remove: + - where: + group: group_a + alert: LowThroughput + """ + result = _apply(config) + + group = next(g for g in result["app-1"]["groups"] if g["name"] == "group_a") + rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] + assert rule_names == ["HighLatency", "job:latency:mean5m"] + + def test_remove_by_alert_and_labels_combined(self): + config_matching = """ + remove: + - where: + alert: HighLatency + labels: + severity: critical + """ + result = _apply(config_matching) + assert "HighLatency" not in str(result) + + config_not_matching = """ + remove: + - where: + alert: HighLatency + labels: + severity: warning + """ + result = _apply(config_not_matching) + assert "HighLatency" in str(result) + + def test_remove_by_group_and_labels_combined(self): + config = """ + remove: + - where: + group: group_a + labels: + severity: warning + """ + result = _apply(config) + + group = next(g for g in result["app-1"]["groups"] if g["name"] == "group_a") + rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] + assert rule_names == ["HighLatency", "job:latency:mean5m"] + + def test_remove_by_annotations(self): + config = """ + remove: + - where: + annotations: + summary: latency is high + """ + result = _apply(config) + + assert "HighLatency" not in str(result) + assert "LowThroughput" in str(result) + + def test_remove_multiple_entries_are_ored(self): + config = """ + remove: + - where: + alert: HighLatency + - where: + alert: OtherAlert + """ + result = _apply(config) + + assert "HighLatency" not in str(result) + assert "OtherAlert" not in str(result) + assert "LowThroughput" in str(result) + assert "HostDown" in str(result) + + def test_remove_prunes_empty_groups_and_drops_empty_identifiers(self): + config_pruning_group = """ + remove: + - where: + alert: HostDown + """ + result = _apply(config_pruning_group) + assert [g["name"] for g in result["app-1"]["groups"]] == ["group_a"] + assert "app-2" in result + + config_dropping_identifier = """ + remove: + - where: + alert: OtherAlert + """ + result = _apply(config_dropping_identifier) + assert "app-2" not in result + assert "app-1" in result + + def test_remove_preserves_recording_rules_when_group_not_sole_selector(self): + config = """ + remove: + - where: + labels: + severity: warning + """ + result = _apply(config) + + record = find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) + assert record["expr"] == "avg(latency)" diff --git a/tests/test_rules_customization_remove_patch.py b/tests/test_rules_customization_remove_patch.py new file mode 100644 index 0000000..9189def --- /dev/null +++ b/tests/test_rules_customization_remove_patch.py @@ -0,0 +1,149 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""pytest-bdd step definitions for remove-patch interaction scenarios. + +Feature file: tests/features/remove_patch.feature +""" + +import copy + +from conftest import _sample_alerts +from pytest_bdd import scenarios, when + +from cosl.rules_customization import AlertRulesCustomization + +scenarios("features/remove_patch.feature") + + +# --------------------------------------------------------------------------- +# When — Apply semantics (remove + patch interaction) +# --------------------------------------------------------------------------- + + +@when('I apply a customization that removes alert "LowThroughput" and patches alert "HighLatency"') +def when_remove_and_patch(ctx): + config = """ +remove: + - where: + alert: LowThroughput +patch: + - where: + alert: HighLatency + set: + for: 1h + labels: + severity: page +""" + AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + ctx["result"] = ctx["alerts"] + + +@when('I apply a customization that removes "GoneForever" and patches "Survivor" for to "2m"') +def when_order_of_operations(ctx): + config = """ +remove: + - where: + alert: GoneForever +patch: + - where: + alert: Survivor + set: + for: 2m +""" + ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) + + +@when("I apply the same remove customization to two different inputs") +def when_reuse_customization(ctx): + config = """ +remove: + - where: + alert: HostDown +""" + customization = AlertRulesCustomization.from_yaml(config) + ctx["result1"] = customization.apply(ctx["alerts"]) + other = { + "other": {"groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}]} + } + ctx["result2"] = customization.apply(other) + + +# --------------------------------------------------------------------------- +# Plain pytest tests — edge cases for remove + patch interaction +# --------------------------------------------------------------------------- + + +class TestApplySemantics: + def test_input_is_not_mutated(self): + config = """ + remove: + - where: + alert: LowThroughput + patch: + - where: + alert: HighLatency + set: + for: 1h + labels: + severity: page + """ + sample = _sample_alerts() + snapshot = copy.deepcopy(sample) + AlertRulesCustomization.from_yaml(config).apply(sample) + assert sample == snapshot + + def test_order_of_operations_is_remove_then_patch(self): + config = """ + remove: + - where: + alert: GoneForever + patch: + - where: + alert: GoneForever + set: + for: 1m + - where: + alert: Survivor + set: + for: 2m + """ + relation_alerts = { + "app": { + "groups": [ + { + "name": "g", + "rules": [ + {"alert": "GoneForever", "expr": "x", "for": "10m"}, + {"alert": "Survivor", "expr": "y", "for": "10m"}, + ], + } + ] + } + } + result = AlertRulesCustomization.from_yaml(config).apply(relation_alerts) + + rules = result["app"]["groups"][0]["rules"] + assert [r["alert"] for r in rules] == ["Survivor"] + assert rules[0]["for"] == "2m" + + def test_apply_is_reusable_across_inputs(self): + config = """ + remove: + - where: + alert: HostDown + """ + customization = AlertRulesCustomization.from_yaml(config) + + result_1 = customization.apply(_sample_alerts()) + assert "HostDown" not in str(result_1["app-1"]) + assert "app-2" in result_1 + + other_relation_alerts = { + "other": { + "groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}] + } + } + result_2 = customization.apply(other_relation_alerts) + assert result_2 == {} + + assert "app-1" in result_1 From f90421d47afe3c5d4d763b42fb01bd73b189678a Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 1 Sep 2026 11:44:48 -0400 Subject: [PATCH 4/5] feat: improvements --- pyproject.toml | 4 + tests/conftest.py | 123 +++--------------- tests/features/patch.feature | 2 +- tests/features/remove.feature | 2 +- tests/features/remove_patch.feature | 4 +- tests/sample_alerts.yaml | 31 +++++ tests/test_rules_customization_patch.py | 109 ---------------- tests/test_rules_customization_remove.py | 69 +--------- .../test_rules_customization_remove_patch.py | 6 +- tests/test_rules_customization_schema.py | 66 ++++++---- uv.lock | 11 ++ 11 files changed, 122 insertions(+), 305 deletions(-) create mode 100644 tests/sample_alerts.yaml diff --git a/pyproject.toml b/pyproject.toml index 9018548..b453ae9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,10 @@ ignore-words-list = "assertIn" allow-direct-references = true [dependency-groups] +dev = [ + "pytest>=8.3.5", + "pytest-bdd>=7.3.0", +] test = [ "pytest>=8.3.5", "pytest-bdd>=7.3.0", diff --git a/tests/conftest.py b/tests/conftest.py index 4b376ca..6253a00 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,65 +3,23 @@ """Shared pytest fixtures, step definitions, and helpers for alert rule customization tests.""" import copy +from pathlib import Path import pytest +import yaml from pytest_bdd import given, parsers, then from cosl.rules_customization import AlertRulesCustomization -# --------------------------------------------------------------------------- -# Data fixtures -# --------------------------------------------------------------------------- +_HERE = Path(__file__).parent +_SAMPLE_ALERTS_PATH = _HERE / "sample_alerts.yaml" @pytest.fixture def sample_alerts(): - """Relation alerts dict in the same shape as MetricsConsumer.alerts.""" - return { - "app-1": { - "groups": [ - { - "name": "group_a", - "rules": [ - { - "alert": "HighLatency", - "expr": "latency > 100", - "for": "10m", - "labels": {"severity": "critical", "juju_application": "app-1"}, - "annotations": {"summary": "latency is high"}, - }, - { - "alert": "LowThroughput", - "expr": "throughput < 10", - "for": "5m", - "labels": {"severity": "warning"}, - }, - { - "record": "job:latency:mean5m", - "expr": "avg(latency)", - "labels": {"severity": "warning"}, - }, - ], - }, - { - "name": "group_b", - "rules": [ - {"alert": "HostDown", "expr": "up < 1"}, - ], - }, - ] - }, - "app-2": { - "groups": [ - { - "name": "group_c", - "rules": [ - {"alert": "OtherAlert", "expr": "x > 0"}, - ], - } - ] - }, - } + """Relation alerts dict loaded from sample_alerts.yaml.""" + with open(_SAMPLE_ALERTS_PATH) as f: + return yaml.safe_load(f) @pytest.fixture @@ -75,10 +33,12 @@ def ctx(): # --------------------------------------------------------------------------- -@given("a set of relation alerts from two apps") -def given_sample_alerts(ctx, sample_alerts): - ctx["alerts"] = sample_alerts - ctx["original"] = copy.deepcopy(sample_alerts) +@given(parsers.parse('the sample alerts from "{filename}"')) +def given_sample_alerts_from_file(ctx, filename): + path = _HERE / filename + with open(path) as f: + ctx["alerts"] = yaml.safe_load(f) + ctx["original"] = copy.deepcopy(ctx["alerts"]) @given('a rule named "GoneForever" and a rule named "Survivor"') @@ -279,55 +239,6 @@ def _find_record_anywhere(result, record_name): raise AssertionError(f"Recording rule {record_name!r} not found in result") -def _sample_alerts(): - """Relation alerts dict in the same shape as MetricsConsumer.alerts.""" - return { - "app-1": { - "groups": [ - { - "name": "group_a", - "rules": [ - { - "alert": "HighLatency", - "expr": "latency > 100", - "for": "10m", - "labels": {"severity": "critical", "juju_application": "app-1"}, - "annotations": {"summary": "latency is high"}, - }, - { - "alert": "LowThroughput", - "expr": "throughput < 10", - "for": "5m", - "labels": {"severity": "warning"}, - }, - { - "record": "job:latency:mean5m", - "expr": "avg(latency)", - "labels": {"severity": "warning"}, - }, - ], - }, - { - "name": "group_b", - "rules": [ - {"alert": "HostDown", "expr": "up < 1"}, - ], - }, - ] - }, - "app-2": { - "groups": [ - { - "name": "group_c", - "rules": [ - {"alert": "OtherAlert", "expr": "x > 0"}, - ], - } - ] - }, - } - - def find_rule(alerts, identifier, group_name, rule_name, *, by_record=False): """Return a single rule from an alerts dict, raising if not found.""" key = "record" if by_record else "alert" @@ -336,5 +247,11 @@ def find_rule(alerts, identifier, group_name, rule_name, *, by_record=False): return next(rule for rule in group["rules"] if rule.get(key) == rule_name) +def _load_sample_alerts(): + """Load the canonical sample alerts from sample_alerts.yaml.""" + with open(_SAMPLE_ALERTS_PATH) as f: + return yaml.safe_load(f) + + def _apply(config): - return AlertRulesCustomization.from_yaml(config).apply(_sample_alerts()) + return AlertRulesCustomization.from_yaml(config).apply(_load_sample_alerts()) diff --git a/tests/features/patch.feature b/tests/features/patch.feature index a13e5c2..fb41042 100644 --- a/tests/features/patch.feature +++ b/tests/features/patch.feature @@ -4,7 +4,7 @@ Feature: Alert rule patch customization So that I can tweak thresholds, labels, and annotations Background: - Given a set of relation alerts from two apps + Given the sample alerts from "sample_alerts.yaml" Scenario: Patch updates the for duration of a matching alert When I apply a customization that patches alert "HighLatency" setting for to "30m" diff --git a/tests/features/remove.feature b/tests/features/remove.feature index 4ad5e6f..2dcfb13 100644 --- a/tests/features/remove.feature +++ b/tests/features/remove.feature @@ -4,7 +4,7 @@ Feature: Alert rule remove customization So that I can drop irrelevant alerts Background: - Given a set of relation alerts from two apps + Given the sample alerts from "sample_alerts.yaml" Scenario: Remove an alert by name When I apply a customization that removes alert "LowThroughput" diff --git a/tests/features/remove_patch.feature b/tests/features/remove_patch.feature index 79a30c6..6a861f7 100644 --- a/tests/features/remove_patch.feature +++ b/tests/features/remove_patch.feature @@ -4,7 +4,7 @@ Feature: Alert rule remove and patch interaction So that I can rely on the correct ordering and immutability guarantees Scenario: The original input is not mutated by apply - Given a set of relation alerts from two apps + Given the sample alerts from "sample_alerts.yaml" When I apply a customization that removes alert "LowThroughput" and patches alert "HighLatency" Then the original input is unchanged @@ -15,6 +15,6 @@ Feature: Alert rule remove and patch interaction And alert "Survivor" has for equal to "2m" Scenario: The customization instance is reusable across different inputs - Given a set of relation alerts from two apps + Given the sample alerts from "sample_alerts.yaml" When I apply the same remove customization to two different inputs Then alert "HostDown" is absent from both results diff --git a/tests/sample_alerts.yaml b/tests/sample_alerts.yaml new file mode 100644 index 0000000..c1efb79 --- /dev/null +++ b/tests/sample_alerts.yaml @@ -0,0 +1,31 @@ +app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + labels: + severity: critical + juju_application: app-1 + annotations: + summary: latency is high + - alert: LowThroughput + expr: throughput < 10 + for: 5m + labels: + severity: warning + - record: job:latency:mean5m + expr: avg(latency) + labels: + severity: warning + - name: group_b + rules: + - alert: HostDown + expr: up < 1 +app-2: + groups: + - name: group_c + rules: + - alert: OtherAlert + expr: x > 0 diff --git a/tests/test_rules_customization_patch.py b/tests/test_rules_customization_patch.py index 36bbf04..aa78050 100644 --- a/tests/test_rules_customization_patch.py +++ b/tests/test_rules_customization_patch.py @@ -5,8 +5,6 @@ Feature file: tests/features/patch.feature """ -from conftest import _apply -from conftest import find_rule as _find_rule from pytest_bdd import scenarios, when from cosl.rules_customization import AlertRulesCustomization @@ -135,110 +133,3 @@ def when_patch_by_label(ctx): # --------------------------------------------------------------------------- # Plain pytest tests — edge cases for patch # --------------------------------------------------------------------------- - - -class TestPatch: - def test_patch_updates_for(self): - config = """ - patch: - - where: - alert: HighLatency - set: - for: 30m - """ - result = _apply(config) - - assert _find_rule(result, "app-1", "group_a", "HighLatency")["for"] == "30m" - assert _find_rule(result, "app-1", "group_a", "LowThroughput")["for"] == "5m" - - def test_patch_replaces_alert_name(self): - config = """ - patch: - - where: - alert: HighLatency - set: - alert: RenamedLatency - """ - result = _apply(config) - - renamed = _find_rule(result, "app-1", "group_a", "RenamedLatency") - assert renamed["expr"] == "latency > 100" - - def test_patch_replaces_expr(self): - config = """ - patch: - - where: - alert: HostDown - set: - expr: up == 0 - """ - result = _apply(config) - - assert _find_rule(result, "app-1", "group_b", "HostDown")["expr"] == "up == 0" - - def test_patch_merges_labels(self): - config = """ - patch: - - where: - alert: HighLatency - set: - labels: - severity: page - extra: added - """ - result = _apply(config) - - labels = _find_rule(result, "app-1", "group_a", "HighLatency")["labels"] - assert labels["severity"] == "page" - assert labels["extra"] == "added" - assert labels["juju_application"] == "app-1" - - def test_patch_merges_annotations(self): - config = """ - patch: - - where: - alert: HighLatency - set: - annotations: - summary: new summary - description: new description - """ - result = _apply(config) - - annotations = _find_rule(result, "app-1", "group_a", "HighLatency")["annotations"] - assert annotations["summary"] == "new summary" - assert annotations["description"] == "new description" - - def test_patch_skips_recording_rules(self): - config = """ - patch: - - where: - group: group_a - set: - expr: hacked - """ - result = _apply(config) - - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - assert record["expr"] == "avg(latency)" - assert _find_rule(result, "app-1", "group_a", "HighLatency")["expr"] == "hacked" - - def test_patch_matches_on_labels(self): - config = """ - patch: - - where: - labels: - severity: warning - set: - labels: - severity: critical - """ - result = _apply(config) - - assert ( - _find_rule(result, "app-1", "group_a", "LowThroughput")["labels"]["severity"] - == "critical" - ) - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - assert record["labels"]["severity"] == "warning" - assert _find_rule(result, "app-2", "group_c", "OtherAlert")["expr"] == "x > 0" diff --git a/tests/test_rules_customization_remove.py b/tests/test_rules_customization_remove.py index 1e626d0..15261ac 100644 --- a/tests/test_rules_customization_remove.py +++ b/tests/test_rules_customization_remove.py @@ -5,6 +5,8 @@ Feature file: tests/features/remove.feature """ +from conftest import _load_sample_alerts +from conftest import find_rule as _find_rule from pytest_bdd import scenarios, when from cosl.rules_customization import AlertRulesCustomization @@ -12,6 +14,10 @@ scenarios("features/remove.feature") +def _apply(config): + return AlertRulesCustomization.from_yaml(config).apply(_load_sample_alerts()) + + # --------------------------------------------------------------------------- # When — Remove # --------------------------------------------------------------------------- @@ -122,67 +128,6 @@ def when_remove_other_alert(ctx): # --------------------------------------------------------------------------- -def find_rule(alerts, identifier, group_name, rule_name, *, by_record=False): - """Fetch a single rule from an alerts dict for assertions.""" - key = "record" if by_record else "alert" - groups = alerts[identifier]["groups"] - group = next(g for g in groups if g["name"] == group_name) - return next(rule for rule in group["rules"] if rule.get(key) == rule_name) - - -def _sample_alerts(): - """Relation alerts dict in the same shape as MetricsConsumer.alerts.""" - return { - "app-1": { - "groups": [ - { - "name": "group_a", - "rules": [ - { - "alert": "HighLatency", - "expr": "latency > 100", - "for": "10m", - "labels": {"severity": "critical", "juju_application": "app-1"}, - "annotations": {"summary": "latency is high"}, - }, - { - "alert": "LowThroughput", - "expr": "throughput < 10", - "for": "5m", - "labels": {"severity": "warning"}, - }, - { - "record": "job:latency:mean5m", - "expr": "avg(latency)", - "labels": {"severity": "warning"}, - }, - ], - }, - { - "name": "group_b", - "rules": [ - {"alert": "HostDown", "expr": "up < 1"}, - ], - }, - ] - }, - "app-2": { - "groups": [ - { - "name": "group_c", - "rules": [ - {"alert": "OtherAlert", "expr": "x > 0"}, - ], - } - ] - }, - } - - -def _apply(config): - return AlertRulesCustomization.from_yaml(config).apply(_sample_alerts()) - - class TestRemove: def test_remove_by_alert_name(self): config = """ @@ -311,5 +256,5 @@ def test_remove_preserves_recording_rules_when_group_not_sole_selector(self): """ result = _apply(config) - record = find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) + record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) assert record["expr"] == "avg(latency)" diff --git a/tests/test_rules_customization_remove_patch.py b/tests/test_rules_customization_remove_patch.py index 9189def..dfa01d2 100644 --- a/tests/test_rules_customization_remove_patch.py +++ b/tests/test_rules_customization_remove_patch.py @@ -7,7 +7,7 @@ import copy -from conftest import _sample_alerts +from conftest import _load_sample_alerts from pytest_bdd import scenarios, when from cosl.rules_customization import AlertRulesCustomization @@ -87,7 +87,7 @@ def test_input_is_not_mutated(self): labels: severity: page """ - sample = _sample_alerts() + sample = _load_sample_alerts() snapshot = copy.deepcopy(sample) AlertRulesCustomization.from_yaml(config).apply(sample) assert sample == snapshot @@ -134,7 +134,7 @@ def test_apply_is_reusable_across_inputs(self): """ customization = AlertRulesCustomization.from_yaml(config) - result_1 = customization.apply(_sample_alerts()) + result_1 = customization.apply(_load_sample_alerts()) assert "HostDown" not in str(result_1["app-1"]) assert "app-2" in result_1 diff --git a/tests/test_rules_customization_schema.py b/tests/test_rules_customization_schema.py index ec8dcfc..5e33589 100644 --- a/tests/test_rules_customization_schema.py +++ b/tests/test_rules_customization_schema.py @@ -55,7 +55,9 @@ def test_unknown_top_level_key_raises(self): - where: alert: Foo """ - with self.assertRaisesRegex(AlertRulesCustomizationError, "Extra inputs are not permitted"): + with self.assertRaisesRegex( + AlertRulesCustomizationError, "Extra inputs are not permitted" + ): AlertRulesCustomization.from_yaml(config) def test_remove_missing_where_raises(self): @@ -63,11 +65,15 @@ def test_remove_missing_where_raises(self): AlertRulesCustomization.from_yaml("remove:\n - alert: Foo") def test_remove_empty_where_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' must have at least one of"): + with self.assertRaisesRegex( + AlertRulesCustomizationError, "'where' must have at least one of" + ): AlertRulesCustomization.from_yaml("remove:\n - where: {}") def test_remove_unknown_where_key_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "Extra inputs are not permitted"): + with self.assertRaisesRegex( + AlertRulesCustomizationError, "Extra inputs are not permitted" + ): AlertRulesCustomization.from_yaml("remove:\n - where:\n expr: up < 1") def test_patch_missing_where_raises(self): @@ -79,45 +85,57 @@ def test_patch_missing_set_raises(self): AlertRulesCustomization.from_yaml("patch:\n - where:\n alert: Foo") def test_patch_empty_where_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "'where' must have at least one of"): + with self.assertRaisesRegex( + AlertRulesCustomizationError, "'where' must have at least one of" + ): AlertRulesCustomization.from_yaml("patch:\n - where: {}\n set:\n for: 5m") def test_patch_unknown_where_key_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "Extra inputs are not permitted"): + with self.assertRaisesRegex( + AlertRulesCustomizationError, "Extra inputs are not permitted" + ): AlertRulesCustomization.from_yaml( "patch:\n - where:\n record: some:record\n set:\n expr: up" ) def test_patch_unknown_set_key_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "Extra inputs are not permitted"): + with self.assertRaisesRegex( + AlertRulesCustomizationError, "Extra inputs are not permitted" + ): AlertRulesCustomization.from_yaml( "patch:\n - where:\n alert: Foo\n set:\n duration: 5m" ) def test_operations_not_a_list_raises(self): for key in ("remove", "patch"): - with self.assertRaisesRegex(AlertRulesCustomizationError, "Input should be a valid list"): + with self.assertRaisesRegex( + AlertRulesCustomizationError, "Input should be a valid list" + ): AlertRulesCustomization.from_yaml(f"{key}: not-a-list") def test_malformed_operation_entries_raise(self): - cases = { - "where not a mapping": "remove:\n - where: nope", - "where.alert not a string": "remove:\n - where:\n alert: [1, 2]", - "where.labels not a mapping": "remove:\n - where:\n labels: severity", - "set not a mapping": "patch:\n - where:\n alert: Foo\n set: nope", - "set.expr not a string": ( - "patch:\n - where:\n alert: Foo\n set:\n expr: {a: b}" - ), - "set.labels not a mapping": ( - "patch:\n - where:\n alert: Foo\n set:\n labels: x" - ), - "remove entry not a mapping": "remove:\n - just-a-string", - "patch entry not a mapping": "patch:\n - just-a-string", - } - for case, config in cases.items(): - with self.subTest(case): + invalid_cases = [ + # where must be a mapping, not a scalar + ("remove:\n - where: nope",), + # where.alert must be a string, not a list + ("remove:\n - where:\n alert: [1, 2]",), + # where.labels must be a mapping, not a scalar + ("remove:\n - where:\n labels: severity",), + # set must be a mapping, not a scalar + ("patch:\n - where:\n alert: Foo\n set: nope",), + # set.expr must be a string, not a mapping + ("patch:\n - where:\n alert: Foo\n set:\n expr: {a: b}",), + # set.labels must be a mapping, not a scalar + ("patch:\n - where:\n alert: Foo\n set:\n labels: x",), + # each remove entry must be a mapping (dict), not a string + ("remove:\n - just-a-string",), + # each patch entry must be a mapping (dict), not a string + ("patch:\n - just-a-string",), + ] + for config in invalid_cases: + with self.subTest(config): with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml(config) + AlertRulesCustomization.from_yaml(config[0]) class TestNoOpConfigs(unittest.TestCase): diff --git a/uv.lock b/uv.lock index 26be559..f7b5e4f 100644 --- a/uv.lock +++ b/uv.lock @@ -271,6 +271,13 @@ dev = [ ] [package.dev-dependencies] +dev = [ + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-bdd", version = "7.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest-bdd", version = "8.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] test = [ { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, @@ -303,6 +310,10 @@ requires-dist = [ provides-extras = ["dev"] [package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-bdd", specifier = ">=7.3.0" }, +] test = [ { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-bdd", specifier = ">=7.3.0" }, From 8ec882d73b5a9da014003557b415126a9553e8d6 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 1 Sep 2026 12:06:53 -0400 Subject: [PATCH 5/5] fix: remove legacy tests --- tests/test_rules_customization.py | 533 ------------------ tests/test_rules_customization_patch.py | 5 - tests/test_rules_customization_remove.py | 83 --- .../test_rules_customization_remove_patch.py | 83 --- tests/test_rules_customization_schema.py | 25 +- 5 files changed, 3 insertions(+), 726 deletions(-) delete mode 100644 tests/test_rules_customization.py diff --git a/tests/test_rules_customization.py b/tests/test_rules_customization.py deleted file mode 100644 index f271f5c..0000000 --- a/tests/test_rules_customization.py +++ /dev/null @@ -1,533 +0,0 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - -import copy -import unittest - -from cosl.rules_customization import ( - AlertRulesCustomization, - AlertRulesCustomizationError, -) - - -def _sample_alerts(): - """Relation alerts dict in the same shape as MetricsConsumer.alerts.""" - return { - "app-1": { - "groups": [ - { - "name": "group_a", - "rules": [ - { - "alert": "HighLatency", - "expr": "latency > 100", - "for": "10m", - "labels": {"severity": "critical", "juju_application": "app-1"}, - "annotations": {"summary": "latency is high"}, - }, - { - "alert": "LowThroughput", - "expr": "throughput < 10", - "for": "5m", - "labels": {"severity": "warning"}, - }, - { - "record": "job:latency:mean5m", - "expr": "avg(latency)", - "labels": {"severity": "warning"}, - }, - ], - }, - { - "name": "group_b", - "rules": [ - {"alert": "HostDown", "expr": "up < 1"}, - ], - }, - ] - }, - "app-2": { - "groups": [ - { - "name": "group_c", - "rules": [ - {"alert": "OtherAlert", "expr": "x > 0"}, - ], - } - ] - }, - } - - -def _find_rule(alerts, identifier, group_name, rule_name, *, by_record=False): - """Fetch a single rule from an alerts dict for assertions.""" - key = "record" if by_record else "alert" - groups = alerts[identifier]["groups"] - group = next(g for g in groups if g["name"] == group_name) - return next(rule for rule in group["rules"] if rule.get(key) == rule_name) - - -class TestFromYamlValidation(unittest.TestCase): - def test_invalid_yaml_raises(self): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml("remove: [unclosed") - - def test_non_mapping_top_level_raises(self): - for config in ("- a\n- b", "42", '"just a string"'): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml(config) - - def test_unknown_top_level_key_raises(self): - config = """ - remove: - - where: - alert: Foo - destroy: - - where: - alert: Foo - """ - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml(config) - - def test_remove_missing_where_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "remove"): - AlertRulesCustomization.from_yaml("remove:\n - alert: Foo") - - def test_remove_empty_where_raises(self): - with self.assertRaisesRegex( - AlertRulesCustomizationError, "'where' must have at least one of" - ): - AlertRulesCustomization.from_yaml("remove:\n - where: {}") - - def test_remove_unknown_where_key_raises(self): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml("remove:\n - where:\n expr: up < 1") - - def test_patch_missing_where_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "patch"): - AlertRulesCustomization.from_yaml("patch:\n - set:\n for: 5m") - - def test_patch_missing_set_raises(self): - with self.assertRaisesRegex(AlertRulesCustomizationError, "patch"): - AlertRulesCustomization.from_yaml("patch:\n - where:\n alert: Foo") - - def test_patch_empty_where_raises(self): - with self.assertRaisesRegex( - AlertRulesCustomizationError, "'where' must have at least one of" - ): - AlertRulesCustomization.from_yaml("patch:\n - where: {}\n set:\n for: 5m") - - def test_patch_unknown_where_key_raises(self): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml( - "patch:\n - where:\n record: some:record\n set:\n expr: up" - ) - - def test_patch_unknown_set_key_raises(self): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml( - "patch:\n - where:\n alert: Foo\n set:\n duration: 5m" - ) - - def test_operations_not_a_list_raises(self): - for key in ("remove", "patch"): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml(f"{key}: not-a-list") - - def test_malformed_operation_entries_raise(self): - cases = { - "where not a mapping": "remove:\n - where: nope", - "where.alert not a string": "remove:\n - where:\n alert: [1, 2]", - "where.labels not a mapping": "remove:\n - where:\n labels: severity", - "set not a mapping": "patch:\n - where:\n alert: Foo\n set: nope", - "set.expr not a string": ( - "patch:\n - where:\n alert: Foo\n set:\n expr: {a: b}" - ), - "set.labels not a mapping": ( - "patch:\n - where:\n alert: Foo\n set:\n labels: x" - ), - "remove entry not a mapping": "remove:\n - just-a-string", - "patch entry not a mapping": "patch:\n - just-a-string", - } - for case, config in cases.items(): - with self.subTest(case): - with self.assertRaises(AlertRulesCustomizationError): - AlertRulesCustomization.from_yaml(config) - - -class TestNoOpConfigs(unittest.TestCase): - def _assert_noop(self, config_string): - sample = _sample_alerts() - result = AlertRulesCustomization.from_yaml(config_string).apply(sample) - self.assertEqual(result, sample) - - def test_empty_config_is_noop(self): - self._assert_noop("") - - def test_whitespace_config_is_noop(self): - self._assert_noop(" \n\t ") - - def test_none_parsing_config_is_noop(self): - # yaml.safe_load of these strings returns None or empty structures. - self._assert_noop("~") - self._assert_noop("# just a comment") - self._assert_noop("{}") - - def test_zero_match_remove_is_noop(self): - config = """ - remove: - - where: - alert: NoSuchAlert - - where: - labels: - nope: nothing - """ - self._assert_noop(config) - - def test_zero_match_patch_is_noop(self): - config = """ - patch: - - where: - alert: NoSuchAlert - set: - for: 1m - """ - self._assert_noop(config) - - -class TestRemove(unittest.TestCase): - def _apply(self, config): - return AlertRulesCustomization.from_yaml(config).apply(_sample_alerts()) - - def test_remove_by_alert_name(self): - config = """ - remove: - - where: - alert: LowThroughput - """ - result = self._apply(config) - - group_names = [g["name"] for g in result["app-1"]["groups"]] - self.assertEqual(group_names, ["group_a", "group_b"]) - rule_names = [r.get("alert") for r in result["app-1"]["groups"][0]["rules"]] - self.assertEqual(rule_names, ["HighLatency", None]) # record remains - - def test_remove_by_group_only_drops_entire_group_including_recording_rules(self): - config = """ - remove: - - where: - group: group_a - """ - result = self._apply(config) - - group_names = [g["name"] for g in result["app-1"]["groups"]] - self.assertEqual(group_names, ["group_b"]) - - def test_remove_group_with_other_selector_keeps_recording_rules(self): - config = """ - remove: - - where: - group: group_a - alert: LowThroughput - """ - result = self._apply(config) - - group = next(g for g in result["app-1"]["groups"] if g["name"] == "group_a") - rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] - # Only the matching alerting rule is removed; the rest survives. - self.assertEqual(rule_names, ["HighLatency", "job:latency:mean5m"]) - - def test_remove_by_alert_and_labels_combined(self): - config_matching = """ - remove: - - where: - alert: HighLatency - labels: - severity: critical - """ - result = self._apply(config_matching) - self.assertNotIn("HighLatency", str(result)) - - # Same alert but non-matching label value: nothing removed. - config_not_matching = """ - remove: - - where: - alert: HighLatency - labels: - severity: warning - """ - result = self._apply(config_not_matching) - self.assertIn("HighLatency", str(result)) - - def test_remove_by_group_and_labels_combined(self): - config = """ - remove: - - where: - group: group_a - labels: - severity: warning - """ - result = self._apply(config) - - group = next(g for g in result["app-1"]["groups"] if g["name"] == "group_a") - rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] - # Both the alerting rule and the recording rule carry severity=warning, - # but only the alerting rule may be removed (group selector is combined). - self.assertEqual(rule_names, ["HighLatency", "job:latency:mean5m"]) - - def test_remove_by_annotations(self): - config = """ - remove: - - where: - annotations: - summary: latency is high - """ - result = self._apply(config) - - self.assertNotIn("HighLatency", str(result)) - self.assertIn("LowThroughput", str(result)) - - def test_remove_multiple_entries_are_ored(self): - config = """ - remove: - - where: - alert: HighLatency - - where: - alert: OtherAlert - """ - result = self._apply(config) - - self.assertNotIn("HighLatency", str(result)) - self.assertNotIn("OtherAlert", str(result)) - self.assertIn("LowThroughput", str(result)) - self.assertIn("HostDown", str(result)) - - def test_remove_prunes_empty_groups_and_drops_empty_identifiers(self): - config_pruning_group = """ - remove: - - where: - alert: HostDown - """ - result = self._apply(config_pruning_group) - # group_b became empty and was pruned; app-1 keeps group_a only. - self.assertEqual([g["name"] for g in result["app-1"]["groups"]], ["group_a"]) - self.assertIn("app-2", result) - - config_dropping_identifier = """ - remove: - - where: - alert: OtherAlert - """ - result = self._apply(config_dropping_identifier) - # app-2's only group became empty, so app-2 was dropped entirely. - self.assertNotIn("app-2", result) - self.assertIn("app-1", result) - - def test_remove_preserves_recording_rules_when_group_not_sole_selector(self): - config = """ - remove: - - where: - labels: - severity: warning - """ - result = self._apply(config) - - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - self.assertEqual(record["expr"], "avg(latency)") - - -class TestPatch(unittest.TestCase): - def _apply(self, config): - return AlertRulesCustomization.from_yaml(config).apply(_sample_alerts()) - - def test_patch_updates_for(self): - config = """ - patch: - - where: - alert: HighLatency - set: - for: 30m - """ - result = self._apply(config) - - self.assertEqual(_find_rule(result, "app-1", "group_a", "HighLatency")["for"], "30m") - # Untouched rule keeps its original value. - self.assertEqual(_find_rule(result, "app-1", "group_a", "LowThroughput")["for"], "5m") - - def test_patch_replaces_alert_name(self): - config = """ - patch: - - where: - alert: HighLatency - set: - alert: RenamedLatency - """ - result = self._apply(config) - - renamed = _find_rule(result, "app-1", "group_a", "RenamedLatency") - self.assertEqual(renamed["expr"], "latency > 100") - - def test_patch_replaces_expr(self): - config = """ - patch: - - where: - alert: HostDown - set: - expr: up == 0 - """ - result = self._apply(config) - - self.assertEqual(_find_rule(result, "app-1", "group_b", "HostDown")["expr"], "up == 0") - - def test_patch_merges_labels(self): - config = """ - patch: - - where: - alert: HighLatency - set: - labels: - severity: page - extra: added - """ - result = self._apply(config) - - labels = _find_rule(result, "app-1", "group_a", "HighLatency")["labels"] - # existing key overwritten, new key added, other keys untouched - self.assertEqual(labels["severity"], "page") - self.assertEqual(labels["extra"], "added") - self.assertEqual(labels["juju_application"], "app-1") - - def test_patch_merges_annotations(self): - config = """ - patch: - - where: - alert: HighLatency - set: - annotations: - summary: new summary - description: new description - """ - result = self._apply(config) - - annotations = _find_rule(result, "app-1", "group_a", "HighLatency")["annotations"] - self.assertEqual(annotations["summary"], "new summary") - self.assertEqual(annotations["description"], "new description") - - def test_patch_skips_recording_rules(self): - config = """ - patch: - - where: - group: group_a - set: - expr: hacked - """ - result = self._apply(config) - - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - self.assertEqual(record["expr"], "avg(latency)") - # Alerting rules in the same group were patched. - self.assertEqual(_find_rule(result, "app-1", "group_a", "HighLatency")["expr"], "hacked") - - def test_patch_matches_on_labels(self): - config = """ - patch: - - where: - labels: - severity: warning - set: - labels: - severity: critical - """ - result = self._apply(config) - - self.assertEqual( - _find_rule(result, "app-1", "group_a", "LowThroughput")["labels"]["severity"], - "critical", - ) - # The recording rule also has severity=warning but must not be patched. - record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) - self.assertEqual(record["labels"]["severity"], "warning") - # The alert in the other app is unaffected. - self.assertEqual(_find_rule(result, "app-2", "group_c", "OtherAlert")["expr"], "x > 0") - - -class TestApplySemantics(unittest.TestCase): - def test_input_is_not_mutated(self): - config = """ - remove: - - where: - alert: LowThroughput - patch: - - where: - alert: HighLatency - set: - for: 1h - labels: - severity: page - """ - sample = _sample_alerts() - snapshot = copy.deepcopy(sample) - AlertRulesCustomization.from_yaml(config).apply(sample) - self.assertEqual(sample, snapshot) - - def test_order_of_operations_is_remove_then_patch(self): - config = """ - remove: - - where: - alert: GoneForever - patch: - - where: - alert: GoneForever - set: - for: 1m - - where: - alert: Survivor - set: - for: 2m - """ - relation_alerts = { - "app": { - "groups": [ - { - "name": "g", - "rules": [ - {"alert": "GoneForever", "expr": "x", "for": "10m"}, - {"alert": "Survivor", "expr": "y", "for": "10m"}, - ], - } - ] - } - } - result = AlertRulesCustomization.from_yaml(config).apply(relation_alerts) - - rules = result["app"]["groups"][0]["rules"] - # Removed despite a patch entry targeting it; patch applied to the survivor. - self.assertEqual([r["alert"] for r in rules], ["Survivor"]) - self.assertEqual(rules[0]["for"], "2m") - - def test_apply_is_reusable_across_inputs(self): - config = """ - remove: - - where: - alert: HostDown - """ - customization = AlertRulesCustomization.from_yaml(config) - - result_1 = customization.apply(_sample_alerts()) - self.assertNotIn("HostDown", str(result_1["app-1"])) - self.assertIn("app-2", result_1) - - other_relation_alerts = { - "other": { - "groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}] - } - } - result_2 = customization.apply(other_relation_alerts) - self.assertEqual(result_2, {}) - - # And the first input's result is unchanged by the second call. - self.assertIn("app-1", result_1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_rules_customization_patch.py b/tests/test_rules_customization_patch.py index aa78050..dcd2e4c 100644 --- a/tests/test_rules_customization_patch.py +++ b/tests/test_rules_customization_patch.py @@ -128,8 +128,3 @@ def when_patch_by_label(ctx): severity: critical """ ctx["result"] = AlertRulesCustomization.from_yaml(config).apply(ctx["alerts"]) - - -# --------------------------------------------------------------------------- -# Plain pytest tests — edge cases for patch -# --------------------------------------------------------------------------- diff --git a/tests/test_rules_customization_remove.py b/tests/test_rules_customization_remove.py index 15261ac..9c811f7 100644 --- a/tests/test_rules_customization_remove.py +++ b/tests/test_rules_customization_remove.py @@ -129,43 +129,6 @@ def when_remove_other_alert(ctx): class TestRemove: - def test_remove_by_alert_name(self): - config = """ - remove: - - where: - alert: LowThroughput - """ - result = _apply(config) - - group_names = [g["name"] for g in result["app-1"]["groups"]] - assert group_names == ["group_a", "group_b"] - rule_names = [r.get("alert") for r in result["app-1"]["groups"][0]["rules"]] - assert rule_names == ["HighLatency", None] - - def test_remove_by_group_only_drops_entire_group_including_recording_rules(self): - config = """ - remove: - - where: - group: group_a - """ - result = _apply(config) - - group_names = [g["name"] for g in result["app-1"]["groups"]] - assert group_names == ["group_b"] - - def test_remove_group_with_other_selector_keeps_recording_rules(self): - config = """ - remove: - - where: - group: group_a - alert: LowThroughput - """ - result = _apply(config) - - group = next(g for g in result["app-1"]["groups"] if g["name"] == "group_a") - rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] - assert rule_names == ["HighLatency", "job:latency:mean5m"] - def test_remove_by_alert_and_labels_combined(self): config_matching = """ remove: @@ -201,52 +164,6 @@ def test_remove_by_group_and_labels_combined(self): rule_names = [r.get("alert") or r.get("record") for r in group["rules"]] assert rule_names == ["HighLatency", "job:latency:mean5m"] - def test_remove_by_annotations(self): - config = """ - remove: - - where: - annotations: - summary: latency is high - """ - result = _apply(config) - - assert "HighLatency" not in str(result) - assert "LowThroughput" in str(result) - - def test_remove_multiple_entries_are_ored(self): - config = """ - remove: - - where: - alert: HighLatency - - where: - alert: OtherAlert - """ - result = _apply(config) - - assert "HighLatency" not in str(result) - assert "OtherAlert" not in str(result) - assert "LowThroughput" in str(result) - assert "HostDown" in str(result) - - def test_remove_prunes_empty_groups_and_drops_empty_identifiers(self): - config_pruning_group = """ - remove: - - where: - alert: HostDown - """ - result = _apply(config_pruning_group) - assert [g["name"] for g in result["app-1"]["groups"]] == ["group_a"] - assert "app-2" in result - - config_dropping_identifier = """ - remove: - - where: - alert: OtherAlert - """ - result = _apply(config_dropping_identifier) - assert "app-2" not in result - assert "app-1" in result - def test_remove_preserves_recording_rules_when_group_not_sole_selector(self): config = """ remove: diff --git a/tests/test_rules_customization_remove_patch.py b/tests/test_rules_customization_remove_patch.py index dfa01d2..5de19a3 100644 --- a/tests/test_rules_customization_remove_patch.py +++ b/tests/test_rules_customization_remove_patch.py @@ -5,9 +5,7 @@ Feature file: tests/features/remove_patch.feature """ -import copy -from conftest import _load_sample_alerts from pytest_bdd import scenarios, when from cosl.rules_customization import AlertRulesCustomization @@ -66,84 +64,3 @@ def when_reuse_customization(ctx): "other": {"groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}]} } ctx["result2"] = customization.apply(other) - - -# --------------------------------------------------------------------------- -# Plain pytest tests — edge cases for remove + patch interaction -# --------------------------------------------------------------------------- - - -class TestApplySemantics: - def test_input_is_not_mutated(self): - config = """ - remove: - - where: - alert: LowThroughput - patch: - - where: - alert: HighLatency - set: - for: 1h - labels: - severity: page - """ - sample = _load_sample_alerts() - snapshot = copy.deepcopy(sample) - AlertRulesCustomization.from_yaml(config).apply(sample) - assert sample == snapshot - - def test_order_of_operations_is_remove_then_patch(self): - config = """ - remove: - - where: - alert: GoneForever - patch: - - where: - alert: GoneForever - set: - for: 1m - - where: - alert: Survivor - set: - for: 2m - """ - relation_alerts = { - "app": { - "groups": [ - { - "name": "g", - "rules": [ - {"alert": "GoneForever", "expr": "x", "for": "10m"}, - {"alert": "Survivor", "expr": "y", "for": "10m"}, - ], - } - ] - } - } - result = AlertRulesCustomization.from_yaml(config).apply(relation_alerts) - - rules = result["app"]["groups"][0]["rules"] - assert [r["alert"] for r in rules] == ["Survivor"] - assert rules[0]["for"] == "2m" - - def test_apply_is_reusable_across_inputs(self): - config = """ - remove: - - where: - alert: HostDown - """ - customization = AlertRulesCustomization.from_yaml(config) - - result_1 = customization.apply(_load_sample_alerts()) - assert "HostDown" not in str(result_1["app-1"]) - assert "app-2" in result_1 - - other_relation_alerts = { - "other": { - "groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}] - } - } - result_2 = customization.apply(other_relation_alerts) - assert result_2 == {} - - assert "app-1" in result_1 diff --git a/tests/test_rules_customization_schema.py b/tests/test_rules_customization_schema.py index 5e33589..5d766ef 100644 --- a/tests/test_rules_customization_schema.py +++ b/tests/test_rules_customization_schema.py @@ -9,33 +9,14 @@ import unittest +from conftest import _load_sample_alerts + from cosl.rules_customization import ( AlertRulesCustomization, AlertRulesCustomizationError, ) -def _sample_alerts(): - return { - "app-1": { - "groups": [ - { - "name": "group_a", - "rules": [ - { - "alert": "HighLatency", - "expr": "latency > 100", - "for": "10m", - "labels": {"severity": "critical", "juju_application": "app-1"}, - "annotations": {"summary": "latency is high"}, - }, - ], - }, - ] - } - } - - class TestFromYamlValidation(unittest.TestCase): def test_invalid_yaml_raises(self): with self.assertRaises(AlertRulesCustomizationError): @@ -140,7 +121,7 @@ def test_malformed_operation_entries_raise(self): class TestNoOpConfigs(unittest.TestCase): def _assert_noop(self, config_string): - sample = _sample_alerts() + sample = _load_sample_alerts() result = AlertRulesCustomization.from_yaml(config_string).apply(sample) self.assertEqual(result, sample)