Compare commits

..
7 Commits
Author SHA1 Message Date
devx-ci-bot bee730a52f release: v0.19.3
Post-merge / detect-type (push) Successful in 45s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m8s
2026-06-26 23:58:24 +00:00
emil d0a4a774a0 DEVX-67: refactor: make molecule weights configurable via pyproject.toml
Post-merge / detect-type (push) Successful in 1m22s
Post-merge / validate-commit-msg (push) Successful in 1m4s
Post-merge / release (push) Successful in 1m15s
Post-merge / sync-wiki (push) Successful in 1m16s
Post-merge / vikunja (push) Successful in 55s
Post-merge / badges (push) Successful in 1m33s
Post-merge / configure-repo (push) Successful in 51s
Post-merge / publish (push) Successful in 1m4s
2026-06-26 23:55:33 +00:00
gitea-actions-bot 882f9805ed chore: update badge URLs to commit a45dd92c [skip ci] 2026-06-27 01:40:17 +02:00
gitea-actions-bot a85e0baaea chore: update badge URLs to commit e0f5da9a [skip ci] 2026-06-27 01:39:01 +02:00
devx-ci-bot 663572768b release: v0.19.2
Post-merge / detect-type (push) Successful in 41s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 52s
2026-06-27 01:38:41 +02:00
emil 1f2533872d DEVX-66: fix: calibrate molecule weights from actual CI execution times
Post-merge / detect-type (push) Successful in 31s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / vikunja (push) Successful in 49s
Post-merge / validate-commit-msg (push) Successful in 45s
Post-merge / configure-repo (push) Successful in 52s
Post-merge / release (push) Successful in 1m9s
Post-merge / badges (push) Successful in 1m24s
Post-merge / publish (push) Successful in 52s
2026-06-26 23:36:58 +00:00
gitea-actions-bot cb7e9dbc7e chore: update badge URLs to commit 0ab8d43a [skip ci] 2026-06-26 21:39:38 +02:00
6 changed files with 183 additions and 47 deletions
+12
View File
@@ -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
+6 -6
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
+6 -6
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/816fbbbb68d1337cf9104b87c643ab2aa2fea62c/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
+1 -1
View File
@@ -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"
+76 -20
View File
@@ -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)
+82 -14
View File
@@ -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: