From d0a4a774a0c3db08e6ec63a325308b8704debaa9 Mon Sep 17 00:00:00 2001 From: emil Date: Fri, 26 Jun 2026 23:55:33 +0000 Subject: [PATCH] DEVX-67: refactor: make molecule weights configurable via pyproject.toml --- src/devx/molecule/distribute_molecule.py | 116 ++++++++++++----------- tests/unit/test_distribute_molecule.py | 107 +++++++++++++++------ 2 files changed, 139 insertions(+), 84 deletions(-) diff --git a/src/devx/molecule/distribute_molecule.py b/src/devx/molecule/distribute_molecule.py index ba11e8d..b7993db 100644 --- a/src/devx/molecule/distribute_molecule.py +++ b/src/devx/molecule/distribute_molecule.py @@ -19,6 +19,7 @@ Usage: from __future__ import annotations +import tomllib from dataclasses import dataclass from pathlib import Path @@ -132,70 +133,75 @@ def build_multi_role_pairs( return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms] -# Heuristic weights for known heavy molecule scenarios. -# These are estimated from CI run times — scenarios that pull large Docker -# images or run complex Ansible playbooks take longer. +# --- Molecule weight configuration --- # -# Weights are calibrated from actual CI execution times (converge→destroy): -# nextcloud: ~7.7m → 15 -# restore/default: ~5.7m → 11 -# customer-apps: ~5.5m → 11 -# zitadel/default: ~5.0m → 10 -# docker_base/default: ~3.9m → 8 -# app_hardening/default: ~1.9m → 4 -# app_container/default: ~1.8m → 3 -# postgres-upgrade: ~1.7m → 3 -# observability/default: ~1.7m → 3 -# storage/default: ~1.5m → 3 -# storage/object-storage: ~1.0m → 2 -# vaultwarden: ~0.7m → 2 -# simple-app: ~0.7m → 2 +# Weights are loaded from ``[tool.devx.molecule.weights]`` in +# ``pyproject.toml``. Each project (infra, grm, …) contributes its own +# weights calibrated from actual CI execution times. # -# Role-specific weights take priority over scenario-name weights. -# The (role, scenario) tuple is checked first, then the scenario name -# alone, then the default weight. -_ROLE_SCENARIO_WEIGHTS: dict[tuple[str, str], int] = { - ("app_container", "nextcloud"): 15, - ("app_container", "customer-apps"): 11, - ("app_container", "vaultwarden"): 2, - ("app_container", "simple-app"): 2, - ("app_container", "postgres-upgrade"): 3, - ("app_container", "default"): 3, - ("restore", "default"): 11, - ("zitadel", "default"): 10, - ("docker_base", "default"): 8, - ("observability", "default"): 3, - ("app_hardening", "default"): 4, - ("storage", "default"): 3, - ("storage", "object-storage"): 2, -} +# Two key formats are supported: +# - ``"scenario" = weight`` — applies to any role with that scenario name +# - ``"role/scenario" = weight`` — role-specific (takes priority) +# +# Example pyproject.toml:: +# +# [tool.devx.molecule.weights] +# "nextcloud" = 15 +# "app_container/customer-apps" = 11 +# "restore/default" = 11 +# "default" = 3 +# +# If no configuration is found, a generic default weight is used for all +# scenarios (producing a round-robin distribution). -# Fallback weights by scenario name only (for single-role projects or -# scenarios not in the role-specific table). -_SCENARIO_WEIGHTS: dict[str, int] = { - "nextcloud": 15, - "customer-apps": 11, - "restore": 11, - "zitadel": 10, - "docker-base": 8, - "postgresql": 3, - "postgres-upgrade": 3, - "gitea": 8, - "redis": 5, - "backup": 5, - "vaultwarden": 2, - "simple-app": 2, - "object-storage": 2, - "default": 3, - "binary": 2, -} _DEFAULT_SCENARIO_WEIGHT = 3 +def _load_molecule_weights(pyproject_path: str = "pyproject.toml") -> tuple[dict[str, int], dict[tuple[str, str], int]]: + """Load molecule weights from ``[tool.devx.molecule.weights]`` in pyproject.toml. + + Returns a tuple of ``(scenario_weights, role_scenario_weights)``: + - ``scenario_weights``: maps scenario name → weight (applies to any role) + - ``role_scenario_weights``: maps (role, scenario) → weight (role-specific) + """ + path = Path(pyproject_path) + if not path.exists(): + return {}, {} + try: + with open(path, "rb") as f: # noqa: PTH123 + data = tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError): + return {}, {} + + weights_raw = data.get("tool", {}).get("devx", {}).get("molecule", {}).get("weights", {}) + if not isinstance(weights_raw, dict): + return {}, {} + + scenario_weights: dict[str, int] = {} + role_scenario_weights: dict[tuple[str, str], int] = {} + + for key, value in weights_raw.items(): + if not isinstance(value, int): + continue + if "/" in key: + role, scenario = key.split("/", 1) + role_scenario_weights[(role.lower(), scenario.lower())] = value + else: + scenario_weights[key.lower()] = value + + return scenario_weights, role_scenario_weights + + +# Load weights once at import time (like devx.config and classify_changes) +_SCENARIO_WEIGHTS, _ROLE_SCENARIO_WEIGHTS = _load_molecule_weights() + + def _scenario_weight(scenario: str, role: str | None = None) -> int: """Estimate a weight for a scenario based on its name and optionally its role. - Role-specific weights take priority over scenario-name-only weights. + Role-specific weights (``"role/scenario"``) take priority over + scenario-name-only weights (``"scenario"``). Falls back to the + default weight if no configuration matches. """ s = scenario.lower() if role is not None: diff --git a/tests/unit/test_distribute_molecule.py b/tests/unit/test_distribute_molecule.py index d225465..326528f 100644 --- a/tests/unit/test_distribute_molecule.py +++ b/tests/unit/test_distribute_molecule.py @@ -13,6 +13,7 @@ from devx.molecule.distribute_molecule import ( PLATFORMS, MultiRoleTestPair, TestPair, + _load_molecule_weights, _lpt_distribute, _scenario_weight, build_multi_role_pairs, @@ -482,42 +483,90 @@ class TestCliMultiRole: class TestScenarioWeight: - def test_known_heavy_scenario(self) -> None: - assert _scenario_weight("nextcloud") == 15 - assert _scenario_weight("gitea") == 8 - - def test_known_light_scenario(self) -> None: - assert _scenario_weight("binary") == 2 - - def test_default_weight(self) -> None: + def test_default_weight_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Without pyproject.toml, all scenarios get the default weight.""" + monkeypatch.chdir(tmp_path) + scenario_w, role_w = _load_molecule_weights() + assert scenario_w == {} + assert role_w == {} assert _scenario_weight("unknown-scenario") == 3 - def test_case_insensitive(self) -> None: - assert _scenario_weight("NextCloud") == 15 - assert _scenario_weight("GITEA") == 8 + def test_load_weights_from_pyproject(self, tmp_path: Path) -> None: + """Weights are loaded from [tool.devx.molecule.weights] in pyproject.toml.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + "[tool.devx.molecule.weights]\n" + '"nextcloud" = 15\n' + '"default" = 3\n' + '"binary" = 2\n' + '"app_container/customer-apps" = 11\n' + '"restore/default" = 11\n' + ) + scenario_w, role_w = _load_molecule_weights(str(pyproject)) + assert scenario_w == {"nextcloud": 15, "default": 3, "binary": 2} + assert role_w == {("app_container", "customer-apps"): 11, ("restore", "default"): 11} - def test_substring_match(self) -> None: - assert _scenario_weight("nextcloud-with-redis") == 15 - assert _scenario_weight("custom-gitea-setup") == 8 - - def test_role_specific_weight(self) -> None: - """Role+scenario pairs take priority over scenario-name-only weights.""" - assert _scenario_weight("default", "restore") == 11 - assert _scenario_weight("default", "zitadel") == 10 + def test_role_specific_takes_priority(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Role-specific weights take priority over scenario-name-only weights.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[tool.devx.molecule.weights]\n"default" = 3\n"docker_base/default" = 8\n"restore/default" = 11\n' + ) + scenario_w, role_w = _load_molecule_weights(str(pyproject)) + monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w) + monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w) assert _scenario_weight("default", "docker_base") == 8 - assert _scenario_weight("default", "app_hardening") == 4 + assert _scenario_weight("default", "restore") == 11 assert _scenario_weight("default", "app_container") == 3 - assert _scenario_weight("default", "storage") == 3 - assert _scenario_weight("default", "observability") == 3 - def test_role_specific_overrides_scenario_name(self) -> None: - """vaultwarden has a scenario-name weight of 2, but role-specific is also 2.""" - assert _scenario_weight("vaultwarden", "app_container") == 2 - assert _scenario_weight("vaultwarden") == 2 + def test_case_insensitive(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Weight keys are matched case-insensitively.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n') + scenario_w, role_w = _load_molecule_weights(str(pyproject)) + monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w) + monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w) + assert _scenario_weight("NextCloud") == 15 + assert _scenario_weight("NEXTCLOUD") == 15 - def test_customer_apps_weight(self) -> None: - assert _scenario_weight("customer-apps", "app_container") == 11 - assert _scenario_weight("customer-apps") == 11 + def test_substring_match(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Scenario-name weights use substring matching.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n') + scenario_w, role_w = _load_molecule_weights(str(pyproject)) + monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w) + monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w) + assert _scenario_weight("nextcloud-with-redis") == 15 + + def test_no_pyproject_returns_empty(self, tmp_path: Path) -> None: + """Missing pyproject.toml returns empty weight dicts.""" + scenario_w, role_w = _load_molecule_weights(str(tmp_path / "nonexistent.toml")) + assert scenario_w == {} + assert role_w == {} + + def test_invalid_weights_ignored(self, tmp_path: Path) -> None: + """Non-integer weight values are silently ignored.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[tool.devx.molecule.weights]\n"good" = 5\n"bad" = "not an int"\n') + scenario_w, role_w = _load_molecule_weights(str(pyproject)) + assert scenario_w == {"good": 5} + assert role_w == {} + + def test_malformed_toml_returns_empty(self, tmp_path: Path) -> None: + """Malformed TOML returns empty weight dicts.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("this is not valid toml = = =") + scenario_w, role_w = _load_molecule_weights(str(pyproject)) + assert scenario_w == {} + assert role_w == {} + + def test_non_dict_weights_returns_empty(self, tmp_path: Path) -> None: + """If [tool.devx.molecule.weights] is not a table, returns empty dicts.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[tool.devx.molecule]\nweights = "not a table"\n') + scenario_w, role_w = _load_molecule_weights(str(pyproject)) + assert scenario_w == {} + assert role_w == {} class TestLptDistribute: