Compare commits

...
11 Commits
Author SHA1 Message Date
devx-ci-bot f21b01dce2 release: v0.32.1 [skip ci] 2026-07-01 23:37:11 +00:00
emil ff80745eea DEVX-110: fix: add missing i18n translations for new tools
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Successful in 14s
Build Images / detect-type (push) Successful in 56s
Post-merge / release (push) Successful in 1m2s
Post-merge / configure-repo (push) Successful in 28s
Post-merge / sync-wiki (push) Successful in 1m11s
Post-merge / badges (push) Successful in 1m19s
Post-merge / vikunja (push) Successful in 1m15s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 4m22s
Build Images / cleanup (push) Successful in 2m50s
2026-07-01 23:35:56 +00:00
gitea-actions-bot 319807f41c chore: update badge URLs to commit ce9bf024 [skip ci] 2026-07-01 23:01:36 +00:00
devx-ci-bot e652d3bb75 release: v0.32.0 [skip ci] 2026-07-01 23:01:19 +00:00
emil d59de06652 DEVX-110: feat: extract docker-login, tofu-ops, check-deps, install-tofu to Python tools
Build Images / cleanup (push) Successful in 3m20s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Build Images / detect-type (push) Successful in 41s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 43s
Post-merge / sync-wiki (push) Successful in 47s
Post-merge / badges (push) Successful in 51s
Post-merge / publish (push) Successful in 22s
Build Images / build-and-push (push) Successful in 3m26s
2026-07-01 23:00:28 +00:00
gitea-actions-bot ae37a8e3e4 chore: update badge URLs to commit de35661c [skip ci] 2026-07-01 22:35:46 +00:00
devx-ci-bot c63e85923a release: v0.31.0 [skip ci] 2026-07-01 22:35:34 +00:00
emil 77c1af8ed3 DEVX-110: feat: centralize venv management in devx.mak
Post-merge / detect-type (push) Successful in 9s
Build Images / detect-type (push) Failing after 13s
Build Images / build-and-push (push) Has been skipped
Post-merge / validate-commit-msg (push) Successful in 10s
Build Images / cleanup (push) Has been skipped
Post-merge / vikunja (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 18s
Post-merge / sync-wiki (push) Successful in 29s
Post-merge / release (push) Successful in 32s
Post-merge / badges (push) Successful in 39s
Post-merge / publish (push) Successful in 17s
2026-07-01 22:34:49 +00:00
gitea-actions-bot a48fb46c52 chore: update badge URLs to commit 1755d7a2 [skip ci] 2026-07-01 20:54:47 +00:00
emil 2392a13afc DEVX-109: docs: add container-level fix verification and verified state modification rules
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 21s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 45s
Post-merge / badges (push) Successful in 46s
2026-07-01 20:53:32 +00:00
gitea-actions-bot 32315b1d5d chore: update badge URLs to commit 753a5f9e [skip ci] 2026-07-01 14:05:12 +00:00
25 changed files with 1590 additions and 81 deletions
+8 -7
View File
@@ -34,12 +34,13 @@ permissions:
You are a CI failure investigator for the devx repo.
## Working Directory
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first:
```bash
cd /home/emo/dev/ideas/oblachno/devx
```
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## CI Job Dependency Graph
@@ -131,8 +132,8 @@ make build-images-dry-run # dry-run build
For doc coverage issues:
```bash
python3 -m devx.ci.doc_coverage --fail-on-missing
python3 -m devx.ci.lint_docs --root .
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
.venv/bin/python -m devx.ci.lint_docs --root .
```
### Step 5: Check for related Vikunja tasks
+7 -3
View File
@@ -30,10 +30,14 @@ permissions:
You are a dependency upgrade specialist for the devx repo.
## Working Directory
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Dependency Reference Locations
- **Primary**: `pyproject.toml``[project] dependencies` and `[project.optional-dependencies]`
@@ -81,8 +85,8 @@ If adding a new dependency without a comment, `check_pyproject_deps` will fail.
pip install -e .[dev] # reinstall with new deps
make pytest-cov # 100% coverage required
make lint-all # ruff + pyright + bandit + actionlint + hadolint
python3 -m devx.tools.check_pyproject_deps # verify dep docs
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
.venv/bin/python -m devx.tools.check_pyproject_deps # verify dep docs
.venv/bin/python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
```
All must pass. If `check_pyproject_deps` fails, add the missing comment.
+8 -4
View File
@@ -25,10 +25,14 @@ permissions:
You are a documentation sync specialist for the devx repo.
## Working Directory
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Documentation Structure
```
@@ -56,7 +60,7 @@ docs/
### Step 1: Check documentation coverage
```bash
python3 -m devx.ci.doc_coverage --fail-on-missing
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
```
If this fails, it lists undocumented items:
- **CLI commands**: any `@click.command()` or `@click.group()` without a docs entry
@@ -69,7 +73,7 @@ for modules.
### Step 2: Lint documentation structure
```bash
python3 -m devx.ci.lint_docs --root .
.venv/bin/python -m devx.ci.lint_docs --root .
```
Common issues:
- **Broken internal links**: `[text](page.md)` where `page.md` doesn't exist
@@ -88,7 +92,7 @@ exist. If a script/module was renamed or deleted, update all doc references.
### Step 4: Verify wiki sync (if investigating a sync failure)
```bash
python3 -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
.venv/bin/python -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
```
Common sync failures:
- **Content mismatch**: wiki page content doesn't match local docs — usually means a previous sync was interrupted
+6 -2
View File
@@ -34,10 +34,14 @@ permissions:
You are a Docker image build specialist for the devx repo.
## Working Directory
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Image Architecture
Three tier images built sequentially (each FROM the previous):
@@ -87,7 +91,7 @@ This builds all 3 tiers sequentially and pushes to the Gitea registry.
If only one tier needs rebuilding:
```bash
python3 -m devx.tools.build_image \
.venv/bin/python -m devx.tools.build_image \
--dockerfile docker/ci-quality/Dockerfile \
--name oblachno-oss/runner-images/ci-quality \
--tag latest \
+5 -1
View File
@@ -27,10 +27,14 @@ permissions:
You are a Gitea Actions workflow validator for the devx repo.
## Working Directory
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Key Files
- `.gitea/workflows/ci.yml` — PR pipeline (quality, detect-changes, release-dry-run, pr-review, auto-merge)
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
- name: Documentation coverage check
env:
PYTHONPATH: src
+38
View File
@@ -49,6 +49,44 @@ repos:
pass_filenames: false
stages: [pre-commit]
- id: checkmake
name: checkmake Makefile linter
entry: make checkmake
language: system
files: (Makefile|\.mak)$
pass_filenames: false
stages: [pre-commit]
- id: check-test-speed
name: unit test speed check
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: check-translations
name: translation completeness check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.check_translations
language: system
files: ^src/devx/translations\.json$
pass_filenames: false
stages: [pre-commit]
- id: doc-coverage
name: documentation coverage check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
language: system
pass_filenames: false
stages: [pre-commit]
- id: lint-docs
name: documentation lint check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
language: system
pass_filenames: false
stages: [pre-commit]
- id: pytest-cov
name: pytest with 100% coverage
entry: make pytest-cov
+41
View File
@@ -1,5 +1,19 @@
# AGENTS.md — Project Conventions for devx
## Virtual Environment
All Python tools, tests, and scripts run inside a standard `.venv` directory.
Activate it before running any non-`make` command:
```bash
source activate.sh # bash/zsh
source activate.fish # fish
source activate.zsh # zsh
```
If `.venv` doesn't exist, run `make setup` first. The `make` targets handle
venv activation automatically — always prefer `make <target>` over raw commands.
## Build & Test Commands
```bash
@@ -522,6 +536,33 @@ create-task: devx-create-task
- Secrets are passed via environment variables, never on the command line
- All user-facing strings wrapped in `_()` for i18n
### Container-Level Fix Verification (Mandatory)
**Rule:** Before pushing any fix that modifies container state (CA certs,
config files, installed packages, daemon restarts), reproduce the exact
sequence locally with the actual Docker image. Do not push to CI as the
first test.
This is a hard rule, not a suggestion. CI cycles take 20+ minutes and
ephemeral staging VMs are destroyed after each run, making interactive
debugging impossible. A local reproduction takes 30 seconds and catches
silent failures immediately.
**Procedure:**
1. `docker pull <actual_image>`
2. `docker run -d --name <test> ...` and wait for it to start
3. Run the exact commands from the Ansible task or script
4. Verify the state change took effect
5. Clean up: `docker rm -f <test>`
### Verified State Modification (Mandatory)
Ansible tasks that modify container state with `changed_when: false`
MUST include a post-task verification step that confirms the state
change took effect. `changed_when: false` suppresses both change
detection AND failure visibility — a task can silently do nothing and
report `ok`.
## Subagent Delegation Policy
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
+18
View File
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file.
## [0.32.1] - 2026-07-01
### Bug Fixes
- Add missing i18n translations for new tools
## [0.32.0] - 2026-07-01
### Features
- Extract docker-login, tofu-ops, check-deps, install-tofu to Python tools
## [0.31.0] - 2026-07-01
### Features
- Centralize venv management in devx.mak
## [0.30.0] - 2026-07-01
### Features
+38 -34
View File
@@ -6,6 +6,36 @@ BIN := $(VENV)/bin
all: setup
# --- devx.mak integration ----------------------------------------------------
# Include shared targets from the devx package itself (venv management,
# workflow-lint, notify-failure, checkmake, lint targets, quality checks, etc.)
# Since devx IS the package, we can include its own devx.mak.
DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_LINT_PATHS := src/ tests/
DEVX_COV_PKG := src/devx
DEVX_TEST_PATHS := tests/
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
# Fallback: when the venv doesn't exist yet (chicken-and-egg), use the
# source tree copy directly. devx IS the package, so src/devx/make/devx.mak
# is always available in this repo.
ifeq ($(strip $(DEVX_MAK)),)
DEVX_MAK := $(CURDIR)/src/devx/make/devx.mak
endif
-include $(DEVX_MAK)
# venv, .env, and activate-scripts are provided by devx.mak
# (devx-venv, devx-env, devx-activate-scripts, $(DEVX_VENV)/bin/activate rule)
# Aliases for convenience and backward compatibility:
.PHONY: venv activate-scripts
venv: devx-venv
.env: devx-env
activate-scripts: devx-activate-scripts
# Full setup for local development
setup: $(VENV)/bin/activate .env activate-scripts install-tools
@$(BIN)/pip install -e '.[dev]' 2>/dev/null; \
@@ -35,22 +65,9 @@ setup-release: $(VENV)/bin/activate .env
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
.env:
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
$(VENV)/bin/activate:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(PYTHON) -m venv $(VENV)
$(BIN)/pip install --upgrade pip setuptools wheel
activate-scripts: $(VENV)/bin/activate
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
install-hooks:
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
@@ -60,23 +77,13 @@ install-tools: $(VENV)/bin/activate
@$(BIN)/pip install -e '.' 2>/dev/null; \
$(BIN)/python -m devx.tools.install_tools
# --- devx.mak integration ----------------------------------------------------
# Include shared targets from the devx package itself (workflow-lint,
# notify-failure, checkmake, lint targets, quality checks, etc.)
# Since devx IS the package, we can include its own devx.mak.
DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_LINT_PATHS := src/ tests/
DEVX_COV_PKG := src/devx
DEVX_TEST_PATHS := tests/
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
-include $(DEVX_MAK)
# Aliases — project-specific names map to devx.mak targets
.PHONY: lint-ruff lint-format typecheck lint-bandit lint-deps lint
.PHONY: workflow-lint workflow-dryrun workflow-dryrun-safe workflow-check
.PHONY: notify-failure checkmake check-mutable-globals check-dep-docs
.PHONY: check-test-speed check-test-coverage check-docs
.PHONY: create-task create-pr push-with-pr git-push rebase pr-rebase
.PHONY: lint-all lint-dockerfiles
lint-ruff: devx-lint-ruff
lint-format: devx-lint-format
typecheck: devx-typecheck
@@ -108,10 +115,7 @@ lint-all: lint workflow-lint lint-dockerfiles
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
lint-dockerfiles:
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
@if ! command -v hadolint >/dev/null 2>&1; then \
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
exit 1; \
fi
@command -v hadolint >/dev/null 2>&1 || { echo "hadolint not found" >&2; exit 1; }
@find docker -name 'Dockerfile*' -exec hadolint {} +
@echo "[lint-dockerfiles] All Dockerfiles passed."
+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)
[![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/d23c6b86736fbbc393345f862d575f23138dbc09/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/python.svg)](https://www.python.org/downloads/)
## 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)
[![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/d23c6b86736fbbc393345f862d575f23138dbc09/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d23c6b86736fbbc393345f862d575f23138dbc09/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ce9bf02483584e88751e8018a445efed3bd91f51/python.svg)](https://www.python.org/downloads/)
## Overview
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.30.0"
__version__ = "0.32.1"
+44 -14
View File
@@ -56,17 +56,57 @@ DEVX_DOCKERFILE_PATHS ?= docker
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
# Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
$(DEVX_BIN)/pip
# ── Virtual environment management ────────────────────────────────────────────
#
# These targets provide a single, consistent venv setup across all
# devx-integrated projects (infra, grm, devx). Each project includes
# devx.mak and aliases its local targets to these.
#
# The venv is a standard .venv directory (no pyenv virtualenv dependency).
# pyenv can still be used to install Python 3.12+ but the venv itself
# is created with `python3 -m venv .venv`.
#
# Projects should set these variables BEFORE including devx.mak:
# DEVX_VENV — venv directory (default: .venv)
# DEVX_BIN — venv bin directory (default: $(DEVX_VENV)/bin)
# DEVX_PYTHON — Python executable (default: python3; should be $(DEVX_BIN)/python after setup)
#
# Common aliases in project Makefiles:
# PIP_INSTALL = $(DEVX_PIP_INSTALL)
# venv: devx-venv
# activate-scripts: devx-activate-scripts
# .env: devx-env
# Create .venv with Python version check (3.12+ required)
$(DEVX_VENV)/bin/activate:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
python3 -m venv $(DEVX_VENV)
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
# Alias: devx-venv creates the venv (delegates to the activate rule)
devx-venv: $(DEVX_VENV)/bin/activate
# Ensure a venv exists — in CI (no pyenv), creates .venv if missing.
# Locally, uses the existing .venv (created by `make setup` or `make devx-venv`).
devx-ensure-venv:
@if [ ! -f $(DEVX_BIN)/python ]; then \
echo "[ensure-venv] Creating $(DEVX_VENV) (no venv found)..."; \
python3 -m venv $(DEVX_VENV); \
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel; \
fi
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review devx-rebase devx-pr-rebase
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts devx-venv devx-ensure-venv
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
.PHONY: devx-clean devx-pre-push
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
@@ -165,12 +205,6 @@ devx-env:
echo "Created .env from .env.example — please edit it with your credentials."; \
fi
# Create Python venv with version check
devx-venv:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(DEVX_PYTHON) -m venv $(DEVX_VENV)
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
# Create activate scripts for shell/fish/zsh
devx-activate-scripts:
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@@ -266,7 +300,7 @@ devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit
# ── Testing ───────────────────────────────────────────────────────────────────
devx-test-unit:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --no-cov
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
devx-pytest-cov:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
@@ -341,12 +375,8 @@ devx-lint-dockerfiles:
# devx-setup-ci) — each project defines its own setup-ci target.
devx-setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
_U="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
pip install --no-cache-dir -e .$(if $(EXTRAS),[$(EXTRAS)],); \
echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
@/opt/venv/bin/python -m devx.tools.setup_image --venv $(DEVX_VENV) --extras "$(EXTRAS)" \
--gitea-host $(DEVX_GITEA_PYPI_HOST) --gitea-org $(DEVX_GITEA_PYPI_ORG)
# ── Docker image build / push / cleanup ───────────────────────────────────────
#
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Check that required development tools are present.
Verifies the availability of core tools (tofu, docker, checkmake, Python
3.12+ in the venv) and prints warnings or errors for missing ones.
Usage::
python3 -m devx.tools.check_deps
python3 -m devx.tools.check_deps --venv .venv
"""
from __future__ import annotations
import shutil
import subprocess # nosec B404
from pathlib import Path
import click
from devx.i18n import _
REQUIRED_TOOLS = ["tofu", "docker"]
OPTIONAL_TOOLS = ["checkmake"]
PYTHON_MIN_VERSION = (3, 12)
def _check_tool(name: str, *, optional: bool = False) -> bool:
"""Check if a tool is on PATH. Returns True if found."""
found = shutil.which(name) is not None
if found:
return True
level = "WARN" if optional else "ERROR"
click.echo(
_("{level}: {tool} not found.{hint}", level=level, tool=name, hint=""),
err=True,
)
return False
def _check_python_version(venv_bin: Path) -> None:
"""Check that the venv Python is >= 3.12."""
python_bin = venv_bin / "python"
if not python_bin.exists():
click.echo(
_("WARN: .venv not found. Run 'make setup-venv' to create it."),
err=True,
)
return
result = subprocess.run( # nosec B603
[str(python_bin), "--version"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
click.echo(_("WARN: Could not determine Python version in .venv."), err=True)
return
version_str = result.stdout.strip().split()[-1] if result.stdout else ""
try:
major, minor = int(version_str.split(".")[0]), int(version_str.split(".")[1])
except (IndexError, ValueError):
click.echo(_("WARN: Could not parse Python version '{version}'.", version=version_str), err=True)
return
if (major, minor) < PYTHON_MIN_VERSION:
click.echo(
_(
"WARN: .venv has Python {version}, but >={req} is required.",
version=version_str,
req=f"{PYTHON_MIN_VERSION[0]}.{PYTHON_MIN_VERSION[1]}",
),
err=True,
)
return
click.echo(_("[check-deps] Virtualenv .venv ready (Python {version}).", version=version_str))
@click.command()
@click.option("--venv", default=".venv", show_default=True, help="Path to the virtual environment.")
@click.option("--checkmake-bin", default=None, help="Path to checkmake binary (fallback if not on PATH).")
def cli(venv: str, checkmake_bin: str | None) -> None:
"""Verify that required development tools are present."""
click.echo(_("[check-deps] Verifying tools..."))
all_required = True
for tool in REQUIRED_TOOLS:
if not _check_tool(tool):
all_required = False
for tool in OPTIONAL_TOOLS:
if not _check_tool(tool, optional=True):
if checkmake_bin and Path(checkmake_bin).exists():
click.echo(_(" {tool}: found at {path}", tool=tool, path=checkmake_bin))
else:
click.echo(_(" Run 'make install-checkmake' to install the Makefile linter."))
_check_python_version(Path(venv) / "bin")
if not all_required:
raise click.ClickException(_("Required tools missing."))
click.echo(_("[check-deps] All core tools present."))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Docker registry login helper.
Handles login to Docker registries (Gitea, Docker Hub) with credential
loading from environment variables. Supports required and optional modes.
Usage::
python3 -m devx.tools.docker_login --registry git.oblachno.oblachno.fyi \\
--token-env CI_GITEA_TOKEN --username-env CI_GITEA_USERNAME \\
--default-username emil
python3 -m devx.tools.docker_login --registry docker.io \\
--token-env DOCKER_HUB_TOKEN --username-env DOCKER_HUB_USERNAME --optional
"""
from __future__ import annotations
import subprocess # nosec B404
import click
from devx.i18n import _
def docker_login(
registry: str,
username: str,
token: str,
*,
suppress_failure: bool = False,
) -> bool:
"""Log in to a Docker registry.
Returns True on success, False on failure.
If ``suppress_failure`` is True, prints a warning instead of raising.
"""
cmd = ["docker", "login", registry, "-u", username, "-p", token]
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
if suppress_failure:
click.echo(
_("[docker-login] Login to {registry} failed (continuing).", registry=registry),
err=True,
)
return False
raise click.ClickException(
_("Login to {registry} failed: {error}", registry=registry, error=result.stderr.strip()),
)
click.echo(_("[docker-login] Logged in to {registry}.", registry=registry))
return True
def _resolve_credentials(
token_env: str,
username_env: str,
default_username: str | None,
) -> tuple[str | None, str | None]:
"""Resolve credentials from environment variables.
Returns (username, token) or (None, None) if token is not set.
"""
import os
token = os.environ.get(token_env, "")
if not token:
return None, None
username = os.environ.get(username_env, "") or (default_username or "")
return username, token
@click.command()
@click.option("--registry", required=True, help="Docker registry URL (e.g. docker.io, git.example.com).")
@click.option("--token-env", required=True, help="Environment variable name for the auth token.")
@click.option("--username-env", required=True, help="Environment variable name for the username.")
@click.option(
"--default-username",
default=None,
help="Default username if the env var is not set.",
)
@click.option(
"--optional",
is_flag=True,
default=False,
help="Skip silently if token is not set instead of raising.",
)
@click.option(
"--suppress-failure",
is_flag=True,
default=False,
help="Continue on login failure instead of raising (prints warning).",
)
def cli(
registry: str,
token_env: str,
username_env: str,
default_username: str | None,
optional: bool,
suppress_failure: bool,
) -> None:
"""Log in to a Docker registry using credentials from environment variables."""
username, token = _resolve_credentials(token_env, username_env, default_username)
if token is None:
if optional:
click.echo(_("[docker-login] Skipping {registry} (token {env} not set).", registry=registry, env=token_env))
return
raise click.ClickException(
_("{env} is not set. Set it in your .env file or pass it as an environment variable.", env=token_env),
)
if not username:
raise click.ClickException(
_("{env} is not set. Set it in your .env file.", env=username_env),
)
docker_login(registry, username, token, suppress_failure=suppress_failure)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+25 -1
View File
@@ -42,6 +42,8 @@ TEA_VERSION = "0.14.1"
HADOLINT_VERSION = "2.12.0"
TOFU_VERSION = "1.12.3"
def _arch() -> str:
"""Return the architecture string used by release assets (delegates to shared utility)."""
@@ -174,7 +176,27 @@ def install_hadolint() -> bool:
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint"]
def install_tofu() -> bool:
"""Install OpenTofu if not already present. Returns True if installed/skipped.
Downloads the official release tarball from GitHub and extracts the
``tofu`` binary to ``~/.local/bin``.
"""
if _is_installed("tofu"):
click.echo("tofu: already installed")
return True
arch = _arch()
os_name = platform.system().lower()
url = (
f"https://github.com/opentofu/opentofu/releases/download/"
f"v{TOFU_VERSION}/tofu_{TOFU_VERSION}_{os_name}_{arch}.tar.gz"
)
dest = _download_and_extract_tarball(url, "tofu")
click.echo(f"tofu: installed to {dest}")
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu"]
def _install_tool(name: str) -> bool:
@@ -189,6 +211,8 @@ def _install_tool(name: str) -> bool:
return install_tea()
if name == "hadolint":
return install_hadolint()
if name == "tofu":
return install_tofu()
raise click.ClickException(f"Unknown tool: {name}")
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Set up the project inside a pre-built CI image.
CI images (e.g. ``ci-quality:latest``) ship with a Python virtualenv at
``/opt/venv`` that already contains the runtime dependencies. This tool
links that venv to ``.venv`` in the project root and installs the project
itself in editable mode, optionally with extras.
If ``/opt/venv`` does not exist (local development), falls back to
``make setup-ci`` via ``subprocess``.
Usage::
python3 -m devx.tools.setup_image # runtime deps only
python3 -m devx.tools.setup_image --extras lint # runtime + lint deps
python3 -m devx.tools.setup_image --extras ci,lint
"""
from __future__ import annotations
import os
import subprocess # nosec B404
from pathlib import Path
import click
DEFAULT_VENV = ".venv"
OPT_VENV = "/opt/venv"
FALLBACK_TARGET = "setup-ci"
def _build_pip_extra_index_url(
gitea_host: str,
gitea_org: str,
username: str,
token: str,
) -> str:
"""Build the PIP_EXTRA_INDEX_URL for the Gitea PyPI registry.
Returns a URL of the form:
https://<user>:<token>@<host>/api/packages/<org>/pypi/simple/
"""
return f"https://{username}:{token}@{gitea_host}/api/packages/{gitea_org}/pypi/simple/"
def _install_in_image(
venv_link: str,
opt_venv: str,
extras: str,
gitea_host: str,
gitea_org: str,
) -> None:
"""Link /opt/venv to .venv, activate it, and pip install the project.
Sets ``PIP_EXTRA_INDEX_URL`` when ``CI_GITEA_TOKEN`` is available so
that private packages from the Gitea PyPI registry can be installed.
"""
# Symlink /opt/venv → .venv
link = Path(venv_link)
if link.exists() or link.is_symlink():
link.unlink()
link.symlink_to(opt_venv)
# Build pip install command
spec = f".[{extras}]" if extras else "."
pip_bin = str(Path(venv_link) / "bin" / "pip")
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
env = os.environ.copy()
token = env.get("CI_GITEA_TOKEN", "")
if token:
username = env.get("CI_GITEA_USERNAME", "emil")
env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url(
gitea_host,
gitea_org,
username,
token,
)
click.echo(f"[setup-image] Linked {opt_venv}" + (f" with [{extras}]" if extras else "") + ".")
subprocess.run(cmd, check=True, env=env) # nosec B603
def _fallback_to_setup_ci() -> None:
"""Fall back to ``make setup-ci`` when /opt/venv is not present."""
click.echo(f"[setup-image] {OPT_VENV} not found — falling back to {FALLBACK_TARGET}")
subprocess.run( # nosec B603, B607
["make", FALLBACK_TARGET],
check=True,
)
@click.command()
@click.option(
"--venv",
default=DEFAULT_VENV,
show_default=True,
help="Path to the local venv symlink (e.g. .venv).",
)
@click.option(
"--opt-venv",
default=OPT_VENV,
show_default=True,
help="Path to the pre-built venv inside the CI image.",
)
@click.option(
"--extras",
default="",
help="Comma-separated dependency extras (e.g. 'ci,lint'). Empty for runtime only.",
)
@click.option(
"--gitea-host",
default="git.oblachno.oblachno.fyi",
show_default=True,
help="Gitea host for the PyPI registry.",
)
@click.option(
"--gitea-org",
default="oblachno-oss",
show_default=True,
help="Gitea org for the PyPI registry.",
)
def cli(
venv: str,
opt_venv: str,
extras: str,
gitea_host: str,
gitea_org: str,
) -> None:
"""Set up the project using a pre-built CI image venv."""
if Path(opt_venv).is_dir():
_install_in_image(venv, opt_venv, extras, gitea_host, gitea_org)
else:
_fallback_to_setup_ci()
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""OpenTofu operations: init and validate across directories.
Handles initialization and validation of OpenTofu configurations across
multiple directories (modules + environments). Supports CI mode with
``-backend=false`` to avoid state backend access.
Usage::
python3 -m devx.tools.tofu_ops init --env staging
python3 -m devx.tools.tofu_ops validate
python3 -m devx.tools.tofu_ops validate --ci
"""
from __future__ import annotations
import subprocess # nosec B404
from pathlib import Path
import click
from devx.i18n import _
DEFAULT_ENV_DIRS = ["tofu/environments/{env}", "tofu/environments/dns"]
DEFAULT_VALIDATE_DIRS = [
"tofu/modules/hetzner-vm",
"tofu/modules/hetzner-network",
"tofu/environments/staging",
"tofu/environments/production",
"tofu/environments/dns",
]
def _run_tofu(cmd: list[str], cwd: Path) -> None:
"""Run a tofu command in the given directory, raising on failure."""
click.echo(_(" -> {dir}", dir=cwd))
result = subprocess.run( # nosec B603, B607
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_("tofu command failed in {dir}: {error}", dir=cwd, error=result.stderr.strip()),
)
def tofu_init(env: str, root: str = ".", extra_dirs: list[str] | None = None) -> None:
"""Run ``tofu init`` in the environment directory and DNS directory.
Args:
env: Environment name (e.g. staging, production).
root: Repository root directory.
extra_dirs: Additional directory patterns to initialize.
"""
root_path = Path(root)
dirs = [d.format(env=env) for d in (extra_dirs or DEFAULT_ENV_DIRS)]
for dir_pattern in dirs:
dir_path = root_path / dir_pattern
if dir_path.is_dir():
click.echo(_("[tofu-init] Initializing {dir}...", dir=dir_path))
_run_tofu(["tofu", "init"], dir_path)
click.echo(_("[tofu-init] Done."))
def tofu_validate(
root: str = ".",
dirs: list[str] | None = None,
ci: bool = False,
) -> None:
"""Run ``tofu validate`` in all OpenTofu directories.
In CI mode, runs ``tofu init -backend=false`` before validate to avoid
state backend access.
Args:
root: Repository root directory.
dirs: List of directory paths to validate (relative to root).
ci: If True, use CI mode with -backend=false.
"""
root_path = Path(root)
target_dirs = dirs or DEFAULT_VALIDATE_DIRS
mode = "ci" if ci else "validate"
click.echo(_("[tofu-{mode}] Validating OpenTofu configurations...", mode=mode))
for dir_rel in target_dirs:
dir_path = root_path / dir_rel
if not dir_path.is_dir():
continue
if ci:
_run_tofu(["tofu", "init", "-backend=false", "-input=false"], dir_path)
_run_tofu(["tofu", "validate"], dir_path)
click.echo(_("[tofu-{mode}] All configurations valid.", mode=mode))
@click.group()
def cli() -> None:
"""OpenTofu operations."""
@cli.command()
@click.option("--env", required=True, help="Environment name (staging, production).")
@click.option("--root", default=".", help="Repository root directory.")
def init(env: str, root: str) -> None:
"""Initialize OpenTofu in an environment."""
tofu_init(env, root)
@cli.command()
@click.option("--root", default=".", help="Repository root directory.")
@click.option("--ci", is_flag=True, default=False, help="CI mode: use -backend=false.")
def validate(root: str, ci: bool) -> None:
"""Validate OpenTofu configurations."""
tofu_validate(root, ci=ci)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+184
View File
@@ -3094,5 +3094,189 @@
"pl": " - {count} standard labels verified",
"ru": " - {count} standard labels verified",
"zh": " - {count} standard labels verified"
},
"[check-deps] Virtualenv .venv ready (Python {version}).": {
"en": "[check-deps] Virtualenv .venv ready (Python {version}).",
"bg": "[check-deps] Виртуална среда .venv готова (Python {version}).",
"de": "[check-deps] Virtuelle Umgebung .venv bereit (Python {version}).",
"pl": "[check-deps] Środowisko wirtualne .venv gotowe (Python {version}).",
"ru": "[check-deps] Виртуальное окружение .venv готово (Python {version}).",
"zh": "[check-deps] 虚拟环境 .venv 已就绪 (Python {version})。"
},
"{level}: {tool} not found.{hint}": {
"en": "{level}: {tool} not found.{hint}",
"bg": "{level}: {tool} не е намерен.{hint}",
"de": "{level}: {tool} nicht gefunden.{hint}",
"pl": "{level}: {tool} nie znaleziono.{hint}",
"ru": "{level}: {tool} не найден.{hint}",
"zh": "{level}: 未找到 {tool}。{hint}"
},
"WARN: Could not determine Python version in .venv.": {
"en": "WARN: Could not determine Python version in .venv.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се определи версията на Python в .venv.",
"de": "WARNUNG: Python-Version in .venv konnte nicht bestimmt werden.",
"pl": "OSTRZEŻENIE: Nie można określić wersji Python w .venv.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось определить версию Python в .venv.",
"zh": "警告: 无法确定 .venv 中的 Python 版本。"
},
"WARN: Could not parse Python version '{version}'.": {
"en": "WARN: Could not parse Python version '{version}'.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се анализира версията на Python '{version}'.",
"de": "WARNUNG: Python-Version '{version}' konnte nicht analysiert werden.",
"pl": "OSTRZEŻENIE: Nie można przeanalizować wersji Python '{version}'.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось разобрать версию Python '{version}'.",
"zh": "警告: 无法解析 Python 版本 '{version}'。"
},
"WARN: .venv not found. Run 'make setup-venv' to create it.": {
"en": "WARN: .venv not found. Run 'make setup-venv' to create it.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv не е намерен. Изпълнете 'make setup-venv' за създаване.",
"de": "WARNUNG: .venv nicht gefunden. Führen Sie 'make setup-venv' aus, um es zu erstellen.",
"pl": "OSTRZEŻENIE: Nie znaleziono .venv. Uruchom 'make setup-venv', aby utworzyć.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: .venv не найден. Выполните 'make setup-venv' для создания.",
"zh": "警告: 未找到 .venv。运行 'make setup-venv' 来创建。"
},
"[docker-login] Logged in to {registry}.": {
"en": "[docker-login] Logged in to {registry}.",
"bg": "[docker-login] Влязъл в {registry}.",
"de": "[docker-login] Angemeldet bei {registry}.",
"pl": "[docker-login] Zalogowano do {registry}.",
"ru": "[docker-login] Выполнен вход в {registry}.",
"zh": "[docker-login] 已登录到 {registry}。"
},
"[docker-login] Login to {registry} failed (continuing).": {
"en": "[docker-login] Login to {registry} failed (continuing).",
"bg": "[docker-login] Влизането в {registry} не успя (продължава).",
"de": "[docker-login] Anmeldung bei {registry} fehlgeschlagen (wird fortgesetzt).",
"pl": "[docker-login] Logowanie do {registry} nie powiodło się (kontynuowanie).",
"ru": "[docker-login] Ошибка входа в {registry} (продолжаем).",
"zh": "[docker-login] 登录 {registry} 失败(继续)。"
},
"[docker-login] Skipping {registry} (token {env} not set).": {
"en": "[docker-login] Skipping {registry} (token {env} not set).",
"bg": "[docker-login] Пропускане на {registry} (токен {env} не е зададен).",
"de": "[docker-login] {registry} übersprungen (Token {env} nicht gesetzt).",
"pl": "[docker-login] Pomijanie {registry} (token {env} nie ustawiony).",
"ru": "[docker-login] Пропуск {registry} (токен {env} не задан).",
"zh": "[docker-login] 跳过 {registry}(未设置令牌 {env})。"
},
"{env} is not set. Set it in your .env file.": {
"en": "{env} is not set. Set it in your .env file.",
"bg": "{env} не е зададен. Задайте го във вашия .env файл.",
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.",
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.",
"ru": "{env} не задан. Установите его в файле .env.",
"zh": "{env} 未设置。请在 .env 文件中设置。"
},
"{env} is not set. Set it in your .env file or pass it as an environment variable.": {
"en": "{env} is not set. Set it in your .env file or pass it as an environment variable.",
"bg": "{env} не е зададен. Задайте го във вашия .env файл или го подайте като променлива на средата.",
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei oder übergeben Sie es als Umgebungsvariable.",
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env lub przekaż jako zmienną środowiskową.",
"ru": "{env} не задан. Установите его в файле .env или передайте как переменную окружения.",
"zh": "{env} 未设置。请在 .env 文件中设置或作为环境变量传递。"
},
"Login to {registry} failed: {error}": {
"en": "Login to {registry} failed: {error}",
"bg": "Влизането в {registry} не успя: {error}",
"de": "Anmeldung bei {registry} fehlgeschlagen: {error}",
"pl": "Logowanie do {registry} nie powiodło się: {error}",
"ru": "Ошибка входа в {registry}: {error}",
"zh": "登录 {registry} 失败: {error}"
},
"tofu command failed in {dir}: {error}": {
"en": "tofu command failed in {dir}: {error}",
"bg": "командата tofu не успя в {dir}: {error}",
"de": "tofu-Befehl fehlgeschlagen in {dir}: {error}",
"pl": "polecenie tofu nie powiodło się w {dir}: {error}",
"ru": "команда tofu не удалась в {dir}: {error}",
"zh": "tofu 命令在 {dir} 中失败: {error}"
},
"WARN: .venv has Python {version}, but >={req} is required.": {
"en": "WARN: .venv has Python {version}, but >={req} is required.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.",
"de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.",
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.",
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。"
},
" -> {dir}": {
"en": " -> {dir}",
"bg": " -> {dir}",
"de": " -> {dir}",
"pl": " -> {dir}",
"ru": " -> {dir}",
"zh": " -> {dir}"
},
"[tofu-init] Initializing {dir}...": {
"en": "[tofu-init] Initializing {dir}...",
"bg": "[tofu-init] Инициализиране на {dir}...",
"de": "[tofu-init] Initialisiere {dir}...",
"pl": "[tofu-init] Inicjalizacja {dir}...",
"ru": "[tofu-init] Инициализация {dir}...",
"zh": "[tofu-init] 正在初始化 {dir}..."
},
"[tofu-init] Done.": {
"en": "[tofu-init] Done.",
"bg": "[tofu-init] Готово.",
"de": "[tofu-init] Fertig.",
"pl": "[tofu-init] Gotowe.",
"ru": "[tofu-init] Готово.",
"zh": "[tofu-init] 完成。"
},
"[tofu-{mode}] Validating OpenTofu configurations...": {
"en": "[tofu-{mode}] Validating OpenTofu configurations...",
"bg": "[tofu-{mode}] Проверка на OpenTofu конфигурациите...",
"de": "[tofu-{mode}] Validiere OpenTofu-Konfigurationen...",
"pl": "[tofu-{mode}] Sprawdzanie konfiguracji OpenTofu...",
"ru": "[tofu-{mode}] Проверка конфигураций OpenTofu...",
"zh": "[tofu-{mode}] 正在验证 OpenTofu 配置..."
},
"[tofu-{mode}] All configurations valid.": {
"en": "[tofu-{mode}] All configurations valid.",
"bg": "[tofu-{mode}] Всички конфигурации са валидни.",
"de": "[tofu-{mode}] Alle Konfigurationen gültig.",
"pl": "[tofu-{mode}] Wszystkie konfiguracje są poprawne.",
"ru": "[tofu-{mode}] Все конфигурации валидны.",
"zh": "[tofu-{mode}] 所有配置有效。"
},
"[check-deps] Verifying tools...": {
"en": "[check-deps] Verifying tools...",
"bg": "[check-deps] Проверка на инструментите...",
"de": "[check-deps] Werkzeuge werden überprüft...",
"pl": "[check-deps] Sprawdzanie narzędzi...",
"ru": "[check-deps] Проверка инструментов...",
"zh": "[check-deps] 正在验证工具..."
},
" {tool}: found at {path}": {
"en": " {tool}: found at {path}",
"bg": " {tool}: намерен на {path}",
"de": " {tool}: gefunden unter {path}",
"pl": " {tool}: znaleziono w {path}",
"ru": " {tool}: найден в {path}",
"zh": " {tool}: 在 {path} 找到"
},
" Run 'make install-checkmake' to install the Makefile linter.": {
"en": " Run 'make install-checkmake' to install the Makefile linter.",
"bg": " Изпълнете 'make install-checkmake' за инсталиране на Makefile линтера.",
"de": " Führen Sie 'make install-checkmake' aus, um den Makefile-Linter zu installieren.",
"pl": " Uruchom 'make install-checkmake', aby zainstalować linter Makefile.",
"ru": " Выполните 'make install-checkmake' для установки линтера Makefile.",
"zh": " 运行 'make install-checkmake' 来安装 Makefile 检查器。"
},
"Required tools missing.": {
"en": "Required tools missing.",
"bg": "Липсват задължителни инструменти.",
"de": "Erforderliche Werkzeuge fehlen.",
"pl": "Brak wymaganych narzędzi.",
"ru": "Отсутствуют обязательные инструменты.",
"zh": "缺少必需的工具。"
},
"[check-deps] All core tools present.": {
"en": "[check-deps] All core tools present.",
"bg": "[check-deps] Всички основни инструменти са налични.",
"de": "[check-deps] Alle Kernwerkzeuge vorhanden.",
"pl": "[check-deps] Wszystkie podstawowe narzędzia są dostępne.",
"ru": "[check-deps] Все основные инструменты доступны.",
"zh": "[check-deps] 所有核心工具均已就绪。"
}
}
+108
View File
@@ -0,0 +1,108 @@
"""Unit tests for devx.tools.check_deps."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.check_deps import (
_check_python_version,
_check_tool,
cli,
)
class TestCheckTool:
@patch("devx.tools.check_deps.shutil.which", return_value="/usr/bin/tofu")
def test_found(self, mock_which: MagicMock) -> None:
assert _check_tool("tofu") is True
@patch("devx.tools.check_deps.shutil.which", return_value=None)
def test_not_found_required(self, mock_which: MagicMock) -> None:
assert _check_tool("tofu") is False
@patch("devx.tools.check_deps.shutil.which", return_value=None)
def test_not_found_optional(self, mock_which: MagicMock) -> None:
assert _check_tool("checkmake", optional=True) is False
class TestCheckPythonVersion:
@patch("devx.tools.check_deps.subprocess.run")
def test_valid_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
venv_bin.mkdir()
(venv_bin / "python").touch()
mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.12.3\n", stderr="")
_check_python_version(venv_bin)
@patch("devx.tools.check_deps.subprocess.run")
def test_old_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
venv_bin.mkdir()
(venv_bin / "python").touch()
mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.11.0\n", stderr="")
_check_python_version(venv_bin)
def test_no_venv(self, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
_check_python_version(venv_bin)
@patch("devx.tools.check_deps.subprocess.run")
def test_command_fails(self, mock_run: MagicMock, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
venv_bin.mkdir()
(venv_bin / "python").touch()
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
_check_python_version(venv_bin)
@patch("devx.tools.check_deps.subprocess.run")
def test_unparseable_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
venv_bin.mkdir()
(venv_bin / "python").touch()
mock_run.return_value = MagicMock(returncode=0, stdout="garbage\n", stderr="")
_check_python_version(venv_bin)
class TestCli:
@patch("devx.tools.check_deps._check_python_version")
@patch("devx.tools.check_deps._check_tool")
def test_all_present(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
mock_check.return_value = True
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "All core tools present" in result.output
@patch("devx.tools.check_deps._check_python_version")
@patch("devx.tools.check_deps._check_tool")
def test_missing_required(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
mock_check.side_effect = lambda name, optional=False: name != "tofu"
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
@patch("devx.tools.check_deps._check_python_version")
@patch("devx.tools.check_deps._check_tool")
def test_missing_optional_with_fallback(self, mock_check: MagicMock, mock_py: MagicMock, tmp_path: Path) -> None:
checkmake_bin = tmp_path / "checkmake"
checkmake_bin.touch()
def _side(name: str, optional: bool = False) -> bool:
return name != "checkmake"
mock_check.side_effect = _side
runner = CliRunner()
result = runner.invoke(cli, ["--checkmake-bin", str(checkmake_bin)])
assert result.exit_code == 0
@patch("devx.tools.check_deps._check_python_version")
@patch("devx.tools.check_deps._check_tool")
def test_missing_optional_no_fallback(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
def _side(name: str, optional: bool = False) -> bool:
return name != "checkmake"
mock_check.side_effect = _side
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
+149
View File
@@ -0,0 +1,149 @@
"""Unit tests for devx.tools.docker_login."""
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.docker_login import (
_resolve_credentials,
cli,
docker_login,
)
class TestDockerLogin:
@patch("devx.tools.docker_login.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert docker_login("registry.io", "user", "tok") is True
@patch("devx.tools.docker_login.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed")
with pytest.raises(Exception, match="auth failed"):
docker_login("registry.io", "user", "tok")
@patch("devx.tools.docker_login.subprocess.run")
def test_failure_suppressed(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed")
assert docker_login("registry.io", "user", "tok", suppress_failure=True) is False
class TestResolveCredentials:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
def test_both_set(self) -> None:
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None)
assert user == "emil"
assert token == "tok"
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_token_only_with_default(self) -> None:
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil")
assert user == "emil"
assert token == "tok"
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self) -> None:
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil")
assert user is None
assert token is None
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_no_username_no_default(self) -> None:
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None)
assert user == ""
assert token == "tok"
class TestCli:
@patch("devx.tools.docker_login.docker_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
def test_required_login(self, mock_login: MagicMock) -> None:
mock_login.return_value = True
runner = CliRunner()
result = runner.invoke(
cli,
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
)
assert result.exit_code == 0
mock_login.assert_called_once()
@patch("devx.tools.docker_login.docker_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_default_username(self, mock_login: MagicMock) -> None:
mock_login.return_value = True
runner = CliRunner()
result = runner.invoke(
cli,
[
"--registry",
"reg.io",
"--token-env",
"CI_GITEA_TOKEN",
"--username-env",
"CI_GITEA_USERNAME",
"--default-username",
"emil",
],
)
assert result.exit_code == 0
mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=False)
@patch.dict("os.environ", {}, clear=True)
def test_required_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(
cli,
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
)
assert result.exit_code != 0
@patch.dict("os.environ", {}, clear=True)
def test_optional_no_token_skips(self) -> None:
runner = CliRunner()
result = runner.invoke(
cli,
[
"--registry",
"reg.io",
"--token-env",
"CI_GITEA_TOKEN",
"--username-env",
"CI_GITEA_USERNAME",
"--optional",
],
)
assert result.exit_code == 0
assert "Skipping" in result.output
@patch("devx.tools.docker_login.docker_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_no_username_raises(self, mock_login: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
cli,
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
)
assert result.exit_code != 0
mock_login.assert_not_called()
@patch("devx.tools.docker_login.docker_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
def test_suppress_failure(self, mock_login: MagicMock) -> None:
mock_login.return_value = False
runner = CliRunner()
result = runner.invoke(
cli,
[
"--registry",
"reg.io",
"--token-env",
"CI_GITEA_TOKEN",
"--username-env",
"CI_GITEA_USERNAME",
"--suppress-failure",
],
)
assert result.exit_code == 0
mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=True)
+35 -1
View File
@@ -240,6 +240,35 @@ class TestInstallHadolint:
assert (tmp_path / "hadolint").exists()
class TestInstallTofu:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_tofu() is True
def test_install(self, tmp_path: Path) -> None:
import io
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake tofu"
with tarfile.open(tarball_path, "w:gz") as tar:
info = tarfile.TarInfo(name="tofu")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(platform, "system", return_value="Linux"):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
assert install_tools.install_tofu() is True
assert (tmp_path / "tofu").exists()
class TestListTools:
def test_list(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
@@ -274,6 +303,11 @@ class TestInstallTool:
assert install_tools._install_tool("hadolint") is True
mock.assert_called_once()
def test_tofu(self) -> None:
with patch.object(install_tools, "install_tofu", return_value=True) as mock:
assert install_tools._install_tool("tofu") is True
mock.assert_called_once()
def test_unknown_tool(self) -> None:
with pytest.raises(ClickException, match="Unknown tool"):
install_tools._install_tool("unknown")
@@ -292,7 +326,7 @@ class TestMain:
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert mock_install.call_count == 5
assert mock_install.call_count == 6
def test_install_specific_tool(self) -> None:
runner = CliRunner()
+262
View File
@@ -0,0 +1,262 @@
"""Unit tests for devx.tools.setup_image."""
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.setup_image import (
_build_pip_extra_index_url,
_fallback_to_setup_ci,
_install_in_image,
cli,
)
class TestBuildPipExtraIndexUrl:
def test_basic_url(self) -> None:
url = _build_pip_extra_index_url(
"git.oblachno.oblachno.fyi",
"oblachno-oss",
"emil",
"tok123",
)
assert url == "https://emil:tok123@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"
def test_custom_host_org(self) -> None:
url = _build_pip_extra_index_url(
"gitea.example.com",
"my-org",
"user",
"secret",
)
assert url == "https://user:secret@gitea.example.com/api/packages/my-org/pypi/simple/"
class TestInstallInImage:
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_link_and_install_no_token(self, mock_path: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
mock_path.return_value.symlink_to.assert_called_once_with("/opt/venv")
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "--no-cache-dir" in cmd
assert "-e" in cmd
assert "." in cmd
# No extras → spec is "."
assert ".[]" not in " ".join(cmd)
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_link_and_install_with_extras(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "ci,lint", "host", "org")
cmd = mock_run.call_args[0][0]
assert ".[ci,lint]" in cmd
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_with_token_sets_pip_extra_index_url(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(
os.environ,
{"CI_GITEA_TOKEN": "tok123", "CI_GITEA_USERNAME": "emil"},
clear=True,
):
_install_in_image(str(venv_link), "/opt/venv", "lint", "git.host", "org")
env = mock_run.call_args[1]["env"]
assert "PIP_EXTRA_INDEX_URL" in env
assert "emil:tok123@git.host" in env["PIP_EXTRA_INDEX_URL"]
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_with_token_defaults_username(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
env = mock_run.call_args[1]["env"]
assert "emil:tok123@host" in env["PIP_EXTRA_INDEX_URL"]
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_removes_existing_link(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = True
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.unlink = MagicMock()
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
mock_path.return_value.unlink.assert_called_once()
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_removes_existing_symlink(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = True
mock_path.return_value.unlink = MagicMock()
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
mock_path.return_value.unlink.assert_called_once()
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_failure_raises(self, mock_path: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
mock_run.side_effect = subprocess.CalledProcessError(1, ["pip"])
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(subprocess.CalledProcessError):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
class TestFallbackToSetupCi:
@patch("devx.tools.setup_image.subprocess.run")
def test_fallback_runs_make_setup_ci(self, mock_run: MagicMock) -> None:
_fallback_to_setup_ci()
mock_run.assert_called_once_with(["make", "setup-ci"], check=True)
@patch("devx.tools.setup_image.subprocess.run")
def test_fallback_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, ["make"])
with pytest.raises(subprocess.CalledProcessError):
_fallback_to_setup_ci()
class TestCli:
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_with_opt_venv_present(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(cli, ["--extras", "ci,lint"])
assert result.exit_code == 0
mock_install.assert_called_once()
@patch("devx.tools.setup_image._fallback_to_setup_ci")
@patch("devx.tools.setup_image.Path")
def test_cli_falls_back_when_no_opt_venv(
self,
mock_path: MagicMock,
mock_fallback: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = False
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
mock_fallback.assert_called_once()
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_default_values(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
call_args = mock_install.call_args[0]
assert call_args[0] == ".venv"
assert call_args[1] == "/opt/venv"
assert call_args[2] == "" # no extras
assert call_args[3] == "git.oblachno.oblachno.fyi"
assert call_args[4] == "oblachno-oss"
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_custom_venv_and_gitea(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(
cli,
["--venv", ".custom-venv", "--gitea-host", "gitea.io", "--gitea-org", "myorg"],
)
assert result.exit_code == 0
call_args = mock_install.call_args[0]
assert call_args[0] == ".custom-venv"
assert call_args[3] == "gitea.io"
assert call_args[4] == "myorg"
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_with_extras(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(cli, ["--extras", "lint"])
assert result.exit_code == 0
assert mock_install.call_args[0][2] == "lint"
+115
View File
@@ -0,0 +1,115 @@
"""Unit tests for devx.tools.tofu_ops."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.tofu_ops import (
_run_tofu,
cli,
tofu_init,
tofu_validate,
)
class TestRunTofu:
@patch("devx.tools.tofu_ops.subprocess.run")
def test_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
_run_tofu(["tofu", "init"], tmp_path)
mock_run.assert_called_once()
@patch("devx.tools.tofu_ops.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
with pytest.raises(Exception, match="error"):
_run_tofu(["tofu", "validate"], tmp_path)
class TestTofuInit:
@patch("devx.tools.tofu_ops._run_tofu")
def test_init_existing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
(tmp_path / "tofu/environments/dns").mkdir(parents=True)
tofu_init("staging", root=str(tmp_path))
assert mock_run.call_count == 2
@patch("devx.tools.tofu_ops._run_tofu")
def test_init_skips_missing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
# dns dir doesn't exist
tofu_init("staging", root=str(tmp_path))
assert mock_run.call_count == 1
@patch("devx.tools.tofu_ops._run_tofu")
def test_init_no_dirs_exist(self, mock_run: MagicMock, tmp_path: Path) -> None:
tofu_init("staging", root=str(tmp_path))
mock_run.assert_not_called()
@patch("devx.tools.tofu_ops._run_tofu")
def test_init_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "custom/dir").mkdir(parents=True)
tofu_init("staging", root=str(tmp_path), extra_dirs=["custom/dir"])
assert mock_run.call_count == 1
class TestTofuValidate:
@patch("devx.tools.tofu_ops._run_tofu")
def test_validate_all_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
for d in [
"tofu/modules/hetzner-vm",
"tofu/modules/hetzner-network",
"tofu/environments/staging",
"tofu/environments/production",
"tofu/environments/dns",
]:
(tmp_path / d).mkdir(parents=True)
tofu_validate(root=str(tmp_path))
assert mock_run.call_count == 5
@patch("devx.tools.tofu_ops._run_tofu")
def test_validate_skips_missing(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
tofu_validate(root=str(tmp_path))
assert mock_run.call_count == 1
@patch("devx.tools.tofu_ops._run_tofu")
def test_validate_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
tofu_validate(root=str(tmp_path), ci=True)
# CI mode runs init + validate = 2 calls per dir
assert mock_run.call_count == 2
first_call = mock_run.call_args_list[0][0][0]
assert "init" in first_call
assert "-backend=false" in first_call
@patch("devx.tools.tofu_ops._run_tofu")
def test_validate_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "custom").mkdir()
tofu_validate(root=str(tmp_path), dirs=["custom"])
assert mock_run.call_count == 1
class TestCli:
@patch("devx.tools.tofu_ops.tofu_init")
def test_init_command(self, mock_init: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["init", "--env", "staging"])
assert result.exit_code == 0
mock_init.assert_called_once_with("staging", ".")
@patch("devx.tools.tofu_ops.tofu_validate")
def test_validate_command(self, mock_validate: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["validate"])
assert result.exit_code == 0
mock_validate.assert_called_once_with(".", ci=False)
@patch("devx.tools.tofu_ops.tofu_validate")
def test_validate_ci_command(self, mock_validate: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["validate", "--ci"])
assert result.exit_code == 0
mock_validate.assert_called_once_with(".", ci=True)