Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee730a52f | ||
|
|
d0a4a774a0 | ||
|
|
882f9805ed | ||
|
|
a85e0baaea | ||
|
|
663572768b | ||
|
|
1f2533872d | ||
|
|
cb7e9dbc7e |
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.19.3] - 2026-06-26
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Make molecule weights configurable via pyproject.toml
|
||||||
|
|
||||||
|
## [0.19.2] - 2026-06-26
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Calibrate molecule weights from actual CI execution times
|
||||||
|
|
||||||
## [0.19.1] - 2026-06-26
|
## [0.19.1] - 2026-06-26
|
||||||
|
|
||||||
### Refactor
|
### Refactor
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.19.1"
|
__version__ = "0.19.3"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Usage:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -132,27 +133,82 @@ def build_multi_role_pairs(
|
|||||||
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
|
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
|
||||||
|
|
||||||
|
|
||||||
# Heuristic weights for known heavy molecule scenarios.
|
# --- Molecule weight configuration ---
|
||||||
# These are estimated from CI run times — scenarios that pull large Docker
|
#
|
||||||
# images or run complex Ansible playbooks take longer.
|
# Weights are loaded from ``[tool.devx.molecule.weights]`` in
|
||||||
_SCENARIO_WEIGHTS: dict[str, int] = {
|
# ``pyproject.toml``. Each project (infra, grm, …) contributes its own
|
||||||
"nextcloud": 10,
|
# weights calibrated from actual CI execution times.
|
||||||
"gitea": 8,
|
#
|
||||||
"vaultwarden": 7,
|
# Two key formats are supported:
|
||||||
"zitadel": 7,
|
# - ``"scenario" = weight`` — applies to any role with that scenario name
|
||||||
"postgresql": 6,
|
# - ``"role/scenario" = weight`` — role-specific (takes priority)
|
||||||
"redis": 5,
|
#
|
||||||
"backup": 5,
|
# Example pyproject.toml::
|
||||||
"docker-base": 4,
|
#
|
||||||
"default": 3,
|
# [tool.devx.molecule.weights]
|
||||||
"binary": 2,
|
# "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).
|
||||||
|
|
||||||
_DEFAULT_SCENARIO_WEIGHT = 3
|
_DEFAULT_SCENARIO_WEIGHT = 3
|
||||||
|
|
||||||
|
|
||||||
def _scenario_weight(scenario: str) -> int:
|
def _load_molecule_weights(pyproject_path: str = "pyproject.toml") -> tuple[dict[str, int], dict[tuple[str, str], int]]:
|
||||||
"""Estimate a weight for a scenario based on its name."""
|
"""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 (``"role/scenario"``) take priority over
|
||||||
|
scenario-name-only weights (``"scenario"``). Falls back to the
|
||||||
|
default weight if no configuration matches.
|
||||||
|
"""
|
||||||
s = scenario.lower()
|
s = scenario.lower()
|
||||||
|
if role is not None:
|
||||||
|
r = role.lower()
|
||||||
|
key = (r, s)
|
||||||
|
if key in _ROLE_SCENARIO_WEIGHTS:
|
||||||
|
return _ROLE_SCENARIO_WEIGHTS[key]
|
||||||
for key, weight in _SCENARIO_WEIGHTS.items():
|
for key, weight in _SCENARIO_WEIGHTS.items():
|
||||||
if key in s:
|
if key in s:
|
||||||
return weight
|
return weight
|
||||||
@@ -181,11 +237,11 @@ def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) ->
|
|||||||
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||||
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
|
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
|
||||||
|
|
||||||
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
|
Each pair is weighted by role+scenario heuristics (e.g. ``nextcloud`` is
|
||||||
heavier than ``binary``). Pairs are sorted by weight descending and
|
heavier than ``simple-app``). Pairs are sorted by weight descending and
|
||||||
assigned to the runner with the least total weight.
|
assigned to the runner with the least total weight.
|
||||||
"""
|
"""
|
||||||
weights = [_scenario_weight(p.scenario) for p in pairs]
|
weights = [_scenario_weight(p.scenario, p.role) for p in pairs]
|
||||||
return _lpt_distribute(pairs, weights, max_runners)
|
return _lpt_distribute(pairs, weights, max_runners)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from devx.molecule.distribute_molecule import (
|
|||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
MultiRoleTestPair,
|
MultiRoleTestPair,
|
||||||
TestPair,
|
TestPair,
|
||||||
|
_load_molecule_weights,
|
||||||
_lpt_distribute,
|
_lpt_distribute,
|
||||||
_scenario_weight,
|
_scenario_weight,
|
||||||
build_multi_role_pairs,
|
build_multi_role_pairs,
|
||||||
@@ -482,23 +483,90 @@ class TestCliMultiRole:
|
|||||||
|
|
||||||
|
|
||||||
class TestScenarioWeight:
|
class TestScenarioWeight:
|
||||||
def test_known_heavy_scenario(self) -> None:
|
def test_default_weight_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
assert _scenario_weight("nextcloud") == 10
|
"""Without pyproject.toml, all scenarios get the default weight."""
|
||||||
assert _scenario_weight("gitea") == 8
|
monkeypatch.chdir(tmp_path)
|
||||||
|
scenario_w, role_w = _load_molecule_weights()
|
||||||
def test_known_light_scenario(self) -> None:
|
assert scenario_w == {}
|
||||||
assert _scenario_weight("binary") == 2
|
assert role_w == {}
|
||||||
|
|
||||||
def test_default_weight(self) -> None:
|
|
||||||
assert _scenario_weight("unknown-scenario") == 3
|
assert _scenario_weight("unknown-scenario") == 3
|
||||||
|
|
||||||
def test_case_insensitive(self) -> None:
|
def test_load_weights_from_pyproject(self, tmp_path: Path) -> None:
|
||||||
assert _scenario_weight("NextCloud") == 10
|
"""Weights are loaded from [tool.devx.molecule.weights] in pyproject.toml."""
|
||||||
assert _scenario_weight("GITEA") == 8
|
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:
|
def test_role_specific_takes_priority(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
assert _scenario_weight("nextcloud-with-redis") == 10
|
"""Role-specific weights take priority over scenario-name-only weights."""
|
||||||
assert _scenario_weight("custom-gitea-setup") == 8
|
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", "restore") == 11
|
||||||
|
assert _scenario_weight("default", "app_container") == 3
|
||||||
|
|
||||||
|
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_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:
|
class TestLptDistribute:
|
||||||
|
|||||||
Reference in New Issue
Block a user