diff --git a/pyproject.toml b/pyproject.toml index e2a95e3..b453ae9 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", @@ -125,6 +126,11 @@ 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/features/patch.feature b/tests/features/patch.feature new file mode 100644 index 0000000..5b37636 --- /dev/null +++ b/tests/features/patch.feature @@ -0,0 +1,244 @@ +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 + + Scenario: Patch updates the for duration of a matching alert + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + """ + + Given the following patch + """ + patch: + - where: + alert: HighLatency + set: + for: 30m + """ + + When the patch is applied + + Then alert "HighLatency" has "for" equal to "30m" + + Scenario: Patch replaces the alert name + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + """ + + Given the following patch + """ + patch: + - where: + alert: HighLatency + set: + alert: RenamedLatency + """ + + When the patch is applied + + Then alert "RenamedLatency" is present + And alert "HighLatency" is absent + + Scenario: Patch replaces the expression + + Given the following alert rules + """ + app-1: + groups: + - name: group_b + rules: + - alert: HostDown + expr: up < 1 + """ + + Given the following patch + """ + patch: + - where: + alert: HostDown + set: + expr: up == 0 + """ + + When the patch is applied + + Then alert "HostDown" has "expr" equal to "up == 0" + + Scenario: Patch overwrites an existing label and adds a new one leaving others untouched + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + labels: + severity: critical + juju_application: app-1 + """ + + Given the following patch + """ + patch: + - where: + alert: HighLatency + set: + labels: + severity: page + extra: added + """ + + When the patch is applied + + 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 + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + labels: + juju_application: app-1 + """ + + Given the following patch + """ + patch: + - where: + alert: HighLatency + set: + labels: + juju_application: other-app + """ + + When the patch is applied + + Then alert "HighLatency" has label "juju_application" equal to "other-app" + + Scenario: Patch merges annotations + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + annotations: + summary: latency is high + """ + + Given the following patch + """ + patch: + - where: + alert: HighLatency + set: + annotations: + summary: new summary + description: new description + """ + + When the patch is applied + + 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 + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + - record: job:latency:mean5m + expr: avg(latency) + """ + + Given the following patch + """ + patch: + - where: + group: group_a + set: + expr: hacked + """ + + When the patch is applied + + Then 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 + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + labels: + severity: critical + - alert: LowThroughput + expr: throughput < 10 + for: 5m + labels: + severity: warning + - record: job:latency:mean5m + expr: avg(latency) + labels: + severity: warning + """ + + Given the following patch + """ + patch: + - where: + labels: + severity: warning + set: + labels: + severity: critical + """ + + When the patch is applied + + Then alert "LowThroughput" has label "severity" equal to "critical" + And 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..1f6b78a --- /dev/null +++ b/tests/features/remove.feature @@ -0,0 +1,293 @@ +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 + + Scenario: Remove an alert by name + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + - alert: LowThroughput + expr: throughput < 10 + for: 5m + - record: job:latency:mean5m + expr: avg(latency) + """ + + Given the following remove config + """ + remove: + - where: + alert: LowThroughput + """ + + When the customization is applied + + Then alert "LowThroughput" is absent + And alert "HighLatency" is present + And recording rule "job:latency:mean5m" is present + + Scenario: Remove an entire group by group name drops everything including recording rules + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + - record: job:latency:mean5m + expr: avg(latency) + - name: group_b + rules: + - alert: HostDown + expr: up < 1 + """ + + Given the following remove config + """ + remove: + - where: + group: group_a + """ + + When the customization is applied + + 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 + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + - alert: LowThroughput + expr: throughput < 10 + for: 5m + - record: job:latency:mean5m + expr: avg(latency) + """ + + Given the following remove config + """ + remove: + - where: + group: group_a + alert: LowThroughput + """ + + When the customization is applied + + Then alert "LowThroughput" is absent + And alert "HighLatency" is present + And recording rule "job:latency:mean5m" is present + + Scenario: Remove by label value + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + labels: + severity: critical + - alert: LowThroughput + expr: throughput < 10 + for: 5m + labels: + severity: warning + - record: job:latency:mean5m + expr: avg(latency) + """ + + Given the following remove config + """ + remove: + - where: + labels: + severity: warning + """ + + When the customization is applied + + Then alert "LowThroughput" is absent + And alert "HighLatency" is present + And recording rule "job:latency:mean5m" is present + + Scenario: Remove by annotation value + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + annotations: + summary: latency is high + - alert: LowThroughput + expr: throughput < 10 + for: 5m + """ + + Given the following remove config + """ + remove: + - where: + annotations: + summary: latency is high + """ + + When the customization is applied + + Then alert "HighLatency" is absent + And alert "LowThroughput" is present + + Scenario: Remove by juju topology label + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + labels: + juju_application: app-1 + - alert: LowThroughput + expr: throughput < 10 + for: 5m + """ + + Given the following remove config + """ + remove: + - where: + labels: + juju_application: app-1 + """ + + When the customization is applied + + Then alert "HighLatency" is absent + And alert "LowThroughput" is present + + Scenario: Multiple remove entries are OR'd + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + - alert: LowThroughput + expr: throughput < 10 + - name: group_b + rules: + - alert: HostDown + expr: up < 1 + app-2: + groups: + - name: group_c + rules: + - alert: OtherAlert + expr: x > 0 + """ + + Given the following remove config + """ + remove: + - where: + alert: HighLatency + - where: + alert: OtherAlert + """ + + When the customization is applied + + Then alert "HighLatency" is absent + And alert "OtherAlert" is absent + And alert "LowThroughput" is present + And alert "HostDown" is present + + Scenario: Removing the only rule in a group prunes the empty group + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + - name: group_b + rules: + - alert: HostDown + expr: up < 1 + """ + + Given the following remove config + """ + remove: + - where: + alert: HostDown + """ + + When the customization is applied + + 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 + + Given the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + app-2: + groups: + - name: group_c + rules: + - alert: OtherAlert + expr: x > 0 + """ + + Given the following remove config + """ + remove: + - where: + alert: OtherAlert + """ + + When the customization is applied + + Then identifier "app-2" is absent + And identifier "app-1" is present diff --git a/tests/features/remove_patch.feature b/tests/features/remove_patch.feature new file mode 100644 index 0000000..50d2146 --- /dev/null +++ b/tests/features/remove_patch.feature @@ -0,0 +1,96 @@ +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 the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HighLatency + expr: latency > 100 + for: 10m + labels: + severity: critical + - alert: LowThroughput + expr: throughput < 10 + for: 5m + """ + + Given the following combined config + """ + remove: + - where: + alert: LowThroughput + patch: + - where: + alert: HighLatency + set: + for: 1h + labels: + severity: page + """ + + When the customization is applied + + Then the original input is unchanged + + Scenario: Operations are applied in order remove then patch + + Given the following alert rules + """ + app: + groups: + - name: g + rules: + - alert: GoneForever + expr: x + for: 10m + - alert: Survivor + expr: y + for: 10m + """ + + Given the following combined config + """ + remove: + - where: + alert: GoneForever + patch: + - where: + alert: Survivor + set: + for: 2m + """ + + When the customization is applied + + 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 the following alert rules + """ + app-1: + groups: + - name: group_a + rules: + - alert: HostDown + expr: up < 1 + """ + + Given the following remove config + """ + remove: + - where: + alert: HostDown + """ + + When the same customization is applied to two different inputs + + Then alert "HostDown" is absent from both results diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..99d5e10 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,29 @@ +from pathlib import Path + +import yaml + +_SAMPLE_ALERTS_PATH = Path(__file__).parent / "sample_alerts.yaml" + + +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 _find_alert(result, name): + for rule_file in result.values(): + for group in rule_file.get("groups", []): + for rule in group.get("rules", []): + if rule.get("alert") == name: + return rule + raise AssertionError(f"Alert {name!r} not found in result") + + +def _find_record(result, name): + for rule_file in result.values(): + for group in rule_file.get("groups", []): + for rule in group.get("rules", []): + if rule.get("record") == name: + return rule + raise AssertionError(f"Recording rule {name!r} not found in result") 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.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 new file mode 100644 index 0000000..daa5782 --- /dev/null +++ b/tests/test_rules_customization_patch.py @@ -0,0 +1,92 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""pytest-bdd step definitions for patch scenarios. + +Feature file: tests/features/patch.feature +""" + +import yaml +from helpers import _find_alert, _find_record +from pytest_bdd import given, parsers, scenarios, then, when + +from cosl.rules_customization import AlertRulesCustomization + +scenarios("features/patch.feature") + + +# --------------------------------------------------------------------------- +# Given +# --------------------------------------------------------------------------- + + +@given("the following alert rules", target_fixture="alerts") +def given_the_following_alert_rules(docstring): + return yaml.safe_load(docstring) + + +@given("the following patch", target_fixture="patch") +def given_the_following_patch(docstring): + return AlertRulesCustomization.from_yaml(docstring) + + +# --------------------------------------------------------------------------- +# When +# --------------------------------------------------------------------------- + + +@when("the patch is applied", target_fixture="result") +def when_the_patch_is_applied(patch, alerts): + return patch.apply(alerts) + + +# --------------------------------------------------------------------------- +# Then +# --------------------------------------------------------------------------- + + +@then(parsers.parse('alert "{name}" has "{field}" equal to "{value}"')) +def then_alert_field(result, name, field, value): + found = _find_alert(result, name) + assert found[field] == value, f"expected {field}={value!r}, got {found.get(field)!r}" + + +@then(parsers.parse('alert "{name}" has label "{key}" equal to "{value}"')) +def then_alert_label(result, name, key, value): + found = _find_alert(result, name) + labels = found.get("labels", {}) + assert labels.get(key) == value, f"expected label {key}={value!r}, got {labels.get(key)!r}" + + +@then(parsers.parse('alert "{name}" has annotation "{key}" equal to "{value}"')) +def then_alert_annotation(result, name, key, value): + found = _find_alert(result, name) + annotations = found.get("annotations", {}) + assert ( + annotations.get(key) == value + ), f"expected annotation {key}={value!r}, got {annotations.get(key)!r}" + + +@then(parsers.parse('alert "{name}" is present')) +def then_alert_present(result, name): + _find_alert(result, name) # raises AssertionError if not found + + +@then(parsers.parse('alert "{name}" is absent')) +def then_alert_absent(result, name): + for rule_file in result.values(): + for group in rule_file.get("groups", []): + for rule in group.get("rules", []): + assert rule.get("alert") != name, f"alert {name!r} was found but should be absent" + + +@then(parsers.parse('recording rule "{name}" has "{field}" equal to "{value}"')) +def then_recording_rule_field(result, name, field, value): + found = _find_record(result, name) + assert found[field] == value, f"expected {field}={value!r}, got {found.get(field)!r}" + + +@then(parsers.parse('recording rule "{name}" has label "{key}" equal to "{value}"')) +def then_recording_rule_label(result, name, key, value): + found = _find_record(result, name) + labels = found.get("labels", {}) + assert labels.get(key) == value, f"expected label {key}={value!r}, got {labels.get(key)!r}" diff --git a/tests/test_rules_customization_remove.py b/tests/test_rules_customization_remove.py new file mode 100644 index 0000000..10feafa --- /dev/null +++ b/tests/test_rules_customization_remove.py @@ -0,0 +1,195 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""pytest-bdd step definitions for remove scenarios. + +Feature file: tests/features/remove.feature +""" + +import yaml +from helpers import _find_alert, _find_record +from pytest_bdd import given, parsers, scenarios, then, when + +from cosl.rules_customization import AlertRulesCustomization + +scenarios("features/remove.feature") + + +# --------------------------------------------------------------------------- +# Given +# --------------------------------------------------------------------------- + + +@given("the following alert rules", target_fixture="alerts") +def given_the_following_alert_rules(docstring): + return yaml.safe_load(docstring) + + +@given("the following remove config", target_fixture="remove_config") +def given_the_following_remove_config(docstring): + return AlertRulesCustomization.from_yaml(docstring) + + +# --------------------------------------------------------------------------- +# When +# --------------------------------------------------------------------------- + + +@when("the customization is applied", target_fixture="result") +def when_the_customization_is_applied(remove_config, alerts): + return remove_config.apply(alerts) + + +# --------------------------------------------------------------------------- +# Then +# --------------------------------------------------------------------------- + + +@then(parsers.parse('alert "{name}" is present')) +def then_alert_present(result, name): + _find_alert(result, name) # raises AssertionError if not found + + +@then(parsers.parse('alert "{name}" is absent')) +def then_alert_absent(result, name): + for rule_file in result.values(): + for group in rule_file.get("groups", []): + for rule in group.get("rules", []): + assert rule.get("alert") != name, f"alert {name!r} was found but should be absent" + + +@then(parsers.parse('recording rule "{name}" is present')) +def then_recording_rule_present(result, name): + _find_record(result, name) # raises AssertionError if not found + + +@then(parsers.parse('group "{group_name}" is absent from identifier "{identifier}"')) +def then_group_absent(result, group_name, identifier): + if identifier not in result: + return + group_names = [g["name"] for g in result[identifier].get("groups", [])] + assert ( + group_name not in group_names + ), f"group {group_name!r} was found in {identifier!r} but should be absent" + + +@then(parsers.parse('group "{group_name}" is present in identifier "{identifier}"')) +def then_group_present(result, group_name, identifier): + assert identifier in result, f"identifier {identifier!r} not found in result" + group_names = [g["name"] for g in result[identifier].get("groups", [])] + assert group_name in group_names, f"group {group_name!r} not found in {identifier!r}" + + +@then(parsers.parse('identifier "{identifier}" is absent')) +def then_identifier_absent(result, identifier): + assert identifier not in result, f"identifier {identifier!r} was found but should be absent" + + +@then(parsers.parse('identifier "{identifier}" is present')) +def then_identifier_present(result, identifier): + assert identifier in result, f"identifier {identifier!r} not found in result" + + +# --------------------------------------------------------------------------- +# Plain pytest tests — edge cases for remove +# --------------------------------------------------------------------------- + + +class TestRemove: + def test_remove_by_alert_and_labels_combined(self): + alerts = _sample_alerts() + + config_matching = """ +remove: + - where: + alert: HighLatency + labels: + severity: critical +""" + result = AlertRulesCustomization.from_yaml(config_matching).apply(alerts) + assert "HighLatency" not in str(result) + + config_not_matching = """ +remove: + - where: + alert: HighLatency + labels: + severity: warning +""" + result = AlertRulesCustomization.from_yaml(config_not_matching).apply(alerts) + assert "HighLatency" in str(result) + + def test_remove_by_group_and_labels_combined(self): + alerts = _sample_alerts() + config = """ +remove: + - where: + group: group_a + labels: + severity: warning +""" + result = AlertRulesCustomization.from_yaml(config).apply(alerts) + 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_preserves_recording_rules_when_group_not_sole_selector(self): + alerts = _sample_alerts() + config = """ +remove: + - where: + labels: + severity: warning +""" + result = AlertRulesCustomization.from_yaml(config).apply(alerts) + record = _find_rule(result, "app-1", "group_a", "job:latency:mean5m", by_record=True) + assert record["expr"] == "avg(latency)" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _sample_alerts(): + """Return the canonical sample alert rules used by plain pytest tests.""" + return yaml.safe_load("""\ +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/test_rules_customization_remove_patch.py b/tests/test_rules_customization_remove_patch.py new file mode 100644 index 0000000..2bea869 --- /dev/null +++ b/tests/test_rules_customization_remove_patch.py @@ -0,0 +1,98 @@ +# 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 + +import yaml +from helpers import _find_alert +from pytest_bdd import given, parsers, scenarios, then, when + +from cosl.rules_customization import AlertRulesCustomization + +scenarios("features/remove_patch.feature") + + +# --------------------------------------------------------------------------- +# Given +# --------------------------------------------------------------------------- + + +@given("the following alert rules", target_fixture="alerts") +def given_the_following_alert_rules(docstring): + return yaml.safe_load(docstring) + + +@given("the following combined config", target_fixture="customization") +def given_the_following_combined_config(docstring): + return AlertRulesCustomization.from_yaml(docstring) + + +@given("the following remove config", target_fixture="customization") +def given_the_following_remove_config(docstring): + return AlertRulesCustomization.from_yaml(docstring) + + +# --------------------------------------------------------------------------- +# When +# --------------------------------------------------------------------------- + + +@when("the customization is applied", target_fixture="apply_outcome") +def when_the_customization_is_applied(customization, alerts): + original = copy.deepcopy(alerts) + result = customization.apply(alerts) + return {"result": result, "original": original, "input": alerts} + + +@when("the same customization is applied to two different inputs", target_fixture="both_results") +def when_same_customization_applied_twice(customization, alerts): + other = { + "other": {"groups": [{"name": "g", "rules": [{"alert": "HostDown", "expr": "up < 1"}]}]} + } + return { + "result1": customization.apply(alerts), + "result2": customization.apply(other), + } + + +# --------------------------------------------------------------------------- +# Then +# --------------------------------------------------------------------------- + + +@then("the original input is unchanged") +def then_original_input_unchanged(apply_outcome): + assert ( + apply_outcome["input"] == apply_outcome["original"] + ), "apply() mutated the input — original and current input differ" + + +@then(parsers.parse('alert "{name}" is absent from identifier "{identifier}"')) +def then_alert_absent_from_identifier(apply_outcome, name, identifier): + result = apply_outcome["result"] + if identifier not in result: + return + assert name not in str( + result[identifier] + ), f"alert {name!r} was found in identifier {identifier!r} but should be absent" + + +@then(parsers.parse('alert "{name}" has "{field}" equal to "{value}"')) +def then_alert_field(apply_outcome, name, field, value): + result = apply_outcome["result"] + found = _find_alert(result, name) + assert found[field] == value, f"expected {field}={value!r}, got {found.get(field)!r}" + + +@then(parsers.parse('alert "{name}" is absent from both results')) +def then_alert_absent_from_both(both_results, name): + assert name not in str( + both_results["result1"] + ), f"alert {name!r} found in result1 but should be absent" + assert name not in str( + both_results["result2"] + ), f"alert {name!r} found in result2 but should be absent" diff --git a/tests/test_rules_customization_schema.py b/tests/test_rules_customization_schema.py new file mode 100644 index 0000000..ff5dcd6 --- /dev/null +++ b/tests/test_rules_customization_schema.py @@ -0,0 +1,162 @@ +# 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 helpers import _load_sample_alerts + +from cosl.rules_customization import ( + AlertRulesCustomization, + AlertRulesCustomizationError, +) + + +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, "Extra inputs are not permitted" + ): + 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.assertRaisesRegex( + AlertRulesCustomizationError, "Extra inputs are not permitted" + ): + 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.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" + ): + 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" + ): + AlertRulesCustomization.from_yaml(f"{key}: not-a-list") + + def test_malformed_operation_entries_raise(self): + 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[0]) + + +class TestNoOpConfigs(unittest.TestCase): + def _assert_noop(self, config_string): + sample = _load_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 377eb0f..f7b5e4f 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, @@ -269,10 +271,19 @@ 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.*'" }, { 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'" }, ] [package.metadata] @@ -288,6 +299,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'" }, @@ -298,7 +310,14 @@ requires-dist = [ provides-extras = ["dev"] [package.metadata.requires-dev] -test = [{ name = "pytest", specifier = ">=8.3.5" }] +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" }, +] [[package]] name = "coverage" @@ -688,6 +707,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" @@ -743,6 +771,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" @@ -909,6 +1130,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" @@ -1363,6 +1606,50 @@ 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"