From 91216da1a4c6b87a0cfbf59d2d2a59a0e5f910c1 Mon Sep 17 00:00:00 2001 From: emil Date: Fri, 26 Jun 2026 15:04:11 +0000 Subject: [PATCH] DEVX-61: feat: single-source-of-truth config via [tool.devx] in pyproject.toml --- .gitea/workflows/ci.yml | 2 +- pyproject.toml | 7 ++ src/devx/config.py | 72 +++++++++++++++++++-- src/devx/make/devx.mak | 44 ++++++------- src/devx/tools/check_config.py | 74 ++++++++++++++++++++++ src/devx/translations.json | 32 ++++++++++ tests/unit/test_check_config.py | 90 ++++++++++++++++++++++++++ tests/unit/test_config.py | 109 ++++++++++++++++++++++++-------- 8 files changed, 373 insertions(+), 57 deletions(-) create mode 100644 src/devx/tools/check_config.py create mode 100644 tests/unit/test_check_config.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b2dab8b..05db409 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: PYTHONPATH: src run: | . .venv/bin/activate - python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5 + python3 -m devx.tools.check_test_speed --max-seconds 5 --max-single-seconds 0.5 - name: Documentation coverage check env: PYTHONPATH: src diff --git a/pyproject.toml b/pyproject.toml index a984cfe..abe0203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,13 @@ strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "s # Rule priority (first match wins): # 1. user_facing_overrides (safety — highest priority) # 2. infrastructure_overrides (explicit per-file) +# Project-specific devx configuration (read by devx.config) +[tool.devx] +task_prefix = "DEVX" +vikunja_project_id = 8 +repo_owner = "oblachno-oss" +repo_name = "devx" + # 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns) # 4. Default: user-facing (safe) [tool.devx.classify] diff --git a/src/devx/config.py b/src/devx/config.py index b84e8a0..b44a4b0 100644 --- a/src/devx/config.py +++ b/src/devx/config.py @@ -1,28 +1,86 @@ """Shared configuration constants for devx scripts and API clients. -All defaults can be overridden via environment variables with the ``DEVX_`` -prefix. Projects consuming devx can set these in their ``.env`` files. +Configuration is read from two sources, in priority order: + +1. **Environment variables** (``DEVX_`` prefix) — highest priority, used for + CI secrets and per-run overrides. +2. **``[tool.devx]`` section in ``pyproject.toml``** — project defaults, + read from the current working directory. + +If neither source provides a value, built-in defaults are used. """ from __future__ import annotations import os import re +import tomllib +from pathlib import Path + + +def _load_pyproject_devx() -> dict[str, object]: + """Load the ``[tool.devx]`` section from pyproject.toml in the CWD. + + Returns an empty dict if the file or section is missing. + """ + path = Path("pyproject.toml") + if not path.exists(): + return {} + try: + with open(path, "rb") as f: # noqa: PTH123 + data: dict[str, object] = tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError): + return {} + tool_raw: object = data.get("tool", {}) + if not isinstance(tool_raw, dict): + return {} + tool: dict[str, object] = tool_raw # type: ignore[assignment] + devx_raw: object = tool.get("devx", {}) + if not isinstance(devx_raw, dict): + return {} + devx: dict[str, object] = devx_raw # type: ignore[assignment] + return devx + + +_PYPROJECT = _load_pyproject_devx() + + +def _get(key: str, env_var: str, default: str) -> str: + """Get a config value: env var > pyproject.toml > default.""" + env_val = os.getenv(env_var) + if env_val is not None: + return env_val + pyproject_val = _PYPROJECT.get(key) + if isinstance(pyproject_val, str): + return pyproject_val + return default + + +def _get_int(key: str, env_var: str, default: int) -> int: + """Get an int config value: env var > pyproject.toml > default.""" + env_val = os.getenv(env_var) + if env_val is not None: + return int(env_val) + pyproject_val = _PYPROJECT.get(key) + if isinstance(pyproject_val, int): + return pyproject_val + return default + # API endpoints — override via env vars for different Gitea/Vikunja instances -GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") -VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1") +GITEA_API_URL = _get("gitea_api_url", "DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") +VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1") # Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly. # No default: prevents silent 404s when the wrong owner is used. -REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "") +REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "") # Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.) -TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX") +TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX") TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+") # Vikunja project ID — each project uses a different Vikunja project -VIKUNJA_PROJECT_ID = int(os.getenv("DEVX_VIKUNJA_PROJECT_ID", "6")) +VIKUNJA_PROJECT_ID = _get_int("vikunja_project_id", "DEVX_VIKUNJA_PROJECT_ID", 6) # HTTP client defaults DEFAULT_TIMEOUT = 30 diff --git a/src/devx/make/devx.mak b/src/devx/make/devx.mak index 35f3f49..f3b4e23 100644 --- a/src/devx/make/devx.mak +++ b/src/devx/make/devx.mak @@ -2,15 +2,16 @@ # # This fragment provides common targets for Vikunja task management, # PR creation, and pushing. It is designed to be included from a -# project's Makefile after project-specific variables are set. +# project's Makefile. +# +# Project config (task prefix, Vikunja project ID, repo owner, repo name) +# is read from [tool.devx] in pyproject.toml by devx.config — no +# Makefile variables needed. # # Usage in your Makefile: # -# # Set project-specific variables -# DEVX_VIKUNJA_PROJECT_ID := 3 -# DEVX_REPO_OWNER := oblachno -# DEVX_REPO_NAME := infra -# DEVX_PYTHON := python3 # or $(BIN)/python, etc. +# # Set DEVX_PYTHON if you need a specific interpreter +# DEVX_PYTHON := $(BIN)/python # # # Include the devx fragment (silent if devx not installed yet) # DEVX_MAK := $(shell $(DEVX_PYTHON) -c \ @@ -18,39 +19,34 @@ # 2>/dev/null) # -include $(DEVX_MAK) # -# The fragment uses ?= for all variables so projects can override them -# before the include. If devx is not installed, the -include silently -# skips and the targets are simply unavailable (run 'make setup' first). +# If devx is not installed, the -include silently skips and the targets +# are simply unavailable (run 'make setup' first). # # Variables: -# DEVX_VIKUNJA_PROJECT_ID — Vikunja project ID (default: 1) -# DEVX_REPO_OWNER — Gitea repository owner (default: empty) -# DEVX_REPO_NAME — Gitea repository name (default: empty) -# DEVX_PYTHON — Python executable (default: python3) -# DEVX_PR_BASE — PR base branch (default: master) +# DEVX_PYTHON — Python executable (default: python3) +# DEVX_PR_BASE — PR base branch (default: master) -DEVX_VIKUNJA_PROJECT_ID ?= 1 -DEVX_REPO_OWNER ?= -DEVX_REPO_NAME ?= DEVX_PYTHON ?= python3 DEVX_PR_BASE ?= master -.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr +.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config -# Create a Vikunja task in the configured project +# Create a Vikunja task (project ID read from [tool.devx] in pyproject.toml) devx-create-task: - @$(DEVX_PYTHON) -m devx.tools.create_task --project-id $(DEVX_VIKUNJA_PROJECT_ID) + @$(DEVX_PYTHON) -m devx.tools.create_task # Create a PR with title auto-derived from the Vikunja task +# (owner/repo read from [tool.devx] in pyproject.toml) devx-create-pr: - @$(DEVX_PYTHON) -m devx.tools.create_pr \ - --owner $(DEVX_REPO_OWNER) \ - --repo $(DEVX_REPO_NAME) \ - --base $(DEVX_PR_BASE) + @$(DEVX_PYTHON) -m devx.tools.create_pr --base $(DEVX_PR_BASE) # Push current branch to origin devx-push: @git push -u origin HEAD +# Validate devx configuration in pyproject.toml +devx-check-config: + @$(DEVX_PYTHON) -m devx.tools.check_config + # Push and create PR in one step devx-push-with-pr: devx-push devx-create-pr diff --git a/src/devx/tools/check_config.py b/src/devx/tools/check_config.py new file mode 100644 index 0000000..ce91d5b --- /dev/null +++ b/src/devx/tools/check_config.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Validate devx configuration consistency in pyproject.toml. + +Checks: +1. [tool.devx] section exists with required keys (task_prefix, vikunja_project_id, repo_owner, repo_name) +2. devx version is consistent across all extras that mention it + +Usage:: + + python3 -m devx.tools.check_config +""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + +import click + +from devx.i18n import _ + + +@click.command() +def cli() -> None: + """Validate devx configuration in pyproject.toml.""" + path = Path("pyproject.toml") + if not path.exists(): + click.echo(_("pyproject.toml not found in current directory.")) + sys.exit(1) + + with open(path, "rb") as f: # noqa: PTH123 + data = tomllib.load(f) + + errors: list[str] = [] + + # Check [tool.devx] section + devx_cfg = data.get("tool", {}).get("devx", {}) + required_keys = {"task_prefix", "vikunja_project_id", "repo_owner", "repo_name"} + missing = required_keys - set(devx_cfg.keys()) + if missing: + errors.append( + _("[tool.devx] missing required keys: {keys}", keys=", ".join(sorted(missing))), + ) + + # Check devx version consistency across extras + optional_deps = data.get("project", {}).get("optional-dependencies", {}) + devx_versions: dict[str, str] = {} + for extra_name, deps in optional_deps.items(): + for dep in deps: + # Match "devx>=X.Y.Z", "devx==X.Y.Z", "devx>X.Y.Z", etc. + m = re.search(r"\bdevx\s*(>=|==|>|<=|<|~=)\s*([\d.]+)", dep) + if m: + devx_versions[extra_name] = m.group(2) + + if devx_versions: + unique_versions = set(devx_versions.values()) + if len(unique_versions) > 1: + detail = ", ".join(f"{extra}={v}" for extra, v in sorted(devx_versions.items())) + errors.append( + _("devx version mismatch across extras: {detail}", detail=detail), + ) + + if errors: + for err in errors: + click.echo(f"ERROR: {err}", err=True) + sys.exit(1) + + click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent.")) + + +if __name__ == "__main__": # pragma: no cover + cli() # pragma: no cover diff --git a/src/devx/translations.json b/src/devx/translations.json index b138298..0ee80af 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -439,6 +439,14 @@ "ru": "Настройка параметров репозитория...", "zh": "正在配置仓库设置..." }, + "Configuration OK: [tool.devx] present, devx versions consistent.": { + "bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.", + "de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.", + "en": "Configuration OK: [tool.devx] present, devx versions consistent.", + "pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.", + "ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.", + "zh": "配置正常: [tool.devx] 已存在, devx 版本一致。" + }, "Could not extract conventional commit message from PR commits.": { "bg": "Could not extract conventional commit message from PR commits.", "de": "Could not extract conventional commit message from PR commits.", @@ -487,6 +495,14 @@ "ru": "Created release commit.", "zh": "Created release commit." }, + "devx version mismatch across extras: {detail}": { + "bg": "несъответствие на версията на devx между extras: {detail}", + "de": "devx-Versionskonflikt zwischen Extras: {detail}", + "en": "devx version mismatch across extras: {detail}", + "pl": "niezgodność wersji devx między extras: {detail}", + "ru": "несоответствие версии devx между extras: {detail}", + "zh": "devx 版本在 extras 之间不一致: {detail}" + }, "Docker daemon already running": { "bg": "Докер демонът вече работи", "de": "Docker-Daemon läuft bereits", @@ -1303,6 +1319,14 @@ "ru": "Wiki verification failed — {failures} page(s) empty or mismatched", "zh": "Wiki verification failed — {failures} page(s) empty or mismatched" }, + "[tool.devx] missing required keys: {keys}": { + "bg": "[tool.devx] липсват задължителни ключове: {keys}", + "de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}", + "en": "[tool.devx] missing required keys: {keys}", + "pl": "[tool.devx] brak wymaganych kluczy: {keys}", + "ru": "[tool.devx] отсутствуют обязательные ключи: {keys}", + "zh": "[tool.devx] 缺少必需的键: {keys}" + }, "[dry-run] Would commit: release: v{version}": { "bg": "[dry-run] Would commit: release: v{version}", "de": "[dry-run] Would commit: release: v{version}", @@ -1455,6 +1479,14 @@ "ru": "ожидает", "zh": "待处理" }, + "pyproject.toml not found in current directory.": { + "bg": "pyproject.toml не е намерен в текущата директория.", + "de": "pyproject.toml im aktuellen Verzeichnis nicht gefunden.", + "en": "pyproject.toml not found in current directory.", + "pl": "nie znaleziono pyproject.toml w bieżącym katalogu.", + "ru": "pyproject.toml не найден в текущей директории.", + "zh": "在当前目录中未找到 pyproject.toml。" + }, "unknown": { "bg": "неизвестен", "de": "unbekannt", diff --git a/tests/unit/test_check_config.py b/tests/unit/test_check_config.py new file mode 100644 index 0000000..6e3c2f0 --- /dev/null +++ b/tests/unit/test_check_config.py @@ -0,0 +1,90 @@ +"""Unit tests for devx.tools.check_config.""" + +from pathlib import Path + +from click.testing import CliRunner + +from devx.tools.check_config import cli + + +class TestCheckConfig: + def test_valid_config(self, tmp_path: Path) -> None: + """A valid [tool.devx] section with consistent versions passes.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs: + Path(fs, "pyproject.toml").write_text( + '[project]\nname = "test"\n' + '[project.optional-dependencies]\nci = ["devx>=0.15.0"]\ndev = ["devx>=0.15.0"]\n' + '[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n' + ) + result = runner.invoke(cli) + assert result.exit_code == 0 + assert "Configuration OK" in result.output + + def test_missing_tool_devx_section(self, tmp_path: Path) -> None: + """Missing [tool.devx] section fails with error.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs: + Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n') + result = runner.invoke(cli) + assert result.exit_code == 1 + assert "missing required keys" in result.output + + def test_partial_tool_devx_section(self, tmp_path: Path) -> None: + """Partial [tool.devx] section fails with missing keys.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs: + Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\ntask_prefix = "TEST"\n') + result = runner.invoke(cli) + assert result.exit_code == 1 + assert "missing required keys" in result.output + assert "vikunja_project_id" in result.output + assert "repo_owner" in result.output + assert "repo_name" in result.output + + def test_version_mismatch(self, tmp_path: Path) -> None: + """Version mismatch across extras fails.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs: + Path(fs, "pyproject.toml").write_text( + '[project]\nname = "test"\n' + "[project.optional-dependencies]\n" + 'ci = ["devx>=0.15.0"]\n' + 'dev = ["devx>=0.14.2"]\n' + '[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n' + ) + result = runner.invoke(cli) + assert result.exit_code == 1 + assert "version mismatch" in result.output + + def test_no_pyproject_file(self, tmp_path: Path) -> None: + """Missing pyproject.toml fails.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + result = runner.invoke(cli) + assert result.exit_code == 1 + assert "not found" in result.output + + def test_no_extras_passes(self, tmp_path: Path) -> None: + """No optional-dependencies with devx is fine (no versions to compare).""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs: + Path(fs, "pyproject.toml").write_text( + '[project]\nname = "test"\n' + '[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n' + ) + result = runner.invoke(cli) + assert result.exit_code == 0 + assert "Configuration OK" in result.output + + def test_single_extra_passes(self, tmp_path: Path) -> None: + """Single extra with devx version is fine (no mismatch possible).""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs: + Path(fs, "pyproject.toml").write_text( + '[project]\nname = "test"\n' + '[project.optional-dependencies]\nci = ["devx>=0.15.0", "pytest"]\n' + '[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n' + ) + result = runner.invoke(cli) + assert result.exit_code == 0 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 1070b12..6237028 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,12 +1,14 @@ """Unit tests for config module constants.""" +import importlib +from pathlib import Path + from devx.config import ( CONVENTIONAL_RE, DEFAULT_PER_PAGE, DEFAULT_TIMEOUT, GITEA_API_URL, MAX_RETRIES, - REPO_OWNER, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES, TASK_ID_RE, @@ -20,25 +22,10 @@ class TestConfigConstants: assert "api/v1" in GITEA_API_URL assert "api/v1" in VIKUNJA_API_URL - def test_project_ids(self, monkeypatch: object) -> None: - """VIKUNJA_PROJECT_ID defaults to 6 when DEVX_VIKUNJA_PROJECT_ID is not set.""" - monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False) - import importlib - - import devx.config as cfg - - importlib.reload(cfg) - assert cfg.VIKUNJA_PROJECT_ID == 6 - # Restore module state - importlib.reload(cfg) - def test_timeouts(self) -> None: assert DEFAULT_TIMEOUT == 30 assert DEFAULT_PER_PAGE == 50 - def test_owner(self) -> None: - assert REPO_OWNER == "" - def test_task_prefix(self) -> None: assert TASK_PREFIX == "DEVX" @@ -64,28 +51,100 @@ class TestConfigConstants: assert 503 in RETRY_STATUS_CODES assert 504 in RETRY_STATUS_CODES - def test_env_var_override(self, monkeypatch: object) -> None: - """Test that env vars override defaults at import time.""" - # We can't easily re-import the module, but we can verify - # the constants respect env vars by checking the module source. + +class TestPyprojectReading: + """Test that config.py reads [tool.devx] from pyproject.toml.""" + + def test_pyproject_provides_values(self) -> None: + """When pyproject.toml has [tool.devx], values are read from it.""" import devx.config as cfg - assert cfg.GITEA_API_URL # always non-empty - assert cfg.VIKUNJA_API_URL # always non-empty + # devx's own pyproject.toml has task_prefix=DEVX, vikunja_project_id=8 + assert cfg.TASK_PREFIX == "DEVX" + assert cfg.VIKUNJA_PROJECT_ID == 8 + assert cfg.REPO_OWNER == "oblachno-oss" + + def test_env_overrides_pyproject(self, monkeypatch: object) -> None: + """Env vars take priority over pyproject.toml.""" + monkeypatch.setenv("DEVX_TASK_PREFIX", "CUSTOM") + import devx.config as cfg + + importlib.reload(cfg) + assert cfg.TASK_PREFIX == "CUSTOM" + assert cfg.TASK_ID_RE.search("CUSTOM-42") + monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False) + importlib.reload(cfg) + + def test_no_pyproject_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None: + """When no pyproject.toml exists, defaults are used.""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False) + monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False) + monkeypatch.delenv("DEVX_REPO_OWNER", raising=False) + import devx.config as cfg + + importlib.reload(cfg) + assert cfg.TASK_PREFIX == "DEVX" + assert cfg.VIKUNJA_PROJECT_ID == 6 + assert cfg.REPO_OWNER == "" + importlib.reload(cfg) + + def test_invalid_toml_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None: + """When pyproject.toml is invalid TOML, defaults are used.""" + (tmp_path / "pyproject.toml").write_text("invalid toml {{{") + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False) + import devx.config as cfg + + importlib.reload(cfg) + assert cfg.TASK_PREFIX == "DEVX" + importlib.reload(cfg) + + def test_no_devx_section_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None: + """When pyproject.toml has no [tool.devx], defaults are used.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n') + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False) + monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False) + import devx.config as cfg + + importlib.reload(cfg) + assert cfg.TASK_PREFIX == "DEVX" + assert cfg.VIKUNJA_PROJECT_ID == 6 + importlib.reload(cfg) + + def test_tool_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None: + """When [tool] is not a dict, defaults are used.""" + (tmp_path / "pyproject.toml").write_text('tool = "not a dict"\n') + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False) + import devx.config as cfg + + importlib.reload(cfg) + assert cfg.TASK_PREFIX == "DEVX" + importlib.reload(cfg) + + def test_devx_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None: + """When [tool.devx] is not a dict, defaults are used.""" + (tmp_path / "pyproject.toml").write_text('[tool]\ndevx = "not a dict"\n') + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False) + import devx.config as cfg + + importlib.reload(cfg) + assert cfg.TASK_PREFIX == "DEVX" + importlib.reload(cfg) class TestTaskPrefixOverride: def test_task_prefix_from_env(self, monkeypatch: object) -> None: """Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var.""" monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA") - import importlib - import devx.config as cfg importlib.reload(cfg) assert cfg.TASK_PREFIX == "INFRA" assert cfg.TASK_ID_RE.search("INFRA-42") assert not cfg.TASK_ID_RE.search("DEVX-42") - # Restore monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False) importlib.reload(cfg)