DEVX-61: feat: single-source-of-truth config via [tool.devx] in pyproject.toml
This commit is contained in:
@@ -27,7 +27,7 @@ jobs:
|
|||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .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
|
- name: Documentation coverage check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "s
|
|||||||
# Rule priority (first match wins):
|
# Rule priority (first match wins):
|
||||||
# 1. user_facing_overrides (safety — highest priority)
|
# 1. user_facing_overrides (safety — highest priority)
|
||||||
# 2. infrastructure_overrides (explicit per-file)
|
# 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)
|
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
||||||
# 4. Default: user-facing (safe)
|
# 4. Default: user-facing (safe)
|
||||||
[tool.devx.classify]
|
[tool.devx.classify]
|
||||||
|
|||||||
+65
-7
@@ -1,28 +1,86 @@
|
|||||||
"""Shared configuration constants for devx scripts and API clients.
|
"""Shared configuration constants for devx scripts and API clients.
|
||||||
|
|
||||||
All defaults can be overridden via environment variables with the ``DEVX_``
|
Configuration is read from two sources, in priority order:
|
||||||
prefix. Projects consuming devx can set these in their ``.env`` files.
|
|
||||||
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
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
|
# 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")
|
GITEA_API_URL = _get("gitea_api_url", "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")
|
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.
|
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||||
# No default: prevents silent 404s when the wrong owner is used.
|
# 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 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+")
|
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
|
||||||
|
|
||||||
# Vikunja project ID — each project uses a different Vikunja project
|
# 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
|
# HTTP client defaults
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|||||||
+20
-24
@@ -2,15 +2,16 @@
|
|||||||
#
|
#
|
||||||
# This fragment provides common targets for Vikunja task management,
|
# This fragment provides common targets for Vikunja task management,
|
||||||
# PR creation, and pushing. It is designed to be included from a
|
# 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:
|
# Usage in your Makefile:
|
||||||
#
|
#
|
||||||
# # Set project-specific variables
|
# # Set DEVX_PYTHON if you need a specific interpreter
|
||||||
# DEVX_VIKUNJA_PROJECT_ID := 3
|
# DEVX_PYTHON := $(BIN)/python
|
||||||
# DEVX_REPO_OWNER := oblachno
|
|
||||||
# DEVX_REPO_NAME := infra
|
|
||||||
# DEVX_PYTHON := python3 # or $(BIN)/python, etc.
|
|
||||||
#
|
#
|
||||||
# # Include the devx fragment (silent if devx not installed yet)
|
# # Include the devx fragment (silent if devx not installed yet)
|
||||||
# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \
|
# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \
|
||||||
@@ -18,39 +19,34 @@
|
|||||||
# 2>/dev/null)
|
# 2>/dev/null)
|
||||||
# -include $(DEVX_MAK)
|
# -include $(DEVX_MAK)
|
||||||
#
|
#
|
||||||
# The fragment uses ?= for all variables so projects can override them
|
# If devx is not installed, the -include silently skips and the targets
|
||||||
# before the include. If devx is not installed, the -include silently
|
# are simply unavailable (run 'make setup' first).
|
||||||
# skips and the targets are simply unavailable (run 'make setup' first).
|
|
||||||
#
|
#
|
||||||
# Variables:
|
# Variables:
|
||||||
# DEVX_VIKUNJA_PROJECT_ID — Vikunja project ID (default: 1)
|
# DEVX_PYTHON — Python executable (default: python3)
|
||||||
# DEVX_REPO_OWNER — Gitea repository owner (default: empty)
|
# DEVX_PR_BASE — PR base branch (default: master)
|
||||||
# DEVX_REPO_NAME — Gitea repository name (default: empty)
|
|
||||||
# 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_PYTHON ?= python3
|
||||||
DEVX_PR_BASE ?= master
|
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-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
|
# Create a PR with title auto-derived from the Vikunja task
|
||||||
|
# (owner/repo read from [tool.devx] in pyproject.toml)
|
||||||
devx-create-pr:
|
devx-create-pr:
|
||||||
@$(DEVX_PYTHON) -m devx.tools.create_pr \
|
@$(DEVX_PYTHON) -m devx.tools.create_pr --base $(DEVX_PR_BASE)
|
||||||
--owner $(DEVX_REPO_OWNER) \
|
|
||||||
--repo $(DEVX_REPO_NAME) \
|
|
||||||
--base $(DEVX_PR_BASE)
|
|
||||||
|
|
||||||
# Push current branch to origin
|
# Push current branch to origin
|
||||||
devx-push:
|
devx-push:
|
||||||
@git push -u origin HEAD
|
@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
|
# Push and create PR in one step
|
||||||
devx-push-with-pr: devx-push devx-create-pr
|
devx-push-with-pr: devx-push devx-create-pr
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -439,6 +439,14 @@
|
|||||||
"ru": "Настройка параметров репозитория...",
|
"ru": "Настройка параметров репозитория...",
|
||||||
"zh": "正在配置仓库设置..."
|
"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.": {
|
"Could not extract conventional commit message from PR commits.": {
|
||||||
"bg": "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.",
|
"de": "Could not extract conventional commit message from PR commits.",
|
||||||
@@ -487,6 +495,14 @@
|
|||||||
"ru": "Created release commit.",
|
"ru": "Created release commit.",
|
||||||
"zh": "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": {
|
"Docker daemon already running": {
|
||||||
"bg": "Докер демонът вече работи",
|
"bg": "Докер демонът вече работи",
|
||||||
"de": "Docker-Daemon läuft bereits",
|
"de": "Docker-Daemon läuft bereits",
|
||||||
@@ -1303,6 +1319,14 @@
|
|||||||
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||||
"zh": "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}": {
|
"[dry-run] Would commit: release: v{version}": {
|
||||||
"bg": "[dry-run] Would commit: release: v{version}",
|
"bg": "[dry-run] Would commit: release: v{version}",
|
||||||
"de": "[dry-run] Would commit: release: v{version}",
|
"de": "[dry-run] Would commit: release: v{version}",
|
||||||
@@ -1455,6 +1479,14 @@
|
|||||||
"ru": "ожидает",
|
"ru": "ожидает",
|
||||||
"zh": "待处理"
|
"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": {
|
"unknown": {
|
||||||
"bg": "неизвестен",
|
"bg": "неизвестен",
|
||||||
"de": "unbekannt",
|
"de": "unbekannt",
|
||||||
|
|||||||
@@ -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
|
||||||
+84
-25
@@ -1,12 +1,14 @@
|
|||||||
"""Unit tests for config module constants."""
|
"""Unit tests for config module constants."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from devx.config import (
|
from devx.config import (
|
||||||
CONVENTIONAL_RE,
|
CONVENTIONAL_RE,
|
||||||
DEFAULT_PER_PAGE,
|
DEFAULT_PER_PAGE,
|
||||||
DEFAULT_TIMEOUT,
|
DEFAULT_TIMEOUT,
|
||||||
GITEA_API_URL,
|
GITEA_API_URL,
|
||||||
MAX_RETRIES,
|
MAX_RETRIES,
|
||||||
REPO_OWNER,
|
|
||||||
RETRY_BACKOFF_BASE,
|
RETRY_BACKOFF_BASE,
|
||||||
RETRY_STATUS_CODES,
|
RETRY_STATUS_CODES,
|
||||||
TASK_ID_RE,
|
TASK_ID_RE,
|
||||||
@@ -20,25 +22,10 @@ class TestConfigConstants:
|
|||||||
assert "api/v1" in GITEA_API_URL
|
assert "api/v1" in GITEA_API_URL
|
||||||
assert "api/v1" in VIKUNJA_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:
|
def test_timeouts(self) -> None:
|
||||||
assert DEFAULT_TIMEOUT == 30
|
assert DEFAULT_TIMEOUT == 30
|
||||||
assert DEFAULT_PER_PAGE == 50
|
assert DEFAULT_PER_PAGE == 50
|
||||||
|
|
||||||
def test_owner(self) -> None:
|
|
||||||
assert REPO_OWNER == ""
|
|
||||||
|
|
||||||
def test_task_prefix(self) -> None:
|
def test_task_prefix(self) -> None:
|
||||||
assert TASK_PREFIX == "DEVX"
|
assert TASK_PREFIX == "DEVX"
|
||||||
|
|
||||||
@@ -64,28 +51,100 @@ class TestConfigConstants:
|
|||||||
assert 503 in RETRY_STATUS_CODES
|
assert 503 in RETRY_STATUS_CODES
|
||||||
assert 504 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."""
|
class TestPyprojectReading:
|
||||||
# We can't easily re-import the module, but we can verify
|
"""Test that config.py reads [tool.devx] from pyproject.toml."""
|
||||||
# the constants respect env vars by checking the module source.
|
|
||||||
|
def test_pyproject_provides_values(self) -> None:
|
||||||
|
"""When pyproject.toml has [tool.devx], values are read from it."""
|
||||||
import devx.config as cfg
|
import devx.config as cfg
|
||||||
|
|
||||||
assert cfg.GITEA_API_URL # always non-empty
|
# devx's own pyproject.toml has task_prefix=DEVX, vikunja_project_id=8
|
||||||
assert cfg.VIKUNJA_API_URL # always non-empty
|
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:
|
class TestTaskPrefixOverride:
|
||||||
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
|
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
|
||||||
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
|
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
|
||||||
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
|
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
|
||||||
import importlib
|
|
||||||
|
|
||||||
import devx.config as cfg
|
import devx.config as cfg
|
||||||
|
|
||||||
importlib.reload(cfg)
|
importlib.reload(cfg)
|
||||||
assert cfg.TASK_PREFIX == "INFRA"
|
assert cfg.TASK_PREFIX == "INFRA"
|
||||||
assert cfg.TASK_ID_RE.search("INFRA-42")
|
assert cfg.TASK_ID_RE.search("INFRA-42")
|
||||||
assert not cfg.TASK_ID_RE.search("DEVX-42")
|
assert not cfg.TASK_ID_RE.search("DEVX-42")
|
||||||
# Restore
|
|
||||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
importlib.reload(cfg)
|
importlib.reload(cfg)
|
||||||
|
|||||||
Reference in New Issue
Block a user