From 3d4b4940ffb16de4a3ecedc6e1f96caaa041062b Mon Sep 17 00:00:00 2001 From: emil User Date: Sun, 9 Aug 2026 01:09:20 +0000 Subject: [PATCH] DEVX-153: feat: sync missing features from v0.49.x line to master Co-authored-by: emil User --- .gitea/workflows/build-images.yml | 10 + .gitea/workflows/ci.yml | 14 +- .gitea/workflows/post-merge.yml | 12 +- .pre-commit-config.yaml | 2 +- .vale/styles/Google/AMPM.yml | 9 + .vale/styles/Google/Acronyms.yml | 64 +++ .vale/styles/Google/Anthropomorphism.yml | 12 + .vale/styles/Google/Colons.yml | 13 + .vale/styles/Google/Contractions.yml | 30 ++ .vale/styles/Google/DateFormat.yml | 9 + .vale/styles/Google/Ellipses.yml | 9 + .vale/styles/Google/EmDash.yml | 13 + .vale/styles/Google/ExcessiveClaims.yml | 14 + .vale/styles/Google/Exclamation.yml | 12 + .vale/styles/Google/FirstPerson.yml | 15 + .vale/styles/Google/Gender.yml | 9 + .vale/styles/Google/GenderBias.yml | 43 ++ .vale/styles/Google/HeadingPunctuation.yml | 13 + .vale/styles/Google/Headings.yml | 32 ++ .vale/styles/Google/Jargon.yml | 13 + .vale/styles/Google/Latin.yml | 15 + .vale/styles/Google/LyHyphens.yml | 14 + .vale/styles/Google/OptionalPlurals.yml | 12 + .vale/styles/Google/Ordinal.yml | 7 + .vale/styles/Google/OxfordComma.yml | 28 ++ .vale/styles/Google/Parens.yml | 15 + .vale/styles/Google/Passive.yml | 184 ++++++++ .vale/styles/Google/Periods.yml | 7 + .vale/styles/Google/Quotes.yml | 7 + .vale/styles/Google/Ranges.yml | 7 + .vale/styles/Google/Semicolons.yml | 8 + .vale/styles/Google/Slang.yml | 11 + .vale/styles/Google/Spacing.yml | 10 + .vale/styles/Google/Spelling.yml | 10 + .vale/styles/Google/Timeless.yml | 13 + .vale/styles/Google/Units.yml | 10 + .vale/styles/Google/We.yml | 11 + .vale/styles/Google/Will.yml | 7 + .vale/styles/Google/WordList.yml | 29 ++ .vale/styles/Google/WordListCase.yml | 68 +++ AGENTS.md | 19 +- Makefile | 28 +- README.md | 20 +- docs/index.md | 18 +- ...-approval-fallback-and-ci-consolidation.md | 2 +- docs/tech/architecture.md | 12 +- docs/user/cli-commands.md | 108 ++++- docs/user/getting-started.md | 4 +- pyproject.toml | 13 +- src/devx/__init__.py | 2 +- src/devx/ci/cancel_superseded_runs.py | 185 ++++++++ src/devx/ci/check_workflow_artifact_deps.py | 163 +++++++ src/devx/ci/check_workflow_tofu_init.py | 145 ++++++ src/devx/cli.py | 42 ++ src/devx/i18n.py | 39 +- src/devx/molecule/distribute_molecule.py | 44 +- src/devx/tools/check_alert_rules.py | 86 ++++ .../tools/check_ansible_set_fact_to_json.py | 196 ++++++++ src/devx/tools/check_docker_init.py | 166 +++++++ src/devx/tools/install_tools.py | 28 +- src/devx/tools/setup.py | 94 ---- src/devx/tools/setup_image.py | 4 +- src/devx/utils/api.py | 100 ++++- src/devx/utils/jinja.py | 133 ++++++ src/devx/utils/ui.py | 79 ++++ tests/unit/test_ci_cancel_superseded_runs.py | 172 +++++++ .../test_ci_check_workflow_artifact_deps.py | 419 ++++++++++++++++++ .../unit/test_ci_check_workflow_tofu_init.py | 356 +++++++++++++++ tests/unit/test_distribute_molecule.py | 55 +++ tests/unit/test_i18n.py | 100 +++++ tests/unit/test_install_tools.py | 27 ++ tests/unit/test_setup.py | 204 --------- tests/unit/test_setup_image.py | 1 + tests/unit/test_tools_check_alert_rules.py | 103 +++++ ...st_tools_check_ansible_set_fact_to_json.py | 346 +++++++++++++++ tests/unit/test_tools_check_docker_init.py | 291 ++++++++++++ tests/unit/test_utils_api.py | 171 +++++++ tests/unit/test_utils_jinja.py | 161 +++++++ tests/unit/test_utils_ui.py | 101 +++++ 79 files changed, 4697 insertions(+), 361 deletions(-) create mode 100644 .vale/styles/Google/AMPM.yml create mode 100644 .vale/styles/Google/Acronyms.yml create mode 100644 .vale/styles/Google/Anthropomorphism.yml create mode 100644 .vale/styles/Google/Colons.yml create mode 100644 .vale/styles/Google/Contractions.yml create mode 100644 .vale/styles/Google/DateFormat.yml create mode 100644 .vale/styles/Google/Ellipses.yml create mode 100644 .vale/styles/Google/EmDash.yml create mode 100644 .vale/styles/Google/ExcessiveClaims.yml create mode 100644 .vale/styles/Google/Exclamation.yml create mode 100644 .vale/styles/Google/FirstPerson.yml create mode 100644 .vale/styles/Google/Gender.yml create mode 100644 .vale/styles/Google/GenderBias.yml create mode 100644 .vale/styles/Google/HeadingPunctuation.yml create mode 100644 .vale/styles/Google/Headings.yml create mode 100644 .vale/styles/Google/Jargon.yml create mode 100644 .vale/styles/Google/Latin.yml create mode 100644 .vale/styles/Google/LyHyphens.yml create mode 100644 .vale/styles/Google/OptionalPlurals.yml create mode 100644 .vale/styles/Google/Ordinal.yml create mode 100644 .vale/styles/Google/OxfordComma.yml create mode 100644 .vale/styles/Google/Parens.yml create mode 100644 .vale/styles/Google/Passive.yml create mode 100644 .vale/styles/Google/Periods.yml create mode 100644 .vale/styles/Google/Quotes.yml create mode 100644 .vale/styles/Google/Ranges.yml create mode 100644 .vale/styles/Google/Semicolons.yml create mode 100644 .vale/styles/Google/Slang.yml create mode 100644 .vale/styles/Google/Spacing.yml create mode 100644 .vale/styles/Google/Spelling.yml create mode 100644 .vale/styles/Google/Timeless.yml create mode 100644 .vale/styles/Google/Units.yml create mode 100644 .vale/styles/Google/We.yml create mode 100644 .vale/styles/Google/Will.yml create mode 100644 .vale/styles/Google/WordList.yml create mode 100644 .vale/styles/Google/WordListCase.yml create mode 100644 src/devx/ci/cancel_superseded_runs.py create mode 100644 src/devx/ci/check_workflow_artifact_deps.py create mode 100644 src/devx/ci/check_workflow_tofu_init.py create mode 100644 src/devx/tools/check_alert_rules.py create mode 100644 src/devx/tools/check_ansible_set_fact_to_json.py create mode 100644 src/devx/tools/check_docker_init.py create mode 100644 src/devx/utils/jinja.py create mode 100644 src/devx/utils/ui.py create mode 100644 tests/unit/test_ci_cancel_superseded_runs.py create mode 100644 tests/unit/test_ci_check_workflow_artifact_deps.py create mode 100644 tests/unit/test_ci_check_workflow_tofu_init.py create mode 100644 tests/unit/test_i18n.py create mode 100644 tests/unit/test_tools_check_alert_rules.py create mode 100644 tests/unit/test_tools_check_ansible_set_fact_to_json.py create mode 100644 tests/unit/test_tools_check_docker_init.py create mode 100644 tests/unit/test_utils_api.py create mode 100644 tests/unit/test_utils_jinja.py create mode 100644 tests/unit/test_utils_ui.py diff --git a/.gitea/workflows/build-images.yml b/.gitea/workflows/build-images.yml index 9a8effb..6de5a87 100644 --- a/.gitea/workflows/build-images.yml +++ b/.gitea/workflows/build-images.yml @@ -32,6 +32,11 @@ concurrency: jobs: build-and-push: runs-on: docker + container: + image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest + credentials: + username: ${{ vars.CI_GITEA_USERNAME }} + password: ${{ secrets.CI_GITEA_API_TOKEN }} timeout-minutes: 30 outputs: is-release: ${{ steps.check.outputs.is-release }} @@ -115,6 +120,11 @@ jobs: needs: [build-and-push] if: always() && needs.build-and-push.result == 'success' runs-on: docker + container: + image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest + credentials: + username: ${{ vars.CI_GITEA_USERNAME }} + password: ${{ secrets.CI_GITEA_API_TOKEN }} timeout-minutes: 10 steps: - uses: actions/checkout@v4 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index eb9ed56..e1eee70 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -18,7 +18,11 @@ jobs: # Saves ~4x checkout+setup overhead vs 5 separate jobs. validate: runs-on: docker - container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest + container: + image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest + credentials: + username: ${{ vars.CI_GITEA_USERNAME }} + password: ${{ secrets.CI_GITEA_API_TOKEN }} timeout-minutes: 15 defaults: run: @@ -46,7 +50,7 @@ jobs: - name: Check unit test speed run: | . .venv/bin/activate 2>/dev/null || true - python3 -m devx.tools.check_test_speed --max-seconds 8 --max-single-seconds 0.5 + python3 -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5 - name: Documentation gate (coverage + stale refs + lint + version refs + prose) env: DEVX_DOC_COVERAGE_STRICT: "1" @@ -140,7 +144,11 @@ jobs: github.event_name == 'pull_request' && needs.validate.result == 'success' runs-on: docker - container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest + container: + image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest + credentials: + username: ${{ vars.CI_GITEA_USERNAME }} + password: ${{ secrets.CI_GITEA_API_TOKEN }} timeout-minutes: 10 defaults: run: diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index c5952ba..8dd596d 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -35,7 +35,11 @@ env: jobs: detect-and-configure: runs-on: docker - container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest + container: + image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest + credentials: + username: ${{ vars.CI_GITEA_USERNAME }} + password: ${{ secrets.CI_GITEA_API_TOKEN }} timeout-minutes: 10 defaults: run: @@ -99,7 +103,11 @@ jobs: needs: [detect-and-configure] if: always() && needs.detect-and-configure.result == 'success' runs-on: docker - container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest + container: + image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest + credentials: + username: ${{ vars.CI_GITEA_USERNAME }} + password: ${{ secrets.CI_GITEA_API_TOKEN }} timeout-minutes: 15 outputs: tag: ${{ steps.release-tag.outputs.tag }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 468053e..a2a22c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,7 +59,7 @@ repos: - 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 + entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5 language: system types: [python] pass_filenames: false diff --git a/.vale/styles/Google/AMPM.yml b/.vale/styles/Google/AMPM.yml new file mode 100644 index 0000000..37b49ed --- /dev/null +++ b/.vale/styles/Google/AMPM.yml @@ -0,0 +1,9 @@ +extends: existence +message: "Use 'AM' or 'PM' (preceded by a space)." +link: "https://developers.google.com/style/word-list" +level: error +nonword: true +tokens: + - '\d{1,2}[AP]M\b' + - '\d{1,2} ?[ap]m\b' + - '\d{1,2} ?[aApP]\.[mM]\.' diff --git a/.vale/styles/Google/Acronyms.yml b/.vale/styles/Google/Acronyms.yml new file mode 100644 index 0000000..f41af01 --- /dev/null +++ b/.vale/styles/Google/Acronyms.yml @@ -0,0 +1,64 @@ +extends: conditional +message: "Spell out '%s', if it's unfamiliar to the audience." +link: 'https://developers.google.com/style/abbreviations' +level: suggestion +ignorecase: false +# Ensures that the existence of 'first' implies the existence of 'second'. +first: '\b([A-Z]{3,5})\b' +second: '(?:\b[A-Z][a-z]+ )+\(([A-Z]{3,5})\)' +# ... with the exception of these: +exceptions: + - API + - ASP + - CLI + - CPU + - CSS + - CSV + - DEBUG + - DOM + - DPI + - FAQ + - GCC + - GDB + - GET + - GPU + - GTK + - GUI + - HTML + - HTTP + - HTTPS + - IDE + - JAR + - JSON + - JSX + - LESS + - LLDB + - NET + - NOTE + - NVDA + - OSS + - PATH + - PDF + - PHP + - POST + - RAM + - REPL + - RSA + - SCM + - SCSS + - SDK + - SQL + - SSH + - SSL + - SVG + - TBD + - TCP + - TODO + - URI + - URL + - USB + - UTF + - XML + - XSS + - YAML + - ZIP diff --git a/.vale/styles/Google/Anthropomorphism.yml b/.vale/styles/Google/Anthropomorphism.yml new file mode 100644 index 0000000..36137a1 --- /dev/null +++ b/.vale/styles/Google/Anthropomorphism.yml @@ -0,0 +1,12 @@ +extends: existence +message: "Don't attribute human qualities to software or hardware ('%s')." +link: https://developers.google.com/style/anthropomorphism +level: suggestion +ignorecase: true +# Limited to the two verbs the guide itself names. Broader lists (wants, knows, +# thinks) can't tell a software subject from a human one: on a 950-file corpus +# they produced 8 false positives ('the customer wants', 'your audience knows') +# for every 2 real ones. +tokens: + - sees + - tells diff --git a/.vale/styles/Google/Colons.yml b/.vale/styles/Google/Colons.yml new file mode 100644 index 0000000..98972b9 --- /dev/null +++ b/.vale/styles/Google/Colons.yml @@ -0,0 +1,13 @@ +extends: existence +message: "'%s' should be in lowercase." +link: 'https://developers.google.com/style/colons' +level: warning +scope: sentence +# The match is the word itself, not ': X', and `nonword` is off. Both are +# required for a project Vocab to work: Vale compares accept.txt entries +# against the matched text, and `nonword: true` opts out of that entirely. +# So a proper noun after a colon can be exempted by adding it to accept.txt. +# The guide's other exemption, notice labels, is handled by the lookbehinds; +# headings are already excluded by `scope: sentence`. See issue #20. +tokens: + - '(?/dev/null; \ + @if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir --no-deps -e . 2>/dev/null; \ else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi install-hooks: @@ -113,6 +114,31 @@ pr-rebase: devx-pr-rebase lint-all: lint workflow-lint lint-dockerfiles @echo "[lint-all] All linting checks passed." +# ── Workflow / Ansible / Docker check tools ───────────────────────────────── +# Generic check tools ported from infra. These targets are no-ops in devx +# itself (no .gitea/workflows or ansible/ directory) but provide the +# canonical entry points for consumer repos that include devx.mak. + +check-workflow-artifact-deps: + @$(BIN)/python -m devx.ci.check_workflow_artifact_deps || \ + echo "[check-workflow-artifact-deps] No workflows directory found — skipping." + +check-workflow-tofu-init: + @$(BIN)/python -m devx.ci.check_workflow_tofu_init || \ + echo "[check-workflow-tofu-init] No workflows directory found — skipping." + +check-docker-init: + @$(BIN)/python -m devx.tools.check_docker_init || \ + echo "[check-docker-init] No ansible templates found — skipping." + +check-ansible-set-fact-to-json: + @$(BIN)/python -m devx.tools.check_ansible_set_fact_to_json || \ + echo "[check-ansible-set-fact-to-json] No ansible directory found — skipping." + +check-alert-rules: + @$(BIN)/python -m devx.tools.check_alert_rules --template-path ansible/roles/observability/templates || \ + echo "[check-alert-rules] No alert-rules template found — skipping." + # Note: Not aliased to devx-lint-dockerfiles for the same reason as setup-image — # devx's own CI images may have an older devx.mak. Consumer repos can safely alias. lint-dockerfiles: diff --git a/README.md b/README.md index 84952f1..0fba4d0 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,16 @@ opinionated CI/CD pipeline: conventional commits, automated versioning via git-cliff, squash-merge automation, Vikunja task tracking, wiki sync, and quality badges. -> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian). +> An open source project from **Oblachno** (облачно means *cloudy* in Bulgarian). [![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/e3830f8b008f0fdc97662658d77e4097fe48dfb9/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) -[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) -[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) -[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) -[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) -[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/python.svg)](https://www.python.org/downloads/) +[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) +[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) +[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) +[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) +[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) +[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/python.svg)](https://www.python.org/downloads/) ## Why devx? @@ -87,7 +87,7 @@ extra index and list devx in your dependencies: ```toml [project] dependencies = [ - "devx>=0.48.2", + "devx>=0.49.5", ] [tool.pip] @@ -101,8 +101,8 @@ pip install -e . ``` > **Note:** If your project requires a specific devx version, pin it in -> `dependencies` (for example, `"devx==0.48.2"`) or use a version constraint -> (for example, `"devx>=0.48.2,<0.49"`). +> `dependencies` (for example, `"devx==0.49.5"`) or use a version constraint +> (for example, `"devx>=0.49.5,<0.50"`). ### Optional extras diff --git a/docs/index.md b/docs/index.md index 328a978..caef7cf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,16 +8,16 @@ parallel test distribution, and more into a single installable package. It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm) project to be reusable across all oblachno-oss repositories. -> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian). +> An open source project from **Oblachno** (облачно means *cloudy* in Bulgarian). [![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/e3830f8b008f0fdc97662658d77e4097fe48dfb9/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) -[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) -[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) -[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) -[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) -[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e3830f8b008f0fdc97662658d77e4097fe48dfb9/python.svg)](https://www.python.org/downloads/) +[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) +[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) +[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) +[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) +[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) +[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8c02351c3190af51b1dfe545a7abbec665c51559/python.svg)](https://www.python.org/downloads/) ## Overview @@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry: ```toml [project] dependencies = [ - "devx>=0.48.2", + "devx>=0.49.5", ] [tool.pip] extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" ``` -Pin a specific version if needed: `"devx==0.48.2"` or `"devx>=0.48.2,<0.49"`. +Pin a specific version if needed: `"devx==0.49.5"` or `"devx>=0.49.5,<0.50"`. ### Optional extras diff --git a/docs/retrospectives/2026-07-12-self-approval-fallback-and-ci-consolidation.md b/docs/retrospectives/2026-07-12-self-approval-fallback-and-ci-consolidation.md index 7ae4196..138e395 100644 --- a/docs/retrospectives/2026-07-12-self-approval-fallback-and-ci-consolidation.md +++ b/docs/retrospectives/2026-07-12-self-approval-fallback-and-ci-consolidation.md @@ -132,7 +132,7 @@ unblocked auto-merge across all three repos. ### 2. Double-Prefix Detection (MEDIUM impact) `check_auto_merge_ready.py` now detects and rejects Vikunja task titles -that include the identifier prefix (for example, "DEVX-127: Fix..."). +that include the identifier prefix (for example, "DEVX-127: Fix"). The validator adds the prefix automatically, so a double prefix would fail validation. diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md index b124544..e5c6864 100644 --- a/docs/tech/architecture.md +++ b/docs/tech/architecture.md @@ -86,11 +86,11 @@ overridden via environment variables with the `DEVX_` prefix. Provides: - `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints - `REPO_OWNER` — repository owner (must be set per-project) -- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (for example, `DEVX-N`) +- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regular expression (for example, `DEVX-N`) - `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking - `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults - `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config -- `CONVENTIONAL_RE` — conventional commit format regex +- `CONVENTIONAL_RE` — conventional commit format regular expression ### `exceptions.py` @@ -108,7 +108,7 @@ wraps user-facing strings for translation. Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a custom JSON file. Keys from the project's file are merged on top of devx's -built-in translations, allowing projects to override or add keys without +built-in translations, allowing projects to override, or add keys without modifying the package. ### `api_clients.py` @@ -170,7 +170,7 @@ from `devx.api_clients`, `devx.config`, `devx.gitea_cli`, and `devx.i18n`. Automated release using git-cliff. Calculates the next semver version from conventional commits since the last tag, updates `__version__` in -`__init__.py` and `CHANGELOG.md`, runs lint and tests to verify the release +`__init__.py` and `CHANGELOG.md`, runs lint, and tests to verify the release is healthy, commits with `release: vX.Y.Z [skip ci]`, creates an annotated tag, and pushes both to master. @@ -287,7 +287,7 @@ Click commands from `cli.py` and verifies each has documentation in ### `discover_runners.py` Discovers available Gitea Actions runners at three levels: repository, -organization, and instance (admin). Falls back to the `MOLECULE_RUNNERS` repo +organization, and instance (administrator). Falls back to the `MOLECULE_RUNNERS` repo variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index array for use as a dynamic matrix in Gitea Actions. @@ -329,7 +329,7 @@ Supports `--tool` to install specific tools and `--list` to show status. Runs unit tests and enforces execution-time budgets. Two quality gates: total suite time must not exceed `--max-seconds` (default: 10s), and no individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to -disable). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`. +off). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`. ### `check_test_isolation.py` diff --git a/docs/user/cli-commands.md b/docs/user/cli-commands.md index 3fdf9d1..8d90026 100644 --- a/docs/user/cli-commands.md +++ b/docs/user/cli-commands.md @@ -85,7 +85,7 @@ devx ci detect-release-commit Discover available Gitea Actions runners for dynamic job distribution. Queries the Gitea API for registered runners at repository, organization, and -instance (admin) levels. Falls back to `MOLECULE_RUNNERS` repo variable or +instance (administrator) levels. Falls back to `MOLECULE_RUNNERS` repo variable or `DEFAULT_MAX_RUNNERS` (3). ```bash @@ -315,6 +315,56 @@ devx ci validate-commit-msg commit-msg.txt --branch master Options: - `--branch ` — override branch detection (for CI use) +### `devx ci cancel-superseded-runs` + +Cancel in-flight CI runs for the same PR branch when a new push triggers +a new run. Uses the Gitea Actions API to list running pull_request runs +and cancel those with a lower run ID on the same branch. + +```bash +devx ci cancel-superseded-runs \ + --repo "$REPOSITORY" \ + --current-run-id "$GITHUB_RUN_ID" \ + --head-branch "$HEAD_REF" +``` + +Options: +- `--repo ` — repository (required) +- `--current-run-id ` — current run ID, not cancelled (required) +- `--head-branch ` — PR head branch name (required) +- `--dry-run` — list superseded runs without cancelling +- `--base-url ` — Gitea base URL (default: `GITEA_API_URL` env var) + +### `devx ci check-workflow-artifact-deps` + +Verify that workflow jobs downloading artifacts depend on the uploading +job. Prevents the class of bug where a download job runs in parallel +with the upload job and fails because the artifact isn't available yet. + +```bash +devx ci check-workflow-artifact-deps +devx ci check-workflow-artifact-deps --workflow .gitea/workflows/ci.yml +``` + +Options: +- `--workflow ` — check a specific workflow file +- `--workflows-dir ` — override workflows directory + +### `devx ci check-workflow-tofu-init` + +Verify that workflow jobs using tofu state (tofu output/plan/apply or +scripts that call them) have a tofu-init step in the same job. + +```bash +devx ci check-workflow-tofu-init +devx ci check-workflow-tofu-init --workflow .gitea/workflows/deploy.yml +``` + +Options: +- `--workflow ` — check a specific workflow file +- `--workflows-dir ` — override workflows directory +- `--state-script ` — add a script that uses tofu state (repeatable) + ## Tools Commands ### `devx tools check-test-speed` @@ -323,7 +373,7 @@ Run unit tests and enforce execution-time budgets. Two quality gates: - **Total suite time** must not exceed `--max-seconds` (default: 10s) - **Per-test time** — no individual test may exceed `--max-single-seconds` - (default: 0.5s, 0 to disable) + (default: 0.5s, 0 to turn off) Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0` so pytest emits per-test timing lines. @@ -369,7 +419,7 @@ devx tools check-test-isolation --src-dir src/ Pytest plugin options (automatic when devx is installed): -- `--no-test-isolation` — disable static analysis and runtime subprocess audit +- `--no-test-isolation` — turn off static analysis and runtime subprocess audit - `--test-isolation-max-loop N` — max iterations per loop (default: 100) ### `devx tools configure-repo` @@ -414,7 +464,7 @@ devx tools generate-cliff-config --prefix GRM --force # overwrite existing Options: - `--prefix ` — task ID prefix (default: `DEVX_TASK_PREFIX` env var or `DEVX`) -- `--output ` — output file path (default: `cliff.toml`) +- `--output ` — output path (default: `cliff.toml`) - `--force` — overwrite existing file ### `devx tools install-checkmake` @@ -488,6 +538,56 @@ devx tools pr-rebase # auto-detect PR from current branch Options (pass after `--`): - `--pr ` — PR number (auto-detected from current branch if omitted) +### `devx tools check-docker-init` + +Check that Docker Compose services with healthchecks have `init: true`. +Without `init: true`, CMD-SHELL healthchecks spawn child processes that +become zombies when PID 1 doesn't reap them. + +```bash +devx tools check-docker-init +devx tools check-docker-init --path path/to/docker-compose.yml.j2 +``` + +Options: +- `--path ` — check a specific file or directory +- `--templates-dir ` — override templates directory (default: `ansible/roles/`) + +### `devx tools check-ansible-set-fact-to-json` + +Check that Ansible `set_fact` tasks don't misuse `| to_json`. Using +`to_json` in `set_fact` converts native Python types to JSON strings, +causing iteration bugs (for example, iterating over characters instead +of list items). + +```bash +devx tools check-ansible-set-fact-to-json +devx tools check-ansible-set-fact-to-json --path path/to/playbook.yml +``` + +Options: +- `--path ` — check a specific file or directory +- `--ansible-dir ` — override ansible directories (repeatable) + +### `devx tools check-alert-rules` + +Validate rendered Prometheus alert rules with `promtool check rules`. +Renders a Jinja2 template with test values and validates the output. +Skips (exits 0) if promtool is not on PATH. + +```bash +devx tools check-alert-rules \ + --template-path ansible/roles/observability/templates +devx tools check-alert-rules \ + --template-path ansible/roles/observability/templates \ + --var grafana_base_url=https://grafana.example.com +``` + +Options: +- `--template-path ` — path to templates directory (required) +- `--template-name ` — template filename (default: `alert-rules.yml.j2`) +- `--var key=value` — template variables (repeatable) + ## Molecule Commands Molecule commands require the `molecule` extra (`pip install devx[molecule]`). diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index 17ffa52..7e9684d 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`: ```toml [project] dependencies = [ - "devx>=0.48.2", + "devx>=0.49.5", ] [project.optional-dependencies] dev = [ - "devx>=0.48.2", + "devx>=0.49.5", ] ``` diff --git a/pyproject.toml b/pyproject.toml index 4afef67..3ad7e25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ "python-dotenv==1.2.2", "click==8.4.2", "tenacity==9.1.4", # retry logic for GiteaClient/VikunjaClient + "jinja2==3.1.6", # template rendering (devx.utils.jinja, check_alert_rules) + "pyyaml==6.0.3", # YAML parsing (workflow checks, ansible checks) ] [project.scripts] @@ -62,13 +64,16 @@ molecule = [ "ansible-core==2.21.1", ] # Deploy tools (for infra staging/production deployments) +# Versions aligned with infra's pyproject.toml to avoid reinstalls on every CI job. +# bcrypt and PyJWT are infra deps not in devx core — included here so the CI +# image has them and setup-image can use --no-deps (skip dep resolution). deploy = [ "ansible-core==2.21.1", - "boto3==1.43.37", + "boto3==1.43.44", "docker==7.1.0", - "jinja2==3.1.6", - "pyyaml==6.0.3", - "cryptography==49.0.0", + "cryptography==50.0.0", + "bcrypt==5.0.0", + "PyJWT==2.13.0", ] # Full dev environment (local development) dev = [ diff --git a/src/devx/__init__.py b/src/devx/__init__.py index a7a498c..7b6012f 100644 --- a/src/devx/__init__.py +++ b/src/devx/__init__.py @@ -1,3 +1,3 @@ """devx — reusable development and CI/CD tools for oblachno-oss projects.""" -__version__ = "0.48.2" +__version__ = "0.49.5" diff --git a/src/devx/ci/cancel_superseded_runs.py b/src/devx/ci/cancel_superseded_runs.py new file mode 100644 index 0000000..dc75364 --- /dev/null +++ b/src/devx/ci/cancel_superseded_runs.py @@ -0,0 +1,185 @@ +"""Cancel superseded CI runs for the same PR. + +When a new push to a PR branch triggers a new CI run, any in-flight +runs for the same PR are wasting runner time. This script cancels +all but the latest running CI run for each PR branch. + +Uses the Gitea Actions API: + GET /repos/{owner}/{repo}/actions/runs?status=in_progress&event=pull_request + POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel + +Usage:: + + # CI (cancels superseded runs for the current PR): + python -m devx.ci.cancel_superseded_runs \\ + --repo "$REPOSITORY" \\ + --current-run-id "$GITHUB_RUN_ID" \\ + --head-branch "$HEAD_REF" + + # Dry-run (lists what would be cancelled without cancelling): + python -m devx.ci.cancel_superseded_runs \\ + --repo "$REPOSITORY" \\ + --current-run-id "$GITHUB_RUN_ID" \\ + --head-branch "$HEAD_REF" \\ + --dry-run +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + +_HTTP_NO_CONTENT = 204 +_HTTP_NOT_FOUND = 404 +_HTTP_BAD_REQUEST = 400 +_PAGE_SIZE = 50 + + +def _log(msg: str) -> None: + """Log to stderr.""" + print(f"[cancel-superseded] {msg}", file=sys.stderr, flush=True) + + +def _api_request( + method: str, + path: str, + token: str, + base_url: str, + body: dict | None = None, +) -> dict | list: + """Make a Gitea API request.""" + url = f"{base_url}/api/v1{path}" + headers = { + "Authorization": f"token {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as resp: # nosec B310 — authenticated API request to known Gitea instance + if resp.status == _HTTP_NO_CONTENT: + return {} + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + _log(f"API error {e.code} on {method} {path}: {e.read().decode()[:200]}") + raise + except urllib.error.URLError as e: + _log(f"URL error on {method} {path}: {e}") + raise + + +def list_running_runs(repo: str, token: str, base_url: str) -> list[dict]: + """List all running CI runs for pull_request events.""" + runs: list[dict] = [] + page = 1 + while True: + result = _api_request( + "GET", + f"/repos/{repo}/actions/runs?status=in_progress&event=pull_request&page={page}&limit=50", + token, + base_url, + ) + # Gitea returns {"workflow_runs": [...], "total_count": N} + page_runs = result["workflow_runs"] if isinstance(result, dict) else result + if not page_runs: + break + runs.extend(page_runs) + if len(page_runs) < _PAGE_SIZE: + break + page += 1 + return runs + + +def cancel_run(repo: str, run_id: int, token: str, base_url: str) -> bool: + """Cancel a CI run. Returns True on success.""" + try: + _api_request( + "POST", + f"/repos/{repo}/actions/runs/{run_id}/cancel", + token, + base_url, + ) + except (urllib.error.HTTPError, urllib.error.URLError): + return False + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cancel superseded CI runs for the same PR.") + parser.add_argument("--repo", required=True, help="owner/repo") + parser.add_argument("--current-run-id", required=True, help="Current run ID (not cancelled)") + parser.add_argument("--head-branch", required=True, help="PR head branch name") + parser.add_argument("--dry-run", action="store_true", help="List without cancelling") + parser.add_argument( + "--base-url", + default=os.environ.get("GITEA_API_URL", "https://git.oblachno.oblachno.fyi"), + help="Gitea base URL", + ) + args = parser.parse_args() + + token = os.environ.get("CI_GITEA_API_TOKEN") or os.environ.get("CI_GITEA_TOKEN") + if not token: + _log("No CI_GITEA_API_TOKEN or CI_GITEA_TOKEN set — skipping") + return 0 + + current_run_id = int(args.current_run_id) + + _log(f"Listing running PR runs for {args.repo}...") + try: + runs = list_running_runs(args.repo, token, args.base_url) + except urllib.error.HTTPError as e: + if e.code in (_HTTP_NOT_FOUND, _HTTP_BAD_REQUEST): + _log( + f"Actions runs API not usable (HTTP {e.code}) — " + f"Gitea {args.base_url} may not support this endpoint or status filter. " + f"Skipping cancel-superseded (non-fatal)." + ) + return 0 + raise + _log(f"Found {len(runs)} running PR runs") + + # Group by head_branch — only cancel runs for the SAME branch + # that are older than the current run + same_branch_runs = [ + r + for r in runs + if r.get("head_branch") == args.head_branch + and int(r.get("id", 0)) != current_run_id + and int(r.get("id", 0)) < current_run_id + ] + + if not same_branch_runs: + _log(f"No superseded runs for branch {args.head_branch}") + return 0 + + _log(f"Found {len(same_branch_runs)} superseded run(s) for branch {args.head_branch}:") + for r in same_branch_runs: + run_id = r.get("id") + created = r.get("created_at", "?") + _log(f" Run #{run_id} (created: {created})") + + if args.dry_run: + _log("[dry-run] Would cancel the above runs") + return 0 + + cancelled = 0 + for r in same_branch_runs: + run_id = int(r["id"]) + _log(f"Cancelling run #{run_id}...") + if cancel_run(args.repo, run_id, token, args.base_url): + cancelled += 1 + _log(f" Cancelled run #{run_id}") + else: + _log(f" Failed to cancel run #{run_id}") + + _log(f"Cancelled {cancelled}/{len(same_branch_runs)} superseded runs") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/devx/ci/check_workflow_artifact_deps.py b/src/devx/ci/check_workflow_artifact_deps.py new file mode 100644 index 0000000..9f65133 --- /dev/null +++ b/src/devx/ci/check_workflow_artifact_deps.py @@ -0,0 +1,163 @@ +"""Check that workflow jobs downloading artifacts depend on the uploading job. + +This prevents the class of bug where a job downloads an artifact produced by +another job but does not declare that job in its ``needs`` list. When both +jobs run in parallel, the download fails because the artifact hasn't been +uploaded yet. + +The check scans all workflow YAML files for: + - ``gitea-upload-artifact`` / ``actions/upload-artifact`` steps + - ``gitea-download-artifact`` / ``actions/download-artifact`` steps + +For each download, it finds the job(s) that upload an artifact with a +matching name and verifies that at least one uploading job is in the +downloading job's ``needs`` list. + +Artifact names with ``${{ ... }}`` expressions are matched literally +(both sides use the same expression, so they resolve to the same value +at runtime). + +Usage:: + + python -m devx.ci.check_workflow_artifact_deps + python -m devx.ci.check_workflow_artifact_deps --workflow .gitea/workflows/ci.yml + +Exit code 0 if all artifact dependencies are satisfied, 1 otherwise. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click +import yaml + +REPO_ROOT = Path.cwd() +WORKFLOWS_DIR = REPO_ROOT / ".gitea" / "workflows" + +UPLOAD_ACTIONS = ("upload-artifact",) +DOWNLOAD_ACTIONS = ("download-artifact",) + + +def _is_artifact_action(uses: str, action_types: tuple[str, ...]) -> bool: + """Check if a step's ``uses`` field references an artifact action.""" + if not uses: + return False + uses_lower = uses.lower() + return any(action in uses_lower for action in action_types) + + +def _extract_artifact_info(workflow: dict) -> tuple[dict[str, list[str]], list[tuple[str, str, str]]]: + """Extract artifact upload and download info from a workflow. + + Returns: + uploads: Mapping of artifact_name → list of job names that upload it. + downloads: List of (job_name, artifact_name, step_name) tuples. + """ + uploads: dict[str, list[str]] = {} + downloads: list[tuple[str, str, str]] = [] + + jobs = workflow.get("jobs", {}) + for job_name, job_def in jobs.items(): + for step in job_def.get("steps", []): + uses = step.get("uses", "") + with_data = step.get("with", {}) + artifact_name = with_data.get("name", "") + step_name = step.get("name", "") + + if _is_artifact_action(uses, UPLOAD_ACTIONS): + if artifact_name: + uploads.setdefault(artifact_name, []).append(job_name) + elif _is_artifact_action(uses, DOWNLOAD_ACTIONS) and artifact_name: + downloads.append((job_name, artifact_name, step_name)) + + return uploads, downloads + + +def _check_workflow(filepath: Path) -> list[str]: + """Check a single workflow file for missing artifact dependencies. + + Returns a list of error messages (empty if all OK). + """ + errors: list[str] = [] + content = filepath.read_text(encoding="utf-8") + try: + workflow = yaml.safe_load(content) + except yaml.YAMLError as exc: + return [f"{filepath}: cannot parse YAML: {exc}"] + + if not isinstance(workflow, dict): + return [f"{filepath}: not a valid workflow (expected dict)"] + + uploads, downloads = _extract_artifact_info(workflow) + jobs = workflow.get("jobs", {}) + + for dl_job, artifact_name, step_name in downloads: + uploading_jobs = uploads.get(artifact_name, []) + if not uploading_jobs: + # Artifact not uploaded in this workflow — may come from an + # external source (e.g., S3). Skip. + continue + + dl_job_def = jobs.get(dl_job, {}) + needs_raw = dl_job_def.get("needs", []) + needs = {needs_raw} if isinstance(needs_raw, str) else set(needs_raw or []) + + # Check if any uploading job is in the download job's needs + if not any(uploader in needs for uploader in uploading_jobs): + # Check if the download step has continue-on-error: true + # (valid guard when the uploading job may be skipped due to + # Gitea Actions' needs skip behavior — the download will + # fail gracefully if the artifact doesn't exist). + dl_steps = dl_job_def.get("steps", []) + step_def = next((s for s in dl_steps if s.get("name", "") == step_name), {}) + if step_def.get("continue-on-error") is True: + continue + + uploaders_str = ", ".join(sorted(uploading_jobs)) + errors.append( + f"{filepath.name}::{dl_job}: step '{step_name}' downloads " + f"artifact '{artifact_name}' produced by job(s) " + f"[{uploaders_str}] but none are in its 'needs' list " + f"(current needs: {sorted(needs) or 'none'}). " + f"Add the uploading job to 'needs' or guard the download " + f"with an if: condition checking the upload job's result." + ) + + return errors + + +@click.command() +@click.option( + "--workflow", + type=click.Path(exists=True, path_type=Path), + help="Check a specific workflow file (default: all in .gitea/workflows/).", +) +@click.option( + "--workflows-dir", + type=click.Path(exists=True, path_type=Path), + default=None, + help="Override the workflows directory (default: .gitea/workflows/).", +) +def main(workflow: Path | None, workflows_dir: Path | None) -> None: + """Check that artifact download jobs depend on upload jobs.""" + wdir = workflows_dir or WORKFLOWS_DIR + files = [workflow] if workflow else sorted(wdir.glob("*.yml")) + + all_errors: list[str] = [] + for f in files: + errors = _check_workflow(f) + all_errors.extend(errors) + + if all_errors: + click.echo("[check-workflow-artifact-deps] FAIL: missing artifact dependencies found:") + for err in all_errors: + click.echo(f" - {err}") + sys.exit(1) + else: + click.echo("[check-workflow-artifact-deps] OK: all artifact downloads have upload jobs in needs.") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/ci/check_workflow_tofu_init.py b/src/devx/ci/check_workflow_tofu_init.py new file mode 100644 index 0000000..280d40e --- /dev/null +++ b/src/devx/ci/check_workflow_tofu_init.py @@ -0,0 +1,145 @@ +"""Check that workflow jobs using tofu state have a tofu-init step. + +This prevents the class of bug where a job runs ``tofu output`` or calls +a script that uses tofu state without first running ``tofu init``, +causing "Required plugins are not installed" errors. + +The check scans all workflow YAML files for jobs that: + - Call scripts that use ``tofu output`` (configurable via --state-scripts) + - Call ``tofu output`` directly + - Call ``tofu plan`` or ``tofu apply`` directly + +For each such job, it verifies the same job has a ``tofu-init`` step, +either: + - Directly via ``tofu init`` in a step's run command + - Via ``create_staging_deployment.py --phase tofu-init`` + - Via ``create_production_deployment.py --phase tofu-init`` + +Usage:: + + python -m devx.ci.check_workflow_tofu_init + python -m devx.ci.check_workflow_tofu_init --workflow .gitea/workflows/deploy.yml + +Exit code 0 if all jobs have tofu-init, 1 otherwise. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click +import yaml + +REPO_ROOT = Path.cwd() +WORKFLOWS_DIR = REPO_ROOT / ".gitea" / "workflows" + +# Scripts that call `tofu output`, `tofu plan`, or `tofu apply` internally. +# If a job calls any of these, it must have a tofu-init step. +# NOTE: destroy_orphans.py reads terraform.tfstate directly from disk +# (does not invoke `tofu output`), so it does NOT need tofu-init. +DEFAULT_TOFU_STATE_SCRIPTS: set[str] = { + "preflight_deploy.py", +} + +# Commands that directly use tofu state (must be preceded by tofu init). +TOFU_STATE_COMMANDS = ("tofu output", "tofu plan", "tofu apply", "tofu show") + +# Commands that initialize tofu (counted as tofu-init steps). +TOFU_INIT_COMMANDS = ( + "tofu init", + "--phase tofu-init", + "tofu-init", +) + + +def _check_workflow(filepath: Path, state_scripts: set[str]) -> list[str]: + """Check a single workflow file for missing tofu-init steps. + + Returns a list of error messages (empty if all OK). + """ + errors: list[str] = [] + content = filepath.read_text(encoding="utf-8") + try: + workflow = yaml.safe_load(content) + except yaml.YAMLError as exc: + return [f"{filepath}: cannot parse YAML: {exc}"] + + jobs = workflow.get("jobs", {}) + for job_name, job_def in jobs.items(): + steps = job_def.get("steps", []) + if not steps: + continue + + uses_tofu_state = False + has_tofu_init = False + + for step in steps: + run_cmd = step.get("run", "") + if not run_cmd: + continue + # Check if this step uses tofu state + for script in state_scripts: + if script in run_cmd: + uses_tofu_state = True + for cmd in TOFU_STATE_COMMANDS: + if cmd in run_cmd: + uses_tofu_state = True + # Check if this step initializes tofu + for cmd in TOFU_INIT_COMMANDS: + if cmd in run_cmd: + has_tofu_init = True + + if uses_tofu_state and not has_tofu_init: + errors.append( + f"{filepath.name}::{job_name}: uses tofu state " + f"(tofu output/plan/apply or {state_scripts}) " + f"but has no tofu-init step. Add a step running " + f"'create_*_deployment.py --phase tofu-init' before " + f"the first tofu state access." + ) + + return errors + + +@click.command() +@click.option( + "--workflow", + type=click.Path(exists=True, path_type=Path), + help="Check a specific workflow file (default: all in .gitea/workflows/).", +) +@click.option( + "--workflows-dir", + type=click.Path(exists=True, path_type=Path), + default=None, + help="Override the workflows directory (default: .gitea/workflows/).", +) +@click.option( + "--state-script", + "state_scripts", + multiple=True, + default=None, + help="Add a script name that uses tofu state (can be repeated). Overrides the default list if any are specified.", +) +def main(workflow: Path | None, workflows_dir: Path | None, state_scripts: tuple[str, ...]) -> None: + """Check that workflow jobs using tofu state have a tofu-init step.""" + scripts = set(state_scripts) if state_scripts else DEFAULT_TOFU_STATE_SCRIPTS + wdir = workflows_dir or WORKFLOWS_DIR + files = [workflow] if workflow else sorted(wdir.glob("*.yml")) + + all_errors: list[str] = [] + for f in files: + errors = _check_workflow(f, scripts) + all_errors.extend(errors) + + if all_errors: + click.echo("[check-workflow-tofu-init] FAIL: missing tofu-init steps found:") + for err in all_errors: + click.echo(f" - {err}") + sys.exit(1) + else: + click.echo("[check-workflow-tofu-init] OK: all tofu-state jobs have tofu-init.") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/cli.py b/src/devx/cli.py index ac02e4c..758e33f 100644 --- a/src/devx/cli.py +++ b/src/devx/cli.py @@ -172,6 +172,27 @@ def ci_integration_guard(args: tuple[str, ...]) -> None: _run_module("devx.ci.integration_guard", list(args)) +@ci.command("cancel-superseded-runs") +@click.argument("args", nargs=-1) +def ci_cancel_superseded_runs(args: tuple[str, ...]) -> None: + """Cancel superseded CI runs for the same PR branch.""" + _run_module("devx.ci.cancel_superseded_runs", list(args)) + + +@ci.command("check-workflow-artifact-deps") +@click.argument("args", nargs=-1) +def ci_check_workflow_artifact_deps(args: tuple[str, ...]) -> None: + """Check that artifact download jobs depend on upload jobs.""" + _run_module("devx.ci.check_workflow_artifact_deps", list(args)) + + +@ci.command("check-workflow-tofu-init") +@click.argument("args", nargs=-1) +def ci_check_workflow_tofu_init(args: tuple[str, ...]) -> None: + """Check that workflow jobs using tofu state have a tofu-init step.""" + _run_module("devx.ci.check_workflow_tofu_init", list(args)) + + @cli.group() def tools() -> None: """Development tool commands.""" @@ -240,6 +261,27 @@ def tools_pr_rebase(args: tuple[str, ...]) -> None: _run_module("devx.tools.pr_rebase", list(args)) +@tools.command("check-docker-init") +@click.argument("args", nargs=-1) +def tools_check_docker_init(args: tuple[str, ...]) -> None: + """Check that Docker Compose services with healthchecks have init: true.""" + _run_module("devx.tools.check_docker_init", list(args)) + + +@tools.command("check-ansible-set-fact-to-json") +@click.argument("args", nargs=-1) +def tools_check_ansible_set_fact_to_json(args: tuple[str, ...]) -> None: + """Check that Ansible set_fact tasks don't misuse to_json.""" + _run_module("devx.tools.check_ansible_set_fact_to_json", list(args)) + + +@tools.command("check-alert-rules") +@click.argument("args", nargs=-1) +def tools_check_alert_rules(args: tuple[str, ...]) -> None: + """Validate rendered Prometheus alert rules with promtool.""" + _run_module("devx.tools.check_alert_rules", list(args)) + + @cli.group() def molecule() -> None: """Molecule testing commands (requires devx[molecule]).""" diff --git a/src/devx/i18n.py b/src/devx/i18n.py index 4b9329e..06f364e 100644 --- a/src/devx/i18n.py +++ b/src/devx/i18n.py @@ -6,6 +6,10 @@ Supported: en, bg, de, ru, zh, pl. Projects can extend translations by setting DEVX_TRANSLATIONS_PATH to a JSON file with additional keys. Keys from the project's file are merged on top of devx's built-in translations. + +Projects that use different env var names (e.g. GRM_LANG instead of +DEVX_LANG) can call :func:`configure_i18n` at import time to override +the defaults. """ from __future__ import annotations @@ -14,15 +18,39 @@ import json import os from pathlib import Path +# Configurable env var names — projects can override via configure_i18n() +_lang_env_var = "DEVX_LANG" +_translations_path_env_var = "DEVX_TRANSLATIONS_PATH" + # Load built-in translations _BUILTIN_TRANSLATIONS: dict[str, dict[str, str]] = json.loads( (Path(__file__).parent / "translations.json").read_text(encoding="utf-8") ) +def configure_i18n( + *, + lang_env_var: str = "DEVX_LANG", + translations_path_env_var: str = "DEVX_TRANSLATIONS_PATH", +) -> None: + """Override the env var names used for language and translations path. + + This allows downstream projects (e.g. grm) to use their own env var + names (e.g. ``GRM_LANG``) while still using devx's i18n system. + + Args: + lang_env_var: Environment variable name for language selection. + translations_path_env_var: Environment variable name for the + path to a JSON file with project-specific translations. + """ + global _lang_env_var, _translations_path_env_var + _lang_env_var = lang_env_var + _translations_path_env_var = translations_path_env_var + + def _load_project_translations() -> dict[str, dict[str, str]]: - """Load project-specific translations from DEVX_TRANSLATIONS_PATH if set.""" - path = os.getenv("DEVX_TRANSLATIONS_PATH") + """Load project-specific translations from the configured env var if set.""" + path = os.getenv(_translations_path_env_var) if not path: return {} p = Path(path) @@ -41,10 +69,11 @@ TRANSLATIONS: dict[str, dict[str, str]] = {**_BUILTIN_TRANSLATIONS, **_load_proj def _(key: str, **kwargs: object) -> str: """Return a translated string for the given key. - Translation is opt-in via the ``DEVX_LANG`` environment variable. - If unset, English is always returned regardless of system locale. + Translation is opt-in via the configured language environment variable + (default ``DEVX_LANG``). If unset, English is always returned regardless + of system locale. """ - lang = os.getenv("DEVX_LANG", "en") + lang = os.getenv(_lang_env_var, "en") if lang not in ("en", "bg", "de", "ru", "zh", "pl"): lang = "en" template = TRANSLATIONS.get(key, {}).get(lang, key) diff --git a/src/devx/molecule/distribute_molecule.py b/src/devx/molecule/distribute_molecule.py index a1f5841..7d27274 100644 --- a/src/devx/molecule/distribute_molecule.py +++ b/src/devx/molecule/distribute_molecule.py @@ -104,21 +104,36 @@ def discover_scenarios(root: Path | None = None) -> list[str]: return sorted(scenarios) -def discover_multi_role_scenarios(roles_root: Path | None = None) -> list[tuple[str, str]]: +def discover_multi_role_scenarios( + roles_root: Path | None = None, + include_roles: list[str] | None = None, + exclude_roles: list[str] | None = None, +) -> list[tuple[str, str]]: """Discover (role, scenario) pairs across all roles under *roles_root*. Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping ``common`` and directories starting with ``_``. Returns a sorted list of ``(role_name, scenario_name)`` tuples. + + If *include_roles* is given, only roles whose name is in the list are + returned. If *exclude_roles* is given, roles whose name is in the list + are skipped. Both filters are case-insensitive. """ if roles_root is None: roles_root = DEFAULT_ROLES_ROOT if not roles_root.is_dir(): raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root))) + include_set = {r.lower() for r in include_roles} if include_roles else None + exclude_set = {r.lower() for r in exclude_roles} if exclude_roles else None pairs: list[tuple[str, str]] = [] for role_dir in sorted(roles_root.iterdir()): if not role_dir.is_dir(): continue + role_name = role_dir.name + if include_set is not None and role_name.lower() not in include_set: + continue + if exclude_set is not None and role_name.lower() in exclude_set: + continue mol_dir = role_dir / "molecule" if not mol_dir.is_dir(): continue @@ -348,6 +363,24 @@ def _write_github_env(key: str, value: str) -> None: help="JSON file with custom platform list (each entry: name, image, command). " "Overrides the default platform matrix. Useful for projects with custom test images.", ) +@click.option( + "--include-roles", + "include_roles", + type=str, + default=None, + help="Comma-separated list of role names to include (multi-role mode only). " + "Only scenarios from these roles are distributed. Case-insensitive. " + "Example: --include-roles docker_base,crowdsec,disk_cleanup,app_hardening", +) +@click.option( + "--exclude-roles", + "exclude_roles", + type=str, + default=None, + help="Comma-separated list of role names to exclude (multi-role mode only). " + "Scenarios from these roles are skipped. Case-insensitive. " + "Example: --exclude-roles docker_base,crowdsec,disk_cleanup,app_hardening", +) def cli( runner_index: int | None, max_runners: int, @@ -358,11 +391,18 @@ def cli( molecule_root: Path | None, roles_root: Path | None, platforms_file: Path | None, + include_roles: str | None, + exclude_roles: str | None, ) -> None: platforms = load_platforms(platforms_file) + # Parse role filters + include_list = [r.strip() for r in include_roles.split(",")] if include_roles else None + exclude_list = [r.strip() for r in exclude_roles.split(",")] if exclude_roles else None # Multi-role mode: discover (role, scenario) pairs across all roles if roles_root is not None: - role_scenarios = discover_multi_role_scenarios(roles_root) + role_scenarios = discover_multi_role_scenarios( + roles_root, include_roles=include_list, exclude_roles=exclude_list + ) if list_all: for role, scenario in role_scenarios: click.echo(f"{role}|{scenario}") diff --git a/src/devx/tools/check_alert_rules.py b/src/devx/tools/check_alert_rules.py new file mode 100644 index 0000000..db59fcf --- /dev/null +++ b/src/devx/tools/check_alert_rules.py @@ -0,0 +1,86 @@ +"""Validate Prometheus alert rules with promtool check rules. + +Renders an alert-rules Jinja2 template with test values and validates +the output with ``promtool check rules``. Exits 0 if valid, non-zero +otherwise. Skips (exits 0) if promtool is not on PATH. + +Usage:: + + python -m devx.tools.check_alert_rules \\ + --template-path ansible/roles/observability/templates \\ + --template-name alert-rules.yml.j2 + + # With extra template variables: + python -m devx.tools.check_alert_rules \\ + --template-path ansible/roles/observability/templates \\ + --template-name alert-rules.yml.j2 \\ + --var grafana_base_url=https://grafana.test.example.com +""" + +from __future__ import annotations + +import shutil +import subprocess # nosec B404 — used to run promtool, a trusted binary +import sys +import tempfile +from pathlib import Path + +import click + +from devx.utils.jinja import make_env, render_template + + +@click.command() +@click.option( + "--template-path", + type=click.Path(exists=True, path_type=Path), + required=True, + help="Path to the directory containing the Jinja2 template.", +) +@click.option( + "--template-name", + default="alert-rules.yml.j2", + help="Name of the Jinja2 template file to render.", +) +@click.option( + "--var", + "template_vars", + multiple=True, + help="Template variables in key=value format (can be repeated). " + "Example: --var grafana_base_url=https://grafana.example.com", +) +def main(template_path: Path, template_name: str, template_vars: tuple[str, ...]) -> None: + """Validate rendered alert rules with promtool.""" + if not shutil.which("promtool"): + click.echo("promtool not found in PATH — skipping alert rules validation") + return + + # Parse template variables + kwargs: dict[str, str] = {} + for v in template_vars: + if "=" in v: + key, value = v.split("=", 1) + kwargs[key] = value + + env = make_env(str(template_path)) + output = render_template(env, template_name, **kwargs) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + f.write(output) + tmp_path = f.name + + click.echo("[check-alert-rules] Validating rendered rules with promtool...") + result = subprocess.run( # nosec + ["promtool", "check", "rules", tmp_path], + capture_output=True, + text=True, + check=False, + ) + click.echo(result.stdout, nl=False) + if result.returncode != 0: + click.echo(result.stderr, nl=False, err=True) + sys.exit(result.returncode) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/tools/check_ansible_set_fact_to_json.py b/src/devx/tools/check_ansible_set_fact_to_json.py new file mode 100644 index 0000000..6139e79 --- /dev/null +++ b/src/devx/tools/check_ansible_set_fact_to_json.py @@ -0,0 +1,196 @@ +"""Check that Ansible ``set_fact`` tasks don't misuse ``| to_json``. + +This prevents the class of bug where ``set_fact`` tasks use +``{{ targets | to_json }}`` to store Python lists, but ``to_json`` +converts native types to JSON strings. Ansible then stored the result +as a string, so iterating over the fact yielded individual characters +instead of list items, causing ``object of type 'str' has no attribute +'ip'`` errors. + +The check scans all Ansible task files (playbooks and role tasks) for +``set_fact`` tasks where any value uses ``| to_json`` or ``| to_nice_json`` +and flags them as potential bugs. + +``| to_json`` is legitimate in Jinja2 templates (e.g., rendering JSON +config files) but almost never correct in ``set_fact`` — the fact should +store the native Python type so downstream tasks can iterate/index it. + +Usage:: + + python -m devx.tools.check_ansible_set_fact_to_json + python -m devx.tools.check_ansible_set_fact_to_json --path ansible/playbooks/deploy.yml + +Exit code 0 if no misuses found, 1 otherwise. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click +import yaml + +REPO_ROOT = Path.cwd() +DEFAULT_ANSIBLE_DIRS: list[Path] = [ + REPO_ROOT / "ansible" / "playbooks", + REPO_ROOT / "ansible" / "roles", +] + +TO_JSON_FILTERS = ("| to_json", "| to_nice_json", "|to_json", "|to_nice_json") + + +def _find_task_files(base: Path) -> list[Path]: + """Find all YAML task files under a base directory.""" + if base.is_file() and base.suffix in (".yml", ".yaml"): + return [base] + if not base.is_dir(): + return [] + return sorted(base.rglob("*.yml")) + sorted(base.rglob("*.yaml")) + + +def _check_file(filepath: Path, repo_root: Path) -> list[str]: + """Check a single YAML file for set_fact + to_json misuse. + + Returns a list of error messages (empty if all OK). + """ + errors: list[str] = [] + content = filepath.read_text(encoding="utf-8") + + # Multi-document YAML (--- separators) is common in playbooks + try: + docs = list(yaml.safe_load_all(content)) + except yaml.YAMLError as exc: + return [f"{filepath}: cannot parse YAML: {exc}"] + + for doc in docs: + if isinstance(doc, list): + # Could be a playbook (list of plays) or a role tasks file (list of tasks) + for item in doc: + if isinstance(item, dict): + if any(k in item for k in ("tasks", "pre_tasks", "post_tasks", "handlers", "roles")): + # It's a play + _check_tasks(item, filepath, errors, repo_root) + else: + # It's a bare task (role tasks file) + _check_task(item, filepath, errors, repo_root) + block = item.get("block") + if isinstance(block, list): + _check_task_list(block, filepath, errors, repo_root) + elif isinstance(doc, dict): + # Role tasks file or single play — _check_tasks handles all task sections + _check_tasks(doc, filepath, errors, repo_root) + + return errors + + +def _check_tasks(doc: dict, filepath: Path, errors: list[str], repo_root: Path) -> None: + """Check top-level tasks and nested task sections in a playbook doc.""" + tasks = doc.get("tasks") + if isinstance(tasks, list): + _check_task_list(tasks, filepath, errors, repo_root) + for role_key in ("pre_tasks", "post_tasks", "handlers"): + section = doc.get(role_key) + if isinstance(section, list): + _check_task_list(section, filepath, errors, repo_root) + # Check tasks in roles imported via `roles:` key + roles = doc.get("roles") + if isinstance(roles, list): + for role_entry in roles: + if isinstance(role_entry, dict): + role_tasks = role_entry.get("tasks") + if isinstance(role_tasks, list): + _check_task_list(role_tasks, filepath, errors, repo_root) + + +def _check_task_list(tasks: list, filepath: Path, errors: list[str], repo_root: Path) -> None: + """Check a list of task definitions for set_fact + to_json.""" + for task in tasks: + if not isinstance(task, dict): + continue + _check_task(task, filepath, errors, repo_root) + # Check nested block tasks + block = task.get("block") + if isinstance(block, list): + _check_task_list(block, filepath, errors, repo_root) + + +def _check_task(task: dict, filepath: Path, errors: list[str], repo_root: Path) -> None: + """Check a single task for set_fact + to_json misuse.""" + # Detect set_fact — could be a module name key or ansible.builtin.set_fact + has_set_fact = False + for key in task: + if key in {"set_fact", "ansible.builtin.set_fact"}: + has_set_fact = True + break + + if not has_set_fact: + return + + set_fact_body = task.get("set_fact") or task.get("ansible.builtin.set_fact") + if not isinstance(set_fact_body, dict): + return + + task_name = task.get("name", "(unnamed)") + + for fact_name, fact_value in set_fact_body.items(): + if fact_name in ("cacheable",): + continue + value_str = str(fact_value) + for filter_pattern in TO_JSON_FILTERS: + if filter_pattern in value_str: + try: + display_path = filepath.relative_to(repo_root) + except ValueError: + display_path = filepath + errors.append( + f"{display_path}: task '{task_name}' " + f"sets fact '{fact_name}' with '{filter_pattern.strip()}' " + f"— this converts native Python types to JSON strings. " + f"Remove the filter to preserve the native type, or use " + f"'| from_json' in the consuming task if the string " + f"representation is intentional." + ) + break # One error per fact is enough + + +@click.command() +@click.option( + "--path", + type=click.Path(exists=True, path_type=Path), + help="Check a specific file or directory (default: ansible/playbooks + ansible/roles).", +) +@click.option( + "--ansible-dir", + "ansible_dirs", + type=click.Path(exists=True, path_type=Path), + multiple=True, + default=None, + help="Override the default ansible directories (can be repeated). Defaults to ansible/playbooks and ansible/roles.", +) +def main(path: Path | None, ansible_dirs: tuple[Path, ...]) -> None: + """Check that set_fact tasks don't misuse to_json.""" + dirs = list(ansible_dirs) if ansible_dirs else DEFAULT_ANSIBLE_DIRS + if path: + files = _find_task_files(path) + else: + files: list[Path] = [] + for d in dirs: + files.extend(_find_task_files(d)) + + all_errors: list[str] = [] + for f in files: + errors = _check_file(f, REPO_ROOT) + all_errors.extend(errors) + + if all_errors: + click.echo("[check-ansible-set-fact-to-json] FAIL: set_fact with to_json found:") + for err in all_errors: + click.echo(f" - {err}") + sys.exit(1) + else: + click.echo("[check-ansible-set-fact-to-json] OK: no set_fact tasks misuse to_json.") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/tools/check_docker_init.py b/src/devx/tools/check_docker_init.py new file mode 100644 index 0000000..b853e28 --- /dev/null +++ b/src/devx/tools/check_docker_init.py @@ -0,0 +1,166 @@ +"""Check that Docker Compose services with healthchecks have ``init: true``. + +This prevents zombie process accumulation on production VMs. Without +``init: true``, Docker uses the container's PID 1 process to reap +child processes. Many images (especially those using CMD-SHELL +healthchecks with ``wget``) don't call ``wait()`` on children, causing +zombies to accumulate. + +The check scans all Jinja2 docker-compose templates for services that +have a ``healthcheck:`` key but no ``init: true`` key. Since the +templates use Jinja2 syntax (not pure YAML), the check uses text-based +parsing to identify service blocks and their properties. + +Usage:: + + python -m devx.tools.check_docker_init + python -m devx.tools.check_docker_init --path ansible/roles/observability/templates/docker-compose.yml.j2 + +Exit code 0 if all services with healthchecks have init: true, 1 otherwise. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import click + +REPO_ROOT = Path.cwd() +DEFAULT_TEMPLATES_DIR = REPO_ROOT / "ansible" / "roles" + + +def _find_compose_templates(base: Path) -> list[Path]: + """Find all Jinja2 docker-compose templates under a base directory.""" + if base.is_file(): + return [base] + if not base.is_dir(): + return [] + results: list[Path] = [] + for pattern in ("*docker-compose*", "*compose*"): + results.extend(base.rglob(f"{pattern}.yml.j2")) + results.extend(base.rglob(f"{pattern}.yaml.j2")) + # Also check exporters-compose + results.extend(base.rglob("exporters-compose*.j2")) + # Deduplicate while preserving order + seen: set[Path] = set() + unique: list[Path] = [] + for p in sorted(results): + if p not in seen: + seen.add(p) + unique.append(p) + return unique + + +def _parse_services(content: str) -> dict[str, list[str]]: + """Parse service blocks from a docker-compose Jinja2 template. + + Returns a mapping of service_name → list of lines in that service block. + """ + lines = content.splitlines() + in_services = False + services: dict[str, list[str]] = {} + current_svc: str | None = None + current_lines: list[str] = [] + + for line in lines: + if line.startswith("services:"): + in_services = True + continue + if not in_services: + continue + # Top-level keys (networks:, volumes:) end the services section + if re.match(r"^(networks|volumes):\s*$", line): + if current_svc is not None: + services[current_svc] = current_lines + current_svc = None + in_services = False + continue + # Service definition: exactly 2-space indent, ends with : + # Service names can contain Jinja2 variables like {{ app_name }} + # or {{ app_name }}-db. Match: 2-space indent + non-whitespace + # chars (including {{ }}, -, _, .) + optional spaces inside {{ }} + : + m = re.match(r"^ (\{\{.*?\}\}[a-zA-Z0-9_-]*|[a-zA-Z0-9_().-]+):\s*$", line) + if m: + if current_svc is not None: + services[current_svc] = current_lines + current_svc = m.group(1) + current_lines = [] + elif current_svc is not None: + current_lines.append(line) + + if current_svc is not None: + services[current_svc] = current_lines + + return services + + +def _check_template(filepath: Path, repo_root: Path) -> list[str]: + """Check a single docker-compose template for missing init: true. + + Returns a list of error messages (empty if all OK). + """ + errors: list[str] = [] + content = filepath.read_text(encoding="utf-8") + + if "services:" not in content: + return errors + + services = _parse_services(content) + + for svc_name, svc_lines in services.items(): + svc_text = "\n".join(svc_lines) + has_init = "init: true" in svc_text + has_healthcheck = "healthcheck:" in svc_text + # Skip services that are conditionally included (Jinja2 if blocks) + # but still check them — the healthcheck is inside the conditional + if has_healthcheck and not has_init: + try: + display_path = filepath.relative_to(repo_root) + except ValueError: + display_path = filepath + errors.append( + f"{display_path}: service '{svc_name}' has a healthcheck " + f"but no 'init: true'. Without init: true, CMD-SHELL " + f"healthchecks (wget, pgrep) spawn children that become " + f"zombies when PID 1 doesn't reap them. Add 'init: true' " + f"to enable Docker's built-in tini as PID 1." + ) + + return errors + + +@click.command() +@click.option( + "--path", + type=click.Path(exists=True, path_type=Path), + help="Check a specific file or directory (default: ansible/roles/).", +) +@click.option( + "--templates-dir", + type=click.Path(exists=True, path_type=Path), + default=None, + help="Override the default templates directory (default: ansible/roles/).", +) +def main(path: Path | None, templates_dir: Path | None) -> None: + """Check that Docker Compose services with healthchecks have init: true.""" + tdir = templates_dir or DEFAULT_TEMPLATES_DIR + files = _find_compose_templates(path) if path else _find_compose_templates(tdir) + + all_errors: list[str] = [] + for f in files: + errors = _check_template(f, tdir) + all_errors.extend(errors) + + if all_errors: + click.echo("[check-docker-init] FAIL: services with healthchecks missing init: true:") + for err in all_errors: + click.echo(f" - {err}") + sys.exit(1) + else: + click.echo("[check-docker-init] OK: all services with healthchecks have init: true.") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/tools/install_tools.py b/src/devx/tools/install_tools.py index 861b96a..02146c9 100644 --- a/src/devx/tools/install_tools.py +++ b/src/devx/tools/install_tools.py @@ -75,6 +75,25 @@ def _download(url: str, dest: Path) -> None: shutil.copyfileobj(resp, f) +def _download_with_fallback(urls: list[str], binary_name: str) -> Path: + """Try downloading a binary from a list of URLs, falling back on failure. + + Returns the path to the installed binary. Raises if all URLs fail. + """ + target_dir = _ensure_target_dir() + dest = target_dir / binary_name + errors: list[str] = [] + for url in urls: + try: + _download(url, dest) + dest.chmod(0o755) + return dest + except Exception as exc: # noqa: BLE001 + errors.append(f"{url}: {exc}") + click.echo(f" {binary_name}: retrying — {exc}") + raise click.ClickException(f"Failed to download {binary_name} from all URLs: {'; '.join(errors)}") + + def _download_and_extract_tarball(url: str, binary_name: str) -> Path: """Download a tarball, extract the binary, and install it to TARGET_DIR. @@ -169,8 +188,13 @@ def install_tea() -> bool: click.echo("tea: already installed") return True arch = _arch() - url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}" - dest = _download_binary(url, "tea") + # dl.gitea.com is the primary CDN, but it can return 403 from some networks. + # Fall back to the gitea.com release downloads URL. + urls = [ + f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}", + f"https://gitea.com/gitea/tea/releases/download/v{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}", + ] + dest = _download_with_fallback(urls, "tea") click.echo(f"tea: installed to {dest}") return True diff --git a/src/devx/tools/setup.py b/src/devx/tools/setup.py index a0a0760..777e015 100644 --- a/src/devx/tools/setup.py +++ b/src/devx/tools/setup.py @@ -59,12 +59,6 @@ def _install_pre_commit_hooks(bin_dir: str) -> None: def _install_ansible_collections(bin_dir: str) -> None: """Install required Ansible Galaxy collections if requirements exist. - If the requirements file uses ``type: url`` entries pointing to the - Gitea package registry, downloads them with authentication (using - ``CI_GITEA_TOKEN`` / ``CI_GITEA_API_TOKEN``) and installs from local - files with ``--offline``. Falls back to direct galaxy install if the - mirror download fails or no token is available. - Retries up to 3 times with exponential backoff to handle transient network timeouts when contacting galaxy.ansible.com. """ @@ -74,11 +68,6 @@ def _install_ansible_collections(bin_dir: str) -> None: click.echo(" ansible/requirements.yml not found — skipping collections.") return - # Try Gitea mirror first if requirements use type: url - if _try_gitea_mirror_install(galaxy, requirements): - return - - # Fall back to direct galaxy install with retries @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=10), reraise=True) def _do_install() -> None: _run([galaxy, "collection", "install", "-r", str(requirements)]) @@ -86,89 +75,6 @@ def _install_ansible_collections(bin_dir: str) -> None: _do_install() -def _try_gitea_mirror_install(galaxy: str, requirements: Path) -> bool: - """Download ``type: url`` entries from Gitea with auth and install locally. - - Returns ``True`` if the mirror install succeeded, ``False`` to fall back - to direct galaxy install. - """ - import tempfile - import urllib.request # noqa: PTH123 # nosec B404 - - import yaml # pyright: ignore[reportMissingImports] - - try: - data = yaml.safe_load(requirements.read_text()) - except Exception: - return False - - collections = data.get("collections", []) if data else [] - url_entries = [c for c in collections if c.get("type") == "url"] - if not url_entries: - return False - - # Resolve Gitea token for authenticated downloads - token = os.environ.get("CI_GITEA_API_TOKEN", "").strip() - if not token: - token = os.environ.get("CI_GITEA_TOKEN", "").strip() - if not token: - token = os.environ.get("DEVELOPER_GITEA_API_TOKEN", "").strip() - if not token: - click.echo(" No Gitea token found — falling back to galaxy.ansible.com") - return False - - # Download each tarball with auth - tmpdir = Path(tempfile.mkdtemp(prefix="ansible-collections-")) - local_entries = [] - try: - for entry in url_entries: - source = entry.get("source", "") - if "/api/packages/" not in source: - local_entries.append(entry) - continue - filename = source.rsplit("/", 1)[-1] - dest = tmpdir / filename - click.echo(f" Downloading {entry.get('name', filename)} from Gitea mirror...") - req = urllib.request.Request(source) # nosec B310 - req.add_header("Authorization", f"token {token}") - try: - with urllib.request.urlopen(req, timeout=30) as resp: # noqa: PTH123 # nosec B310 - dest.write_bytes(resp.read()) - except Exception as e: - click.echo(f" WARN: mirror download failed for {entry.get('name')}: {e}") - click.echo(" Falling back to galaxy.ansible.com") - return False - # Extract version from filename (e.g. ansible-posix-2.2.2.tar.gz) - import re - - ver_match = re.search(r"(\d+\.\d+\.\d+)", filename) - local_entries.append( - { - "name": entry["name"], - "version": ver_match.group(1) if ver_match else entry.get("version"), - "type": "file", - "source": str(dest), - } - ) - - # Add non-url entries as-is - for entry in collections: - if entry.get("type") != "url": - local_entries.append(entry) - - # Write local requirements file - local_req = tmpdir / "requirements.yml" - local_req.write_text(yaml.dump({"collections": local_entries})) - - click.echo(" Installing collections from Gitea mirror (offline)...") - _run([galaxy, "collection", "install", "-r", str(local_req), "--offline"]) - return True - finally: - import shutil as _shutil - - _shutil.rmtree(tmpdir, ignore_errors=True) - - def _configure_tea_login() -> None: """Configure tea CLI login from .env if a Gitea token is set. diff --git a/src/devx/tools/setup_image.py b/src/devx/tools/setup_image.py index 71a37dc..cb3e5e6 100644 --- a/src/devx/tools/setup_image.py +++ b/src/devx/tools/setup_image.py @@ -64,9 +64,11 @@ def _install_in_image( link.symlink_to(opt_venv) # Build pip install command + # --no-deps: the CI image already has all dependencies pre-installed. + # We only need to install the project itself in editable mode. spec = f".[{extras}]" if extras else "." pip_bin = str(Path(venv_link) / "bin" / "pip") - cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec] + cmd = [pip_bin, "install", "--no-cache-dir", "--no-deps", "-e", spec] env = os.environ.copy() try: diff --git a/src/devx/utils/api.py b/src/devx/utils/api.py index ab87dd7..fd16878 100644 --- a/src/devx/utils/api.py +++ b/src/devx/utils/api.py @@ -1,14 +1,26 @@ #!/usr/bin/env python3 -"""Utilities for handling API response values. +"""Utilities for handling API response values and base HTTP API client. -Many APIs return boolean values as strings (``"true"``, ``"false"``) -rather than native JSON booleans. The Mattermost ``/api/v4/config/client`` -endpoint is a notable example. These helpers handle both string and -boolean responses safely. +This module provides two categories of utilities: + +1. **Response helpers** — :func:`is_truthy` and :func:`is_falsy` handle + APIs that return boolean values as strings (``"true"``, ``"false"``) + rather than native JSON booleans. + +2. **Base API client** — :class:`APIClient` provides a reusable base + class for HTTP API clients with consistent timeout handling, header + propagation, and automatic raising on 4xx/5xx responses. Usage:: - from devx.utils.api import is_truthy, is_falsy + from devx.utils.api import APIClient, is_truthy + + class MyClient(APIClient): + def __init__(self): + super().__init__( + base_url="https://api.example.com", + headers={"Authorization": "Bearer token"}, + ) if not is_truthy(config.get("EnableOpenServer")): raise ValueError("EnableOpenServer not enabled") @@ -16,6 +28,82 @@ Usage:: from __future__ import annotations +import requests + + +class APIClient: + """Base class for HTTP API clients. + + Subclasses set ``base_url``, ``headers``, and optionally ``auth`` in + their constructor, then use :meth:`_request` or the convenience + methods (:meth:`get`, :meth:`post`, etc.) to make requests. + + All requests raise :class:`requests.HTTPError` on 4xx/5xx responses + via :meth:`requests.Response.raise_for_status`. + """ + + def __init__( + self, + base_url: str, + headers: dict, + timeout: int = 30, + verify: bool = True, + auth: tuple[str, str] | None = None, + ) -> None: + """Initialize the API client. + + Args: + base_url: Base URL for the API (trailing slash stripped). + headers: Default headers sent with every request. + timeout: Request timeout in seconds. + verify: Whether to verify TLS certificates. + auth: Optional ``(username, password)`` tuple for basic auth. + """ + self.base_url = base_url.rstrip("/") + self.headers = headers + self.timeout = timeout + self.verify = verify + self.auth = auth + + def _request(self, method: str, path: str, **kwargs) -> requests.Response: + """Execute an HTTP request against the API. + + The URL is constructed as ``{base_url}{path}``. Default timeout, + verify, auth, and headers are applied but can be overridden via + ``kwargs``. + + Raises: + requests.HTTPError: On 4xx/5xx response status codes. + """ + url = f"{self.base_url}{path}" + kwargs.setdefault("timeout", self.timeout) + kwargs.setdefault("verify", self.verify) + if self.auth is not None: + kwargs.setdefault("auth", self.auth) + resp = requests.request(method, url, headers=self.headers, **kwargs) # noqa: S113 + resp.raise_for_status() + return resp + + def get(self, path: str, **kwargs) -> requests.Response: + """Send a GET request.""" + return self._request("GET", path, **kwargs) + + def post(self, path: str, **kwargs) -> requests.Response: + """Send a POST request.""" + return self._request("POST", path, **kwargs) + + def put(self, path: str, **kwargs) -> requests.Response: + """Send a PUT request.""" + return self._request("PUT", path, **kwargs) + + def delete(self, path: str, **kwargs) -> requests.Response: + """Send a DELETE request.""" + return self._request("DELETE", path, **kwargs) + + def patch(self, path: str, **kwargs) -> requests.Response: + """Send a PATCH request.""" + return self._request("PATCH", path, **kwargs) + def is_truthy(value: str | bool | None) -> bool: """Check if an API config value is truthy. diff --git a/src/devx/utils/jinja.py b/src/devx/utils/jinja.py new file mode 100644 index 0000000..7baf039 --- /dev/null +++ b/src/devx/utils/jinja.py @@ -0,0 +1,133 @@ +"""Shared Jinja2 environment helpers for unit tests and template rendering. + +Creating a Jinja2 Environment is expensive (filesystem scanning, template +compilation). These helpers create cached environments with +``auto_reload=False`` to skip stat() calls on every ``get_template``, +which is the single biggest speedup for template-heavy test suites. + +The filters mimic Ansible builtins not available in plain Jinja2, +making it possible to render Ansible templates outside of Ansible +(e.g. in unit tests or config generation scripts). + +Usage:: + + from devx.utils.jinja import make_env, render_template + + env = make_env("/path/to/templates") + output = render_template(env, "alert-rules.yml.j2", grafana_base_url="https://grafana.example.com") +""" + +from __future__ import annotations + +import functools +import json +import re + +import jinja2 + +# --------------------------------------------------------------------------- +# Filters (mimic Ansible builtins not available in plain Jinja2) +# --------------------------------------------------------------------------- + + +def to_json(value) -> str: + return json.dumps(value) + + +def to_bool(value) -> bool: + """Mimic Ansible's |bool filter for plain Jinja2 tests.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() not in ("", "false", "0", "no", "off", "null", "none") + return bool(value) + + +def regex_replace(value, pattern: str, replacement: str) -> str: + """Mimic Ansible's |regex_replace filter.""" + return re.sub(pattern, replacement, str(value)) + + +def regex_escape(value) -> str: + """Mimic Ansible's |regex_escape filter.""" + return re.escape(str(value)) + + +def regex_search(value, pattern: str) -> str | None: + """Mimic Ansible's |regex_search filter. + + Returns the first match (group 0) or None if no match. + Ansible returns the full match string or None. + """ + m = re.search(pattern, str(value)) + return m.group(0) if m else None + + +# --------------------------------------------------------------------------- +# Environment factory +# --------------------------------------------------------------------------- + +_FILTERS = { + "to_json": to_json, + "bool": to_bool, + "regex_replace": regex_replace, + "regex_escape": regex_escape, + "regex_search": regex_search, +} + + +@functools.cache +def make_env(loader_path: str) -> jinja2.Environment: + """Create a cached Jinja2 Environment with standard filters. + + ``auto_reload=False`` skips stat() on every get_template call — + templates don't change during a test run so this is safe and + cuts ~40% off render time. + """ + env = jinja2.Environment( # nosec B701 — renders YAML/config templates, not HTML + loader=jinja2.FileSystemLoader(loader_path), + undefined=jinja2.StrictUndefined, + auto_reload=False, + cache_size=400, + ) + env.filters.update(_FILTERS) + return env + + +@functools.cache +def make_value_env() -> jinja2.Environment: + """Cached environment for rendering individual manifest string values.""" + env = jinja2.Environment( # nosec B701 — renders config values, not HTML + undefined=jinja2.ChainableUndefined, + auto_reload=False, + ) + env.filters.update(_FILTERS) + return env + + +# --------------------------------------------------------------------------- +# Render helpers +# --------------------------------------------------------------------------- + + +def render_template(env: jinja2.Environment, template_name: str, **kwargs) -> str: + """Render a named template from a FileSystemLoader-backed env.""" + return env.get_template(template_name).render(**kwargs) + + +def render_value(value, ctx: dict): + """Render a single string value as a Jinja2 template if it contains expressions.""" + if not isinstance(value, str): + return value + if "{{" not in value and "{%" not in value: + return value + return make_value_env().from_string(value).render(**ctx) + + +def render_manifest_values(obj, ctx: dict): + """Recursively render all Jinja2 expressions in manifest string values.""" + if isinstance(obj, dict): + return {k: render_manifest_values(v, ctx) for k, v in obj.items()} + if isinstance(obj, list): + return [render_manifest_values(v, ctx) for v in obj] + return render_value(obj, ctx) diff --git a/src/devx/utils/ui.py b/src/devx/utils/ui.py new file mode 100644 index 0000000..1cbafae --- /dev/null +++ b/src/devx/utils/ui.py @@ -0,0 +1,79 @@ +"""User-facing output utilities combining console and log output. + +Console messages are colorised via ``click.style`` for visual feedback. +The persistent log file always receives plain text (no ANSI codes). + +This is a generalisation of grm's ``ui.say()`` function, extracted so +that any CLI tool can use the same pattern. The logger name and +console-level env var are configurable. + +Usage:: + + from devx.utils.ui import say + + say("Starting deployment...") + say("Error occurred", level=logging.ERROR, err=True, color="red") +""" + +from __future__ import annotations + +import logging +import os + +import click + +# Configurable env var for console verbosity — projects can override +# via :func:`configure_ui`. +_LOG_LEVEL_ENV_VAR = "DEVX_LOG_LEVEL" +_LOGGER_NAME = "devx" + + +def configure_ui(*, log_level_env_var: str = "DEVX_LOG_LEVEL", logger_name: str = "devx") -> None: + """Override the env var name and logger name used by :func:`say`. + + This allows downstream projects (e.g. grm) to use their own env var + names (e.g. ``GRM_LOG_LEVEL``) and logger names while still using + devx's ui module. + + Args: + log_level_env_var: Environment variable name for console log level. + logger_name: Logger name for persistent log file output. + """ + global _LOG_LEVEL_ENV_VAR, _LOGGER_NAME + _LOG_LEVEL_ENV_VAR = log_level_env_var + _LOGGER_NAME = logger_name + + +def _console_level() -> int: + """Return the minimum level for console output from the configured env var.""" + value = os.getenv(_LOG_LEVEL_ENV_VAR, "INFO") + try: + return getattr(logging, value.upper()) + except AttributeError: + return logging.INFO + + +def say( + msg: str, + level: int = logging.INFO, + err: bool = False, + color: str | None = None, +) -> None: + """Output a message to the user and also log it for auditing. + + Console output goes via ``click.echo`` (handles encoding, CliRunner, + Windows colorama) only when *level* is at least the configured + console log level (default ``DEVX_LOG_LEVEL``, falls back to INFO). + The same message is always sent to the configured logger so it + appears in the persistent log file regardless of console verbosity. + + Args: + msg: Message to display. + level: Logging level (e.g. ``logging.INFO``, ``logging.ERROR``). + err: If True, output to stderr instead of stdout. + color: Optional ``click.style`` fg color (e.g. ``"green"``, ``"red"``). + """ + if level >= _console_level(): + styled = click.style(msg, fg=color) if color else msg + click.echo(styled, err=err) + logging.getLogger(_LOGGER_NAME).log(level, msg) diff --git a/tests/unit/test_ci_cancel_superseded_runs.py b/tests/unit/test_ci_cancel_superseded_runs.py new file mode 100644 index 0000000..2316594 --- /dev/null +++ b/tests/unit/test_ci_cancel_superseded_runs.py @@ -0,0 +1,172 @@ +"""Unit tests for devx.ci.cancel_superseded_runs.""" + +from __future__ import annotations + +import json +import urllib.error +from unittest.mock import MagicMock, patch + +import pytest + +import devx.ci.cancel_superseded_runs as mod +from devx.ci.cancel_superseded_runs import _api_request, cancel_run, list_running_runs, main + +_HTTP_NO_CONTENT = mod._HTTP_NO_CONTENT +_PAGE_SIZE = mod._PAGE_SIZE + + +class TestConstants: + def test_http_no_content_is_204(self) -> None: + assert _HTTP_NO_CONTENT == 204 + + def test_page_size_is_50(self) -> None: + assert _PAGE_SIZE == 50 + + +class TestApiRequest: + def test_returns_empty_for_204(self) -> None: + mock_resp = MagicMock() + mock_resp.status = _HTTP_NO_CONTENT + mock_resp.read.return_value = b"" + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=None) + with patch("urllib.request.urlopen", return_value=mock_resp): + result = _api_request("POST", "/repos/test/actions/runs/1/cancel", "tok", "https://x") + assert result == {} + + def test_returns_json_for_200(self) -> None: + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.read.return_value = json.dumps({"id": 1}).encode() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=None) + with patch("urllib.request.urlopen", return_value=mock_resp): + result = _api_request("GET", "/repos/test/actions/runs", "tok", "https://x") + assert result == {"id": 1} + + def test_http_error_raises(self) -> None: + err = urllib.error.HTTPError("x", 500, "err", {}, None) + err.read = MagicMock(return_value=b"error body") + with patch("urllib.request.urlopen", side_effect=err): + with pytest.raises(urllib.error.HTTPError): + _api_request("GET", "/repos/test/actions/runs", "tok", "https://x") + + def test_url_error_raises(self) -> None: + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("fail")): + with pytest.raises(urllib.error.URLError): + _api_request("GET", "/repos/test/actions/runs", "tok", "https://x") + + +class TestListRunningRuns: + def test_paginates_until_empty(self) -> None: + page1 = {"workflow_runs": [{"id": 1}, {"id": 2}], "total_count": 2} + page2 = {"workflow_runs": [], "total_count": 2} + responses = iter([page1, page2]) + with patch.object(mod, "_api_request", side_effect=lambda *a, **k: next(responses)): + runs = list_running_runs("owner/repo", "tok", "https://x") + assert len(runs) == 2 + + def test_empty_first_page(self) -> None: + with patch.object(mod, "_api_request", return_value={"workflow_runs": [], "total_count": 0}): + runs = list_running_runs("owner/repo", "tok", "https://x") + assert runs == [] + + def test_stops_at_page_size(self) -> None: + full_page = {"workflow_runs": [{"id": i} for i in range(_PAGE_SIZE)], "total_count": _PAGE_SIZE + 1} + half_page = {"workflow_runs": [{"id": 99}], "total_count": _PAGE_SIZE + 1} + responses = iter([full_page, half_page]) + with patch.object(mod, "_api_request", side_effect=lambda *a, **k: next(responses)): + runs = list_running_runs("owner/repo", "tok", "https://x") + assert len(runs) == _PAGE_SIZE + 1 + + def test_uses_in_progress_status(self) -> None: + with patch.object(mod, "_api_request", return_value={"workflow_runs": [], "total_count": 0}) as mock_req: + list_running_runs("owner/repo", "tok", "https://x") + path = mock_req.call_args.args[1] + assert "status=in_progress" in path + assert "status=running" not in path + + def test_accepts_bare_list(self) -> None: + with patch.object(mod, "_api_request", return_value=[{"id": 1}, {"id": 2}]): + runs = list_running_runs("owner/repo", "tok", "https://x") + assert len(runs) == 2 + + +class TestCancelRun: + def test_success_returns_true(self) -> None: + with patch.object(mod, "_api_request", return_value={}): + assert cancel_run("owner/repo", 123, "tok", "https://x") is True + + def test_http_error_returns_false(self) -> None: + with patch.object(mod, "_api_request", side_effect=urllib.error.HTTPError("x", 500, "err", {}, None)): + assert cancel_run("owner/repo", 123, "tok", "https://x") is False + + +class TestMain: + def test_no_token_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CI_GITEA_API_TOKEN", raising=False) + monkeypatch.delenv("CI_GITEA_TOKEN", raising=False) + monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "1", "--head-branch", "feat"]) + assert main() == 0 + + def test_no_superseded_runs(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok") + monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"]) + with patch.object(mod, "list_running_runs", return_value=[]): + assert main() == 0 + + def test_cancels_superseded(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok") + runs = [ + {"id": 5, "head_branch": "feat"}, + {"id": 8, "head_branch": "feat"}, + {"id": 12, "head_branch": "other"}, + ] + monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"]) + with patch.object(mod, "list_running_runs", return_value=runs): + with patch.object(mod, "cancel_run", return_value=True) as mock_cancel: + assert main() == 0 + cancelled_ids = [call.args[1] for call in mock_cancel.call_args_list] + assert cancelled_ids == [5, 8] + + def test_dry_run_does_not_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok") + runs = [{"id": 5, "head_branch": "feat"}] + monkeypatch.setattr( + "sys.argv", + ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat", "--dry-run"], + ) + with patch.object(mod, "list_running_runs", return_value=runs): + with patch.object(mod, "cancel_run", return_value=True) as mock_cancel: + assert main() == 0 + assert mock_cancel.call_count == 0 + + def test_cancel_failure_continues(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok") + runs = [{"id": 5, "head_branch": "feat"}, {"id": 8, "head_branch": "feat"}] + monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"]) + with patch.object(mod, "list_running_runs", return_value=runs): + with patch.object(mod, "cancel_run", side_effect=[False, True]): + assert main() == 0 + + def test_404_returns_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok") + monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"]) + err = urllib.error.HTTPError("x", 404, "Not Found", {}, None) + with patch.object(mod, "list_running_runs", side_effect=err): + assert main() == 0 + + def test_400_returns_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok") + monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"]) + err = urllib.error.HTTPError("x", 400, "Bad Request", {}, None) + with patch.object(mod, "list_running_runs", side_effect=err): + assert main() == 0 + + def test_500_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok") + monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"]) + err = urllib.error.HTTPError("x", 500, "Server Error", {}, None) + with patch.object(mod, "list_running_runs", side_effect=err): + with pytest.raises(urllib.error.HTTPError): + main() diff --git a/tests/unit/test_ci_check_workflow_artifact_deps.py b/tests/unit/test_ci_check_workflow_artifact_deps.py new file mode 100644 index 0000000..1800d49 --- /dev/null +++ b/tests/unit/test_ci_check_workflow_artifact_deps.py @@ -0,0 +1,419 @@ +"""Unit tests for devx.ci.check_workflow_artifact_deps.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from click.testing import CliRunner + +from devx.ci.check_workflow_artifact_deps import ( + _check_workflow, + _extract_artifact_info, + _is_artifact_action, + main, +) + + +class TestIsArtifactAction: + def test_upload_action_gitea(self): + assert _is_artifact_action("christopherhx/gitea-upload-artifact@v4", ("upload-artifact",)) + + def test_upload_action_github(self): + assert _is_artifact_action("actions/upload-artifact@v4", ("upload-artifact",)) + + def test_download_action(self): + assert _is_artifact_action("christopherhx/gitea-download-artifact@v4", ("download-artifact",)) + + def test_non_artifact_action(self): + assert not _is_artifact_action("actions/checkout@v4", ("upload-artifact",)) + + def test_empty_string(self): + assert not _is_artifact_action("", ("upload-artifact",)) + + def test_case_insensitive(self): + assert _is_artifact_action("Actions/Upload-Artifact@v4", ("upload-artifact",)) + + +class TestExtractArtifactInfo: + def test_uploads_and_downloads(self): + import yaml + + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - name: Upload config + uses: christopherhx/gitea-upload-artifact@v4 + with: + name: config-${{ github.run_id }} + consumer: + needs: [producer] + steps: + - name: Download config + uses: christopherhx/gitea-download-artifact@v4 + with: + name: config-${{ github.run_id }} + """).strip() + wf = yaml.safe_load(workflow_yaml) + uploads, downloads = _extract_artifact_info(wf) + assert uploads == {"config-${{ github.run_id }}": ["producer"]} + assert downloads == [("consumer", "config-${{ github.run_id }}", "Download config")] + + def test_no_artifacts(self): + import yaml + + workflow_yaml = textwrap.dedent(""" + jobs: + build: + steps: + - name: Checkout + uses: actions/checkout@v4 + """).strip() + wf = yaml.safe_load(workflow_yaml) + uploads, downloads = _extract_artifact_info(wf) + assert uploads == {} + assert downloads == [] + + def test_multiple_uploaders_same_artifact(self): + import yaml + + workflow_yaml = textwrap.dedent(""" + jobs: + producer-a: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: shared + producer-b: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: shared + """).strip() + wf = yaml.safe_load(workflow_yaml) + uploads, downloads = _extract_artifact_info(wf) + assert uploads == {"shared": ["producer-a", "producer-b"]} + + def test_step_without_name(self): + import yaml + + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: data + consumer: + needs: [producer] + steps: + - uses: christopherhx/gitea-download-artifact@v4 + with: + name: data + """).strip() + wf = yaml.safe_load(workflow_yaml) + uploads, downloads = _extract_artifact_info(wf) + assert downloads == [("consumer", "data", "")] + + def test_upload_without_name_skipped(self): + import yaml + + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + path: ./dist + """).strip() + wf = yaml.safe_load(workflow_yaml) + uploads, downloads = _extract_artifact_info(wf) + assert uploads == {} + + +class TestCheckWorkflow: + def test_valid_dependency(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - name: Upload config + uses: christopherhx/gitea-upload-artifact@v4 + with: + name: config + consumer: + needs: [producer] + steps: + - name: Download config + uses: christopherhx/gitea-download-artifact@v4 + with: + name: config + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + assert _check_workflow(f) == [] + + def test_missing_dependency(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - name: Upload config + uses: christopherhx/gitea-upload-artifact@v4 + with: + name: config + consumer: + needs: [other-job] + steps: + - name: Download config + uses: christopherhx/gitea-download-artifact@v4 + with: + name: config + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + errors = _check_workflow(f) + assert len(errors) == 1 + assert "consumer" in errors[0] + assert "producer" in errors[0] + + def test_no_needs_at_all(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: data + consumer: + steps: + - uses: christopherhx/gitea-download-artifact@v4 + with: + name: data + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + errors = _check_workflow(f) + assert len(errors) == 1 + assert "consumer" in errors[0] + + def test_artifact_not_uploaded_in_workflow(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + consumer: + steps: + - name: Download external + uses: christopherhx/gitea-download-artifact@v4 + with: + name: external-artifact + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + assert _check_workflow(f) == [] + + def test_multiple_uploaders_one_in_needs(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer-a: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: shared + producer-b: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: shared + consumer: + needs: [producer-a, other] + steps: + - uses: christopherhx/gitea-download-artifact@v4 + with: + name: shared + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + assert _check_workflow(f) == [] + + def test_string_needs(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: data + consumer: + needs: producer + steps: + - uses: christopherhx/gitea-download-artifact@v4 + with: + name: data + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + assert _check_workflow(f) == [] + + def test_needs_null(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: data + consumer: + needs: null + steps: + - uses: christopherhx/gitea-download-artifact@v4 + with: + name: data + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + errors = _check_workflow(f) + assert len(errors) == 1 + assert "consumer" in errors[0] + + def test_invalid_yaml(self, tmp_path: Path): + f = tmp_path / "test.yml" + f.write_text("jobs: [invalid yaml: {") + errors = _check_workflow(f) + assert len(errors) == 1 + assert "cannot parse YAML" in errors[0] + + def test_not_a_dict(self, tmp_path: Path): + f = tmp_path / "test.yml" + f.write_text("just a string") + errors = _check_workflow(f) + assert len(errors) == 1 + assert "not a valid workflow" in errors[0] + + def test_no_jobs(self, tmp_path: Path): + f = tmp_path / "test.yml" + f.write_text("name: empty\non: push\n") + assert _check_workflow(f) == [] + + def test_continue_on_error_guard(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - name: Upload config + uses: christopherhx/gitea-upload-artifact@v4 + with: + name: config + consumer: + needs: [other-job] + steps: + - name: Download config + continue-on-error: true + uses: christopherhx/gitea-download-artifact@v4 + with: + name: config + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + assert _check_workflow(f) == [] + + def test_continue_on_error_false_still_errors(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - name: Upload config + uses: christopherhx/gitea-upload-artifact@v4 + with: + name: config + consumer: + needs: [other-job] + steps: + - name: Download config + continue-on-error: false + uses: christopherhx/gitea-download-artifact@v4 + with: + name: config + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + errors = _check_workflow(f) + assert len(errors) == 1 + assert "consumer" in errors[0] + + def test_job_with_no_steps(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + empty: + runs-on: docker + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + assert _check_workflow(f) == [] + + +class TestMain: + def test_passes_when_valid(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: data + consumer: + needs: [producer] + steps: + - uses: christopherhx/gitea-download-artifact@v4 + with: + name: data + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + runner = CliRunner() + result = runner.invoke(main, ["--workflows-dir", str(tmp_path)]) + assert result.exit_code == 0 + assert "OK" in result.output + + def test_fails_when_missing_dep(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: data + consumer: + steps: + - uses: christopherhx/gitea-download-artifact@v4 + with: + name: data + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + runner = CliRunner() + result = runner.invoke(main, ["--workflows-dir", str(tmp_path)]) + assert result.exit_code == 1 + assert "FAIL" in result.output + assert "consumer" in result.output + + def test_specific_workflow_file(self, tmp_path: Path): + workflow_yaml = textwrap.dedent(""" + jobs: + producer: + steps: + - uses: christopherhx/gitea-upload-artifact@v4 + with: + name: data + consumer: + needs: [producer] + steps: + - uses: christopherhx/gitea-download-artifact@v4 + with: + name: data + """).strip() + f = tmp_path / "test.yml" + f.write_text(workflow_yaml) + runner = CliRunner() + result = runner.invoke(main, ["--workflow", str(f)]) + assert result.exit_code == 0 diff --git a/tests/unit/test_ci_check_workflow_tofu_init.py b/tests/unit/test_ci_check_workflow_tofu_init.py new file mode 100644 index 0000000..2ad6f93 --- /dev/null +++ b/tests/unit/test_ci_check_workflow_tofu_init.py @@ -0,0 +1,356 @@ +"""Unit tests for devx.ci.check_workflow_tofu_init.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from click.testing import CliRunner + +import devx.ci.check_workflow_tofu_init as mod +from devx.ci.check_workflow_tofu_init import _check_workflow, main + + +def _write_workflow(tmp_path: Path, content: str) -> Path: + filepath = tmp_path / "test.yml" + filepath.write_text(textwrap.dedent(content), encoding="utf-8") + return filepath + + +class TestCheckWorkflow: + def test_passes_when_tofu_init_present(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + deploy: + runs-on: docker + steps: + - run: python3 scripts/create_production_deployment.py --phase tofu-init + - run: python3 scripts/preflight_deploy.py --env production + """, + ) + assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == [] + + def test_fails_when_tofu_init_missing(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + preflight: + runs-on: docker + steps: + - run: python3 scripts/preflight_deploy.py --env production + """, + ) + errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) + assert len(errors) == 1 + assert "preflight" in errors[0] + assert "tofu-init" in errors[0] + + def test_passes_when_direct_tofu_init(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + deploy: + runs-on: docker + steps: + - run: tofu init + - run: tofu output -json + """, + ) + assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == [] + + def test_fails_when_direct_tofu_output_without_init(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + check: + runs-on: docker + steps: + - run: tofu output -json + """, + ) + errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) + assert len(errors) == 1 + assert "check" in errors[0] + + def test_passes_when_no_tofu_usage(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + lint: + runs-on: docker + steps: + - run: make lint + """, + ) + assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == [] + + def test_passes_with_staging_deployment_tofu_init(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + deploy: + runs-on: docker + steps: + - run: python3 scripts/create_staging_deployment.py --phase tofu-init + - run: python3 scripts/create_staging_deployment.py --phase deploy + """, + ) + assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == [] + + def test_fails_with_tofu_plan_without_init(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + plan: + runs-on: docker + steps: + - run: tofu plan + """, + ) + errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) + assert len(errors) == 1 + assert "plan" in errors[0] + + def test_fails_with_tofu_apply_without_init(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + apply: + runs-on: docker + steps: + - run: tofu apply -auto-approve + """, + ) + errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) + assert len(errors) == 1 + assert "apply" in errors[0] + + def test_multiple_jobs_one_missing(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + good: + runs-on: docker + steps: + - run: python3 scripts/create_production_deployment.py --phase tofu-init + - run: python3 scripts/preflight_deploy.py --env production + bad: + runs-on: docker + steps: + - run: python3 scripts/preflight_deploy.py --env production + """, + ) + errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) + assert len(errors) == 1 + assert "bad" in errors[0] + + def test_no_steps_passes(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + empty: + runs-on: docker + """, + ) + assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == [] + + def test_destroy_orphans_does_not_require_init(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + cleanup: + runs-on: docker + steps: + - run: python3 scripts/destroy_orphans.py + """, + ) + assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == [] + + def test_invalid_yaml_returns_error(self, tmp_path: Path) -> None: + filepath = tmp_path / "bad.yml" + filepath.write_text("jobs: [invalid yaml: {", encoding="utf-8") + errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) + assert len(errors) == 1 + assert "cannot parse YAML" in errors[0] + + def test_tofu_show_requires_init(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + show: + runs-on: docker + steps: + - run: tofu show -json + """, + ) + errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) + assert len(errors) == 1 + assert "show" in errors[0] + + def test_custom_state_scripts(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + custom: + runs-on: docker + steps: + - run: python3 scripts/my_custom_script.py + """, + ) + errors = _check_workflow(filepath, {"my_custom_script.py"}) + assert len(errors) == 1 + assert "custom" in errors[0] + + def test_step_with_no_run_skipped(self, tmp_path: Path) -> None: + """A step with no 'run' key should be skipped (line 80 continue).""" + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + deploy: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + - run: tofu init + - run: tofu output + """, + ) + assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == [] + + +class TestCli: + def test_passes_with_specific_workflow(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + deploy: + runs-on: docker + steps: + - run: tofu init + - run: tofu output + """, + ) + runner = CliRunner() + result = runner.invoke(main, ["--workflow", str(filepath)]) + assert result.exit_code == 0 + assert "OK" in result.output + + def test_fails_with_missing_tofu_init(self, tmp_path: Path) -> None: + filepath = _write_workflow( + tmp_path, + """ + name: Test + on: push + jobs: + preflight: + runs-on: docker + steps: + - run: python3 scripts/preflight_deploy.py --env production + """, + ) + runner = CliRunner() + result = runner.invoke(main, ["--workflow", str(filepath)]) + assert result.exit_code == 1 + assert "FAIL" in result.output + assert "preflight" in result.output + + def test_checks_all_workflows_by_default(self, tmp_path: Path) -> None: + workflows_dir = tmp_path / "workflows" + workflows_dir.mkdir() + (workflows_dir / "good.yml").write_text( + textwrap.dedent(""" + name: Good + on: push + jobs: + deploy: + runs-on: docker + steps: + - run: tofu init + - run: tofu output + """), + encoding="utf-8", + ) + (workflows_dir / "bad.yml").write_text( + textwrap.dedent(""" + name: Bad + on: push + jobs: + check: + runs-on: docker + steps: + - run: tofu output + """), + encoding="utf-8", + ) + runner = CliRunner() + result = runner.invoke(main, ["--workflows-dir", str(workflows_dir)]) + assert result.exit_code == 1 + assert "bad.yml" in result.output + assert "check" in result.output + + def test_all_workflows_pass(self, tmp_path: Path) -> None: + workflows_dir = tmp_path / "workflows" + workflows_dir.mkdir() + (workflows_dir / "ok.yml").write_text( + textwrap.dedent(""" + name: OK + on: push + jobs: + deploy: + runs-on: docker + steps: + - run: tofu init + - run: tofu plan + """), + encoding="utf-8", + ) + runner = CliRunner() + result = runner.invoke(main, ["--workflows-dir", str(workflows_dir)]) + assert result.exit_code == 0 + assert "OK" in result.output diff --git a/tests/unit/test_distribute_molecule.py b/tests/unit/test_distribute_molecule.py index 4dc1fcb..c3c154f 100644 --- a/tests/unit/test_distribute_molecule.py +++ b/tests/unit/test_distribute_molecule.py @@ -311,6 +311,61 @@ class TestDiscoverMultiRole: with pytest.raises(click.ClickException): discover_multi_role_scenarios() + def test_include_roles_filters_to_subset(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + for scenario in ["default"]: + (roles / "docker_base" / "molecule" / scenario).mkdir(parents=True) + (roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True) + (roles / "app_container" / "molecule" / scenario).mkdir(parents=True) + result = discover_multi_role_scenarios(roles, include_roles=["docker_base", "crowdsec"]) + assert ("docker_base", "default") in result + assert ("crowdsec", "default") in result + assert ("app_container", "default") not in result + assert len(result) == 2 + + def test_include_roles_case_insensitive(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + (roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True) + (roles / "other" / "molecule" / "default").mkdir(parents=True) + result = discover_multi_role_scenarios(roles, include_roles=["docker_base"]) + assert ("Docker_Base", "default") in result + assert len(result) == 1 + + def test_exclude_roles_skips_subset(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + for scenario in ["default"]: + (roles / "docker_base" / "molecule" / scenario).mkdir(parents=True) + (roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True) + (roles / "app_container" / "molecule" / scenario).mkdir(parents=True) + result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base", "crowdsec"]) + assert ("docker_base", "default") not in result + assert ("crowdsec", "default") not in result + assert ("app_container", "default") in result + assert len(result) == 1 + + def test_exclude_roles_case_insensitive(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + (roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True) + (roles / "other" / "molecule" / "default").mkdir(parents=True) + result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base"]) + assert ("Docker_Base", "default") not in result + assert ("other", "default") in result + assert len(result) == 1 + + def test_include_and_exclude_combined(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + for scenario in ["default"]: + (roles / "docker_base" / "molecule" / scenario).mkdir(parents=True) + (roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True) + (roles / "app_container" / "molecule" / scenario).mkdir(parents=True) + result = discover_multi_role_scenarios( + roles, include_roles=["docker_base", "crowdsec", "app_container"], exclude_roles=["crowdsec"] + ) + assert ("docker_base", "default") in result + assert ("crowdsec", "default") not in result + assert ("app_container", "default") in result + assert len(result) == 2 + def test_default_roles_root_constant(self) -> None: assert Path("ansible/roles") == DEFAULT_ROLES_ROOT diff --git a/tests/unit/test_i18n.py b/tests/unit/test_i18n.py new file mode 100644 index 0000000..d9141d5 --- /dev/null +++ b/tests/unit/test_i18n.py @@ -0,0 +1,100 @@ +"""Unit tests for devx.i18n.""" + +from __future__ import annotations + +import pytest + +import devx.i18n as i18n_mod +from devx.i18n import _, configure_i18n + + +class TestTranslate: + def test_returns_english_by_default(self) -> None: + with pytest.MonkeyPatch().context() as mp: + mp.delenv("DEVX_LANG", raising=False) + assert _("Running tests") == "Running tests" + + def test_returns_key_when_missing(self) -> None: + with pytest.MonkeyPatch().context() as mp: + mp.delenv("DEVX_LANG", raising=False) + assert _("nonexistent.key.xyz") == "nonexistent.key.xyz" + + def test_formats_kwargs(self) -> None: + # Find a key with format placeholders + for key, translations in i18n_mod.TRANSLATIONS.items(): + en = translations.get("en", "") + if "{" in en: + with pytest.MonkeyPatch().context() as mp: + mp.delenv("DEVX_LANG", raising=False) + result = _(key, **dict.fromkeys(_extract_format_keys(en), "x")) + assert "{" not in result + return + pytest.skip("No key with format placeholders found") + + def test_invalid_lang_falls_back_to_english(self) -> None: + with pytest.MonkeyPatch().context() as mp: + mp.setenv("DEVX_LANG", "fr") + assert _("Running tests") == "Running tests" + + def test_bulgarian_translation(self) -> None: + with pytest.MonkeyPatch().context() as mp: + mp.setenv("DEVX_LANG", "bg") + # Find a key that has a Bulgarian translation + for key, translations in i18n_mod.TRANSLATIONS.items(): + if "bg" in translations: + result = _(key) + assert result == translations["bg"] + return + pytest.skip("No Bulgarian translation found") + + +class TestConfigureI18n: + def test_custom_lang_env_var(self) -> None: + configure_i18n(lang_env_var="GRM_LANG") + try: + with pytest.MonkeyPatch().context() as mp: + mp.setenv("GRM_LANG", "bg") + mp.delenv("DEVX_LANG", raising=False) + # Find a key with Bulgarian translation + for key, translations in i18n_mod.TRANSLATIONS.items(): + if "bg" in translations: + assert _(key) == translations["bg"] + return + pytest.skip("No Bulgarian translation found") + finally: + configure_i18n() # Reset to defaults + + def test_custom_translations_path_env_var(self, tmp_path) -> None: + custom_translations = {"custom.key": {"en": "Custom Value", "bg": "Персонализирано"}} + custom_file = tmp_path / "custom.json" + custom_file.write_text(__import__("json").dumps(custom_translations)) + + configure_i18n(translations_path_env_var="GRM_TRANSLATIONS_PATH") + try: + # Use i18n_mod.TRANSLATIONS (not a stale import) — other tests + # may call importlib.reload(devx.i18n), replacing the dict object. + translations = i18n_mod.TRANSLATIONS + original = dict(translations) + translations.update(custom_translations) + try: + with pytest.MonkeyPatch().context() as mp: + mp.setenv("GRM_TRANSLATIONS_PATH", str(custom_file)) + assert _("custom.key") == "Custom Value" + finally: + translations.clear() + translations.update(original) + finally: + configure_i18n() # Reset to defaults + + def test_reset_to_defaults(self) -> None: + configure_i18n(lang_env_var="GRM_LANG") + configure_i18n() # Reset + assert i18n_mod._lang_env_var == "DEVX_LANG" + assert i18n_mod._translations_path_env_var == "DEVX_TRANSLATIONS_PATH" + + +def _extract_format_keys(template: str) -> list[str]: + """Extract {key} format placeholders from a template string.""" + import re + + return re.findall(r"\{(\w+)\}", template) diff --git a/tests/unit/test_install_tools.py b/tests/unit/test_install_tools.py index 6fda879..c49074c 100644 --- a/tests/unit/test_install_tools.py +++ b/tests/unit/test_install_tools.py @@ -238,6 +238,33 @@ class TestInstallTea: assert install_tools.install_tea() is True assert (tmp_path / "tea").exists() + def test_install_fallback_to_second_url(self, tmp_path: Path) -> None: + """First URL fails (403), second URL succeeds.""" + call_count = [0] + + def _download_side_effect(url: str, dest: Path) -> None: + call_count[0] += 1 + if call_count[0] == 1: + raise OSError("HTTP Error 403: Forbidden") + Path(dest).write_bytes(b"binary") + + 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(install_tools, "_download", side_effect=_download_side_effect): + assert install_tools.install_tea() is True + assert (tmp_path / "tea").exists() + assert call_count[0] == 2 + + def test_install_all_urls_fail(self, tmp_path: Path) -> None: + """All URLs fail — should raise ClickException.""" + 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(install_tools, "_download", side_effect=OSError("403 Forbidden")): + with pytest.raises(ClickException, match="Failed to download tea"): + install_tools.install_tea() + class TestInstallHadolint: def test_already_installed(self) -> None: diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py index 5527ffa..48248d9 100644 --- a/tests/unit/test_setup.py +++ b/tests/unit/test_setup.py @@ -14,7 +14,6 @@ from devx.tools.setup import ( _install_pre_commit_hooks, _install_python_deps, _run, - _try_gitea_mirror_install, _verify, main, ) @@ -107,7 +106,6 @@ class TestInstallAnsibleCollections: with patch("devx.tools.setup.Path") as mock_path: mock_path.return_value.exists.return_value = True mock_path.return_value.__str__ = lambda _: str(req) - mock_path.return_value.read_text = lambda: req.read_text() _install_ansible_collections(".venv/bin") mock_run.assert_called_once() @@ -136,7 +134,6 @@ class TestInstallAnsibleCollections: with patch("devx.tools.setup.Path") as mock_path: mock_path.return_value.exists.return_value = True mock_path.return_value.__str__ = lambda _: str(req) - mock_path.return_value.read_text = lambda: req.read_text() _install_ansible_collections(".venv/bin") assert mock_run.call_count == 2 @@ -156,211 +153,10 @@ class TestInstallAnsibleCollections: with patch("devx.tools.setup.Path") as mock_path: mock_path.return_value.exists.return_value = True mock_path.return_value.__str__ = lambda _: str(req) - mock_path.return_value.read_text = lambda: req.read_text() with pytest.raises(_subprocess.CalledProcessError): _install_ansible_collections(".venv/bin") assert mock_run.call_count == 3 - @patch("devx.tools.setup._try_gitea_mirror_install", return_value=True) - @patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy") - @patch("devx.tools.setup._run") - def test_mirror_install_skips_galaxy_fallback( - self, mock_run: MagicMock, mock_which: MagicMock, mock_mirror: MagicMock, tmp_path: Path - ) -> None: - """When mirror install succeeds, galaxy fallback is not called.""" - req = tmp_path / "ansible" / "requirements.yml" - req.parent.mkdir(parents=True) - req.write_text("collections: []") - with patch("devx.tools.setup.Path") as mock_path: - mock_path.return_value.exists.return_value = True - mock_path.return_value.__str__ = lambda _: str(req) - mock_path.return_value.read_text = lambda: req.read_text() - _install_ansible_collections(".venv/bin") - # _run should not be called because mirror install returns True - mock_run.assert_not_called() - - -class TestTryGiteaMirrorInstall: - """Tests for _try_gitea_mirror_install — Gitea mirror with auth + fallback.""" - - _GITEA_URL = "https://git.example.com/api/packages/org/generic/ansible-collections/1.0.0/ansible-posix-1.0.0.tar.gz" - - @patch.dict(os.environ, {}, clear=True) - def test_no_url_entries_returns_false(self, tmp_path: Path) -> None: - """Requirements without type: url entries should return False.""" - req = tmp_path / "requirements.yml" - req.write_text("collections:\n - name: ansible.posix\n version: '1.0.0'\n") - result = _try_gitea_mirror_install("ansible-galaxy", req) - assert result is False - - @patch.dict(os.environ, {}, clear=True) - def test_no_token_returns_false(self, tmp_path: Path) -> None: - """No Gitea token set → return False to fall back to galaxy.""" - req = tmp_path / "requirements.yml" - req.write_text( - "collections:\n" - " - name: ansible.posix\n" - " version: '1.0.0'\n" - " type: url\n" - f" source: '{self._GITEA_URL}'\n" - ) - result = _try_gitea_mirror_install("ansible-galaxy", req) - assert result is False - - @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) - def test_yaml_parse_error_returns_false(self, tmp_path: Path) -> None: - """Malformed YAML → return False.""" - req = tmp_path / "requirements.yml" - req.write_text("not: valid: yaml: [[") - result = _try_gitea_mirror_install("ansible-galaxy", req) - assert result is False - - @patch("devx.tools.setup._run") - @patch("urllib.request.urlopen") - @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) - def test_successful_mirror_install(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: - """Valid URL entries + token → downloads with auth and installs offline.""" - req = tmp_path / "requirements.yml" - req.write_text( - f"collections:\n" - f" - name: ansible.posix\n" - f" version: '1.0.0'\n" - f" type: url\n" - f" source: '{self._GITEA_URL}'\n" - ) - mock_resp = MagicMock() - mock_resp.read.return_value = b"fake-tarball" - mock_resp.__enter__ = lambda _: mock_resp - mock_resp.__exit__ = lambda *a: None - mock_urlopen.return_value = mock_resp - - result = _try_gitea_mirror_install("ansible-galaxy", req) - assert result is True - - # Verify auth header was added - call_args = mock_urlopen.call_args[0][0] - assert call_args.get_header("Authorization") == "token tok123" - - # Verify offline install was called - install_cmd = mock_run.call_args[0][0] - assert "collection" in install_cmd - assert "install" in install_cmd - assert "--offline" in install_cmd - - @patch("urllib.request.urlopen") - @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) - def test_download_failure_returns_false(self, mock_urlopen: MagicMock, tmp_path: Path) -> None: - """Download failure → return False to fall back to galaxy.""" - req = tmp_path / "requirements.yml" - req.write_text( - f"collections:\n" - f" - name: ansible.posix\n" - f" version: '1.0.0'\n" - f" type: url\n" - f" source: '{self._GITEA_URL}'\n" - ) - mock_urlopen.side_effect = Exception("401 Unauthorized") - - result = _try_gitea_mirror_install("ansible-galaxy", req) - assert result is False - - @patch("devx.tools.setup._run") - @patch("urllib.request.urlopen") - @patch.dict(os.environ, {"CI_GITEA_API_TOKEN": "tok456"}, clear=True) - def test_prefers_api_token_over_legacy(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: - """CI_GITEA_API_TOKEN takes priority over CI_GITEA_TOKEN.""" - req = tmp_path / "requirements.yml" - req.write_text( - f"collections:\n" - f" - name: ansible.posix\n" - f" version: '1.0.0'\n" - f" type: url\n" - f" source: '{self._GITEA_URL}'\n" - ) - mock_resp = MagicMock() - mock_resp.read.return_value = b"fake-tarball" - mock_resp.__enter__ = lambda _: mock_resp - mock_resp.__exit__ = lambda *a: None - mock_urlopen.return_value = mock_resp - - result = _try_gitea_mirror_install("ansible-galaxy", req) - assert result is True - - call_args = mock_urlopen.call_args[0][0] - assert call_args.get_header("Authorization") == "token tok456" - - @patch("devx.tools.setup._run") - @patch("urllib.request.urlopen") - @patch.dict(os.environ, {"DEVELOPER_GITEA_API_TOKEN": "tok789"}, clear=True) - def test_developer_token_fallback(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: - """DEVELOPER_GITEA_API_TOKEN is used when CI tokens are absent.""" - req = tmp_path / "requirements.yml" - req.write_text( - f"collections:\n" - f" - name: ansible.posix\n" - f" version: '1.0.0'\n" - f" type: url\n" - f" source: '{self._GITEA_URL}'\n" - ) - mock_resp = MagicMock() - mock_resp.read.return_value = b"fake-tarball" - mock_resp.__enter__ = lambda _: mock_resp - mock_resp.__exit__ = lambda *a: None - mock_urlopen.return_value = mock_resp - - result = _try_gitea_mirror_install("ansible-galaxy", req) - assert result is True - - call_args = mock_urlopen.call_args[0][0] - assert call_args.get_header("Authorization") == "token tok789" - - @patch("devx.tools.setup._run") - @patch("urllib.request.urlopen") - @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) - def test_non_gitea_url_passed_through(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: - """URL entries not pointing to /api/packages/ are kept as-is (no download).""" - external_url = "https://galaxy.ansible.com/download/ansible-posix-1.0.0.tar.gz" - req = tmp_path / "requirements.yml" - req.write_text( - f"collections:\n" - f" - name: ansible.posix\n" - f" version: '1.0.0'\n" - f" type: url\n" - f" source: '{external_url}'\n" - ) - # Should not call urlopen since the URL is not a Gitea package URL - result = _try_gitea_mirror_install("ansible-galaxy", req) - assert result is True - mock_urlopen.assert_not_called() - - @patch("devx.tools.setup._run") - @patch("urllib.request.urlopen") - @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) - def test_mixed_entries_gitea_and_non_gitea( - self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path - ) -> None: - """Mix of Gitea URL entries and regular galaxy entries.""" - req = tmp_path / "requirements.yml" - req.write_text( - f"collections:\n" - f" - name: ansible.posix\n" - f" version: '1.0.0'\n" - f" type: url\n" - f" source: '{self._GITEA_URL}'\n" - f" - name: community.general\n" - f" version: '13.0.0'\n" - ) - mock_resp = MagicMock() - mock_resp.read.return_value = b"fake-tarball" - mock_resp.__enter__ = lambda _: mock_resp - mock_resp.__exit__ = lambda *a: None - mock_urlopen.return_value = mock_resp - - result = _try_gitea_mirror_install("ansible-galaxy", req) - assert result is True - # Only the Gitea URL entry should trigger a download - mock_urlopen.assert_called_once() - class TestConfigureTeaLogin: @patch("devx.tools.setup.shutil.which", return_value=None) diff --git a/tests/unit/test_setup_image.py b/tests/unit/test_setup_image.py index 213c692..d829dd2 100644 --- a/tests/unit/test_setup_image.py +++ b/tests/unit/test_setup_image.py @@ -52,6 +52,7 @@ class TestInstallInImage: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] assert "--no-cache-dir" in cmd + assert "--no-deps" in cmd assert "-e" in cmd assert "." in cmd # No extras → spec is "." diff --git a/tests/unit/test_tools_check_alert_rules.py b/tests/unit/test_tools_check_alert_rules.py new file mode 100644 index 0000000..0a9bcea --- /dev/null +++ b/tests/unit/test_tools_check_alert_rules.py @@ -0,0 +1,103 @@ +"""Unit tests for devx.tools.check_alert_rules.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from devx.tools.check_alert_rules import main + + +class TestMain: + def test_skip_when_promtool_not_found(self, tmp_path: Path): + """Should exit 0 and print skip message when promtool is not on PATH.""" + with patch("shutil.which", return_value=None): + runner = CliRunner() + result = runner.invoke(main, ["--template-path", str(tmp_path)]) + assert result.exit_code == 0 + assert "promtool not found" in result.output + + def test_validates_rules_successfully(self, tmp_path: Path): + """Should exit 0 when promtool reports SUCCESS.""" + (tmp_path / "alert-rules.yml.j2").write_text("groups: []") + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Checking /tmp/test.yml\n SUCCESS: 60 rules found\n" + mock_result.stderr = "" + with patch("shutil.which", return_value="/usr/bin/promtool"): + with patch("subprocess.run", return_value=mock_result): + runner = CliRunner() + result = runner.invoke(main, ["--template-path", str(tmp_path)]) + assert result.exit_code == 0 + + def test_fails_on_promtool_error(self, tmp_path: Path): + """Should exit non-zero when promtool reports an error.""" + (tmp_path / "alert-rules.yml.j2").write_text("groups: []") + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "Error: invalid template function 'default'\n" + with patch("shutil.which", return_value="/usr/bin/promtool"): + with patch("subprocess.run", return_value=mock_result): + runner = CliRunner() + result = runner.invoke(main, ["--template-path", str(tmp_path)]) + assert result.exit_code != 0 + + def test_uses_correct_template_path(self, tmp_path: Path): + """Should render the specified template from the given path.""" + (tmp_path / "alert-rules.yml.j2").write_text("groups: []") + captured_args = [] + + def fake_run(args, **kwargs): + captured_args.append(args) + mock = MagicMock() + mock.returncode = 0 + mock.stdout = "SUCCESS" + mock.stderr = "" + return mock + + with patch("shutil.which", return_value="/usr/bin/promtool"): + with patch("subprocess.run", side_effect=fake_run): + runner = CliRunner() + runner.invoke(main, ["--template-path", str(tmp_path)]) + assert captured_args[0][0] == "promtool" + assert captured_args[0][1] == "check" + assert captured_args[0][2] == "rules" + + def test_custom_template_name(self, tmp_path: Path): + """Should render a custom template name.""" + (tmp_path / "custom-rules.yml.j2").write_text("groups: []") + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "SUCCESS" + mock_result.stderr = "" + with patch("shutil.which", return_value="/usr/bin/promtool"): + with patch("subprocess.run", return_value=mock_result): + runner = CliRunner() + result = runner.invoke( + main, ["--template-path", str(tmp_path), "--template-name", "custom-rules.yml.j2"] + ) + assert result.exit_code == 0 + + def test_template_vars_passed(self, tmp_path: Path): + """Should pass template variables to the render call.""" + (tmp_path / "alert-rules.yml.j2").write_text("grafana: {{ grafana_base_url }}\ngroups: []") + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "SUCCESS" + mock_result.stderr = "" + with patch("shutil.which", return_value="/usr/bin/promtool"): + with patch("subprocess.run", return_value=mock_result): + runner = CliRunner() + result = runner.invoke( + main, + [ + "--template-path", + str(tmp_path), + "--var", + "grafana_base_url=https://grafana.test.example.com", + ], + ) + assert result.exit_code == 0 diff --git a/tests/unit/test_tools_check_ansible_set_fact_to_json.py b/tests/unit/test_tools_check_ansible_set_fact_to_json.py new file mode 100644 index 0000000..024d619 --- /dev/null +++ b/tests/unit/test_tools_check_ansible_set_fact_to_json.py @@ -0,0 +1,346 @@ +"""Unit tests for devx.tools.check_ansible_set_fact_to_json.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from click.testing import CliRunner + +from devx.tools.check_ansible_set_fact_to_json import ( + _check_file, + _check_task, + _check_task_list, + _find_task_files, + main, +) + + +class TestFindTaskFiles: + def test_single_file(self, tmp_path: Path): + f = tmp_path / "tasks.yml" + f.write_text("tasks: []") + assert _find_task_files(f) == [f] + + def test_directory_recursive(self, tmp_path: Path): + (tmp_path / "sub").mkdir() + f1 = tmp_path / "a.yml" + f2 = tmp_path / "sub" / "b.yml" + f1.write_text("tasks: []") + f2.write_text("tasks: []") + result = _find_task_files(tmp_path) + assert f1 in result + assert f2 in result + + def test_nonexistent_path(self, tmp_path: Path): + assert _find_task_files(tmp_path / "nonexistent") == [] + + def test_non_yaml_file_skipped(self, tmp_path: Path): + f = tmp_path / "readme.txt" + f.write_text("not yaml") + assert _find_task_files(f) == [] + + +class TestCheckTask: + def test_set_fact_with_to_json_flagged(self, tmp_path: Path): + task = {"name": "Set targets", "set_fact": {"customer_hosts": "{{ targets | to_json }}"}} + errors: list[str] = [] + _check_task(task, tmp_path / "test.yml", errors, tmp_path) + assert len(errors) == 1 + assert "customer_hosts" in errors[0] + assert "to_json" in errors[0] + + def test_set_fact_without_to_json_ok(self, tmp_path: Path): + task = {"name": "Set targets", "set_fact": {"customer_hosts": "{{ targets }}"}} + errors: list[str] = [] + _check_task(task, tmp_path / "test.yml", errors, tmp_path) + assert errors == [] + + def test_ansible_builtin_set_fact(self, tmp_path: Path): + task = {"name": "Set targets", "ansible.builtin.set_fact": {"my_list": "{{ items | to_nice_json }}"}} + errors: list[str] = [] + _check_task(task, tmp_path / "test.yml", errors, tmp_path) + assert len(errors) == 1 + assert "to_nice_json" in errors[0] + + def test_non_set_fact_task_ignored(self, tmp_path: Path): + task = {"name": "Render config", "copy": {"content": "{{ data | to_json }}"}} + errors: list[str] = [] + _check_task(task, tmp_path / "test.yml", errors, tmp_path) + assert errors == [] + + def test_cacheable_key_ignored(self, tmp_path: Path): + task = {"name": "Set fact", "set_fact": {"my_var": "{{ value }}", "cacheable": True}} + errors: list[str] = [] + _check_task(task, tmp_path / "test.yml", errors, tmp_path) + assert errors == [] + + def test_unnamed_task(self, tmp_path: Path): + task = {"set_fact": {"my_var": "{{ value | to_json }}"}} + errors: list[str] = [] + _check_task(task, tmp_path / "test.yml", errors, tmp_path) + assert len(errors) == 1 + assert "(unnamed)" in errors[0] + + def test_set_fact_not_dict_ignored(self, tmp_path: Path): + task = {"set_fact": "not a dict"} + errors: list[str] = [] + _check_task(task, tmp_path / "test.yml", errors, tmp_path) + assert errors == [] + + def test_to_json_no_spaces(self, tmp_path: Path): + task = {"set_fact": {"my_var": "{{ items|to_json }}"}} + errors: list[str] = [] + _check_task(task, tmp_path / "test.yml", errors, tmp_path) + assert len(errors) == 1 + + +class TestCheckTaskList: + def test_block_tasks_checked(self, tmp_path: Path): + tasks = [{"name": "Block", "block": [{"name": "Set in block", "set_fact": {"x": "{{ y | to_json }}"}}]}] + errors: list[str] = [] + _check_task_list(tasks, tmp_path / "test.yml", errors, tmp_path) + assert len(errors) == 1 + assert "x" in errors[0] + + def test_non_dict_task_ignored(self, tmp_path: Path): + tasks = ["just a string", 42, None] + errors: list[str] = [] + _check_task_list(tasks, tmp_path / "test.yml", errors, tmp_path) + assert errors == [] + + +class TestCheckFile: + def test_playbook_with_set_fact_to_json(self, tmp_path: Path): + content = textwrap.dedent(""" + - name: Deploy + hosts: all + tasks: + - name: Set targets + ansible.builtin.set_fact: + customer_hosts: "{{ targets | to_json }}" + """).strip() + f = tmp_path / "playbook.yml" + f.write_text(content) + errors = _check_file(f, tmp_path) + assert len(errors) == 1 + assert "customer_hosts" in errors[0] + + def test_playbook_without_set_fact(self, tmp_path: Path): + content = textwrap.dedent(""" + - name: Deploy + hosts: all + tasks: + - name: Debug + ansible.builtin.debug: + msg: "hello" + """).strip() + f = tmp_path / "playbook.yml" + f.write_text(content) + assert _check_file(f, tmp_path) == [] + + def test_role_tasks_file(self, tmp_path: Path): + content = textwrap.dedent(""" + - name: Set config + set_fact: + my_data: "{{ data | to_json }}" + - name: Copy config + copy: + content: "{{ config | to_json }}" + dest: /etc/config.json + """).strip() + f = tmp_path / "main.yml" + f.write_text(content) + errors = _check_file(f, tmp_path) + assert len(errors) == 1 + assert "my_data" in errors[0] + + def test_pre_tasks_and_post_tasks(self, tmp_path: Path): + content = textwrap.dedent(""" + - name: Play + hosts: all + pre_tasks: + - name: Pre set + set_fact: + pre_var: "{{ x | to_json }}" + post_tasks: + - name: Post set + set_fact: + post_var: "{{ y | to_json }}" + """).strip() + f = tmp_path / "playbook.yml" + f.write_text(content) + errors = _check_file(f, tmp_path) + assert len(errors) == 2 + + def test_handlers_checked(self, tmp_path: Path): + content = textwrap.dedent(""" + - name: Play + hosts: all + handlers: + - name: Restart service + set_fact: + restart_data: "{{ data | to_json }}" + """).strip() + f = tmp_path / "playbook.yml" + f.write_text(content) + errors = _check_file(f, tmp_path) + assert len(errors) == 1 + + def test_invalid_yaml(self, tmp_path: Path): + f = tmp_path / "bad.yml" + f.write_text("tasks: [invalid: {") + errors = _check_file(f, tmp_path) + assert len(errors) == 1 + assert "cannot parse YAML" in errors[0] + + def test_non_dict_doc_skipped(self, tmp_path: Path): + f = tmp_path / "list.yml" + f.write_text("- just\n- a\n- list\n") + assert _check_file(f, tmp_path) == [] + + def test_multi_doc_yaml(self, tmp_path: Path): + content = textwrap.dedent(""" + --- + - name: Play 1 + hosts: all + tasks: + - name: Set in play 1 + set_fact: + var1: "{{ x | to_json }}" + --- + - name: Play 2 + hosts: all + tasks: + - name: Set in play 2 + set_fact: + var2: "{{ y }}" + """).strip() + f = tmp_path / "multi.yml" + f.write_text(content) + errors = _check_file(f, tmp_path) + assert len(errors) == 1 + assert "var1" in errors[0] + + def test_dict_doc_role_tasks_file(self, tmp_path: Path): + content = textwrap.dedent(""" + tasks: + - name: Set var + set_fact: + my_var: "{{ value | to_json }}" + """).strip() + f = tmp_path / "main.yml" + f.write_text(content) + errors = _check_file(f, tmp_path) + assert len(errors) == 1 + assert "my_var" in errors[0] + + def test_play_with_roles_key(self, tmp_path: Path): + content = textwrap.dedent(""" + - name: Play + hosts: all + roles: + - role: my_role + tasks: + - name: Set in role + set_fact: + role_var: "{{ x | to_json }}" + """).strip() + f = tmp_path / "playbook.yml" + f.write_text(content) + errors = _check_file(f, tmp_path) + assert len(errors) == 1 + assert "role_var" in errors[0] + + def test_bare_task_in_list_with_block(self, tmp_path: Path): + content = textwrap.dedent(""" + - name: Outer task + set_fact: + outer: "{{ x | to_json }}" + - name: Block + block: + - name: Inner task + set_fact: + inner: "{{ y | to_json }}" + """).strip() + f = tmp_path / "tasks.yml" + f.write_text(content) + errors = _check_file(f, tmp_path) + assert len(errors) == 2 + + +class TestMain: + def test_passes_when_clean(self, tmp_path: Path): + content = textwrap.dedent(""" + - name: Play + hosts: all + tasks: + - name: Set var + set_fact: + my_var: "{{ value }}" + """).strip() + f = tmp_path / "playbook.yml" + f.write_text(content) + runner = CliRunner() + result = runner.invoke(main, ["--path", str(f)]) + assert result.exit_code == 0 + assert "OK" in result.output + + def test_fails_when_to_json_found(self, tmp_path: Path): + content = textwrap.dedent(""" + - name: Play + hosts: all + tasks: + - name: Set var + set_fact: + my_var: "{{ value | to_json }}" + """).strip() + f = tmp_path / "playbook.yml" + f.write_text(content) + runner = CliRunner() + result = runner.invoke(main, ["--path", str(f)]) + assert result.exit_code == 1 + assert "FAIL" in result.output + assert "my_var" in result.output + + def test_directory_scan(self, tmp_path: Path): + (tmp_path / "good.yml").write_text( + textwrap.dedent(""" + - name: Play + hosts: all + tasks: + - name: Set + set_fact: + x: "{{ y }}" + """).strip() + ) + (tmp_path / "bad.yml").write_text( + textwrap.dedent(""" + - name: Play + hosts: all + tasks: + - name: Set + set_fact: + x: "{{ y | to_json }}" + """).strip() + ) + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)]) + assert result.exit_code == 1 + assert "bad.yml" in result.output + + def test_custom_ansible_dirs(self, tmp_path: Path): + (tmp_path / "playbook.yml").write_text( + textwrap.dedent(""" + - name: Play + hosts: all + tasks: + - name: Set + set_fact: + x: "{{ y | to_json }}" + """).strip() + ) + runner = CliRunner() + result = runner.invoke(main, ["--ansible-dir", str(tmp_path)]) + assert result.exit_code == 1 + assert "playbook.yml" in result.output diff --git a/tests/unit/test_tools_check_docker_init.py b/tests/unit/test_tools_check_docker_init.py new file mode 100644 index 0000000..6b8e73c --- /dev/null +++ b/tests/unit/test_tools_check_docker_init.py @@ -0,0 +1,291 @@ +"""Unit tests for devx.tools.check_docker_init.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from click.testing import CliRunner + +from devx.tools.check_docker_init import _check_template, _find_compose_templates, _parse_services, main + + +class TestFindComposeTemplates: + def test_finds_docker_compose_templates(self, tmp_path: Path): + (tmp_path / "docker-compose.observability.yml.j2").write_text("services:") + (tmp_path / "docker-compose.service.yml.j2").write_text("services:") + result = _find_compose_templates(tmp_path) + assert len(result) == 2 + + def test_finds_exporters_compose(self, tmp_path: Path): + (tmp_path / "exporters-compose.yml.j2").write_text("services:") + result = _find_compose_templates(tmp_path) + assert len(result) == 1 + assert "exporters-compose" in str(result[0]) + + def test_finds_compose_yaml_templates(self, tmp_path: Path): + (tmp_path / "compose.yaml.j2").write_text("services:") + result = _find_compose_templates(tmp_path) + assert len(result) == 1 + + def test_single_file(self, tmp_path: Path): + f = tmp_path / "docker-compose.test.yml.j2" + f.write_text("services:") + result = _find_compose_templates(f) + assert result == [f] + + def test_nonexistent_path(self, tmp_path: Path): + assert _find_compose_templates(tmp_path / "nonexistent") == [] + + def test_deduplicates(self, tmp_path: Path): + (tmp_path / "docker-compose.yml.j2").write_text("services:") + result = _find_compose_templates(tmp_path) + assert len(result) == 1 + + def test_recursive(self, tmp_path: Path): + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "docker-compose.yml.j2").write_text("services:") + result = _find_compose_templates(tmp_path) + assert len(result) == 1 + + +class TestParseServices: + def test_basic_services(self): + content = textwrap.dedent(""" + services: + web: + image: nginx + healthcheck: + test: ["CMD", "curl", "localhost"] + db: + image: postgres + networks: + default: + """).strip() + services = _parse_services(content) + assert "web" in services + assert "db" in services + assert any("image: nginx" in line for line in services["web"]) + + def test_jinja2_service_names(self): + content = textwrap.dedent(""" + services: + {{ app_name }}: + image: {{ app_image }} + healthcheck: + test: ["CMD", "curl"] + {{ app_name }}-db: + image: postgres + networks: + traefik: + """).strip() + services = _parse_services(content) + assert "{{ app_name }}" in services + assert "{{ app_name }}-db" in services + + def test_no_services_section(self): + content = "version: '3'\nvolumes:\n data:" + assert _parse_services(content) == {} + + def test_service_at_end_of_file(self): + content = textwrap.dedent(""" + services: + web: + image: nginx + """).strip() + services = _parse_services(content) + assert "web" in services + + def test_volumes_ends_services(self): + content = textwrap.dedent(""" + services: + web: + image: nginx + volumes: + data: + """).strip() + services = _parse_services(content) + assert "web" in services + assert "data" not in services + + +class TestCheckTemplate: + def test_service_with_healthcheck_and_init_ok(self, tmp_path: Path): + content = textwrap.dedent(""" + services: + web: + image: nginx + init: true + healthcheck: + test: ["CMD", "curl", "localhost"] + networks: + default: + """).strip() + f = tmp_path / "docker-compose.yml.j2" + f.write_text(content) + assert _check_template(f, tmp_path) == [] + + def test_service_with_healthcheck_no_init_flagged(self, tmp_path: Path): + content = textwrap.dedent(""" + services: + web: + image: nginx + healthcheck: + test: ["CMD", "curl", "localhost"] + networks: + default: + """).strip() + f = tmp_path / "docker-compose.yml.j2" + f.write_text(content) + errors = _check_template(f, tmp_path) + assert len(errors) == 1 + assert "web" in errors[0] + assert "init: true" in errors[0] + + def test_service_without_healthcheck_ok(self, tmp_path: Path): + content = textwrap.dedent(""" + services: + web: + image: nginx + networks: + default: + """).strip() + f = tmp_path / "docker-compose.yml.j2" + f.write_text(content) + assert _check_template(f, tmp_path) == [] + + def test_multiple_services_some_missing(self, tmp_path: Path): + content = textwrap.dedent(""" + services: + good: + image: nginx + init: true + healthcheck: + test: ["CMD", "curl"] + bad: + image: redis + healthcheck: + test: ["CMD", "redis-cli", "ping"] + networks: + default: + """).strip() + f = tmp_path / "docker-compose.yml.j2" + f.write_text(content) + errors = _check_template(f, tmp_path) + assert len(errors) == 1 + assert "bad" in errors[0] + assert "good" not in errors[0] + + def test_no_services_section(self, tmp_path: Path): + content = "version: '3'\nvolumes:\n data:" + f = tmp_path / "docker-compose.yml.j2" + f.write_text(content) + assert _check_template(f, tmp_path) == [] + + def test_jinja2_conditional_service(self, tmp_path: Path): + content = textwrap.dedent(""" + services: + {% if backup_enabled %} + backup: + image: backup + healthcheck: + test: ["CMD-SHELL", "pgrep backup"] + {% endif %} + networks: + default: + """).strip() + f = tmp_path / "docker-compose.yml.j2" + f.write_text(content) + errors = _check_template(f, tmp_path) + assert len(errors) == 1 + assert "backup" in errors[0] + + def test_relative_path_in_error(self, tmp_path: Path): + content = textwrap.dedent(""" + services: + web: + image: nginx + healthcheck: + test: ["CMD"] + networks: + default: + """).strip() + f = tmp_path / "docker-compose.yml.j2" + f.write_text(content) + errors = _check_template(f, tmp_path) + assert len(errors) == 1 + assert "docker-compose.yml.j2" in errors[0] + assert str(tmp_path) not in errors[0] + + +class TestMain: + def test_passes_when_all_ok(self, tmp_path: Path): + content = textwrap.dedent(""" + services: + web: + image: nginx + init: true + healthcheck: + test: ["CMD"] + networks: + default: + """).strip() + f = tmp_path / "docker-compose.yml.j2" + f.write_text(content) + runner = CliRunner() + result = runner.invoke(main, ["--path", str(f)]) + assert result.exit_code == 0 + assert "OK" in result.output + + def test_fails_when_missing_init(self, tmp_path: Path): + content = textwrap.dedent(""" + services: + web: + image: nginx + healthcheck: + test: ["CMD"] + networks: + default: + """).strip() + f = tmp_path / "docker-compose.yml.j2" + f.write_text(content) + runner = CliRunner() + result = runner.invoke(main, ["--path", str(f)]) + assert result.exit_code == 1 + assert "FAIL" in result.output + assert "web" in result.output + + def test_default_dir(self, tmp_path: Path): + (tmp_path / "docker-compose.good.yml.j2").write_text( + textwrap.dedent(""" + services: + web: + image: nginx + init: true + healthcheck: + test: ["CMD"] + networks: + default: + """).strip() + ) + (tmp_path / "docker-compose.bad.yml.j2").write_text( + textwrap.dedent(""" + services: + db: + image: postgres + healthcheck: + test: ["CMD"] + networks: + default: + """).strip() + ) + runner = CliRunner() + result = runner.invoke(main, ["--templates-dir", str(tmp_path)]) + assert result.exit_code == 1 + assert "db" in result.output + + def test_no_templates_found(self, tmp_path: Path): + runner = CliRunner() + result = runner.invoke(main, ["--templates-dir", str(tmp_path)]) + assert result.exit_code == 0 + assert "OK" in result.output diff --git a/tests/unit/test_utils_api.py b/tests/unit/test_utils_api.py new file mode 100644 index 0000000..63bfda4 --- /dev/null +++ b/tests/unit/test_utils_api.py @@ -0,0 +1,171 @@ +"""Unit tests for devx.utils.api.APIClient.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from devx.utils.api import APIClient + + +class TestAPIClient: + @patch("devx.utils.api.requests.request") + def test_get(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {"Authorization": "Bearer token"}) + result = client.get("/users") + assert result is mock_resp + mock_req.assert_called_once_with( + "GET", + "https://api.example.com/users", + headers={"Authorization": "Bearer token"}, + timeout=30, + verify=True, + ) + + @patch("devx.utils.api.requests.request") + def test_post(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {}) + client.post("/users", json={"name": "alice"}) + mock_req.assert_called_once_with( + "POST", + "https://api.example.com/users", + headers={}, + timeout=30, + verify=True, + json={"name": "alice"}, + ) + + @patch("devx.utils.api.requests.request") + def test_put(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {}) + client.put("/users/1", json={"name": "bob"}) + mock_req.assert_called_once_with( + "PUT", + "https://api.example.com/users/1", + headers={}, + timeout=30, + verify=True, + json={"name": "bob"}, + ) + + @patch("devx.utils.api.requests.request") + def test_delete(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {}) + client.delete("/users/1") + mock_req.assert_called_once_with( + "DELETE", + "https://api.example.com/users/1", + headers={}, + timeout=30, + verify=True, + ) + + @patch("devx.utils.api.requests.request") + def test_patch(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {}) + client.patch("/users/1", json={"name": "carol"}) + mock_req.assert_called_once_with( + "PATCH", + "https://api.example.com/users/1", + headers={}, + timeout=30, + verify=True, + json={"name": "carol"}, + ) + + @patch("devx.utils.api.requests.request") + def test_auth_tuple(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {}, auth=("admin", "pass")) + client.get("/data") + mock_req.assert_called_once_with( + "GET", + "https://api.example.com/data", + headers={}, + timeout=30, + verify=True, + auth=("admin", "pass"), + ) + + @patch("devx.utils.api.requests.request") + def test_custom_timeout_and_verify(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {}, timeout=60, verify=False) + client.get("/data") + mock_req.assert_called_once_with( + "GET", + "https://api.example.com/data", + headers={}, + timeout=60, + verify=False, + ) + + @patch("devx.utils.api.requests.request") + def test_raises_on_error(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = requests.HTTPError("500") + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {}) + with pytest.raises(requests.HTTPError): + client.get("/fail") + + @patch("devx.utils.api.requests.request") + def test_strips_trailing_slash(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com/", {}) + client.get("/users") + mock_req.assert_called_once_with( + "GET", + "https://api.example.com/users", + headers={}, + timeout=30, + verify=True, + ) + + @patch("devx.utils.api.requests.request") + def test_kwargs_override_defaults(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {}, timeout=30) + client.get("/slow", timeout=120) + mock_req.assert_called_once_with( + "GET", + "https://api.example.com/slow", + headers={}, + timeout=120, + verify=True, + ) + + @patch("devx.utils.api.requests.request") + def test_no_auth_when_not_set(self, mock_req): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_req.return_value = mock_resp + client = APIClient("https://api.example.com", {}) + client.get("/data") + call_kwargs = mock_req.call_args.kwargs + assert "auth" not in call_kwargs diff --git a/tests/unit/test_utils_jinja.py b/tests/unit/test_utils_jinja.py new file mode 100644 index 0000000..47d1a80 --- /dev/null +++ b/tests/unit/test_utils_jinja.py @@ -0,0 +1,161 @@ +"""Unit tests for devx.utils.jinja.""" + +from __future__ import annotations + +import jinja2 + +from devx.utils.jinja import ( + make_env, + make_value_env, + regex_escape, + regex_replace, + regex_search, + render_manifest_values, + render_template, + render_value, + to_bool, + to_json, +) + + +class TestFilters: + def test_to_json(self): + assert to_json({"a": 1}) == '{"a": 1}' + + def test_to_json_list(self): + assert to_json([1, 2]) == "[1, 2]" + + def test_to_bool_true(self): + assert to_bool(True) is True + + def test_to_bool_false(self): + assert to_bool(False) is False + + def test_to_bool_string_true(self): + assert to_bool("yes") is True + + def test_to_bool_string_false(self): + assert to_bool("false") is False + + def test_to_bool_empty_string(self): + assert to_bool("") is False + + def test_to_bool_none(self): + assert to_bool(None) is False + + def test_to_bool_int(self): + assert to_bool(1) is True + assert to_bool(0) is False + + def test_regex_replace(self): + assert regex_replace("hello world", "world", "there") == "hello there" + + def test_regex_replace_with_pattern(self): + assert regex_replace("abc123", r"\d+", "X") == "abcX" + + def test_regex_escape(self): + assert regex_escape("a.b*c") == "a\\.b\\*c" + + def test_regex_search_found(self): + assert regex_search("hello world", r"world") == "world" + + def test_regex_search_not_found(self): + assert regex_search("hello", r"world") is None + + def test_regex_search_group(self): + assert regex_search("abc123", r"\d+") == "123" + + +class TestMakeEnv: + def test_returns_environment(self, tmp_path): + (tmp_path / "test.j2").write_text("hello {{ name }}") + env = make_env(str(tmp_path)) + assert isinstance(env, jinja2.Environment) + + def test_has_filters(self, tmp_path): + env = make_env(str(tmp_path)) + assert "to_json" in env.filters + assert "bool" in env.filters + assert "regex_replace" in env.filters + assert "regex_escape" in env.filters + assert "regex_search" in env.filters + + def test_cached(self, tmp_path): + env1 = make_env(str(tmp_path)) + env2 = make_env(str(tmp_path)) + assert env1 is env2 + + def test_auto_reload_disabled(self, tmp_path): + env = make_env(str(tmp_path)) + assert env.auto_reload is False + + def test_strict_undefined(self, tmp_path): + env = make_env(str(tmp_path)) + assert env.undefined is jinja2.StrictUndefined + + +class TestMakeValueEnv: + def test_returns_environment(self): + env = make_value_env() + assert isinstance(env, jinja2.Environment) + + def test_chainable_undefined(self): + env = make_value_env() + assert env.undefined is jinja2.ChainableUndefined + + def test_cached(self): + assert make_value_env() is make_value_env() + + def test_has_filters(self): + env = make_value_env() + assert "to_json" in env.filters + + +class TestRenderTemplate: + def test_renders_named_template(self, tmp_path): + (tmp_path / "test.j2").write_text("hello {{ name }}") + env = make_env(str(tmp_path)) + assert render_template(env, "test.j2", name="world") == "hello world" + + def test_renders_with_filters(self, tmp_path): + (tmp_path / "test.j2").write_text("{{ data | to_json }}") + env = make_env(str(tmp_path)) + assert render_template(env, "test.j2", data={"a": 1}) == '{"a": 1}' + + +class TestRenderValue: + def test_renders_string_with_expressions(self): + assert render_value("hello {{ name }}", {"name": "world"}) == "hello world" + + def test_passes_through_non_string(self): + assert render_value(42, {}) == 42 + + def test_passes_through_string_without_expressions(self): + assert render_value("plain text", {}) == "plain text" + + def test_passes_through_none(self): + assert render_value(None, {}) is None + + +class TestRenderManifestValues: + def test_renders_dict_values(self): + result = render_manifest_values({"key": "{{ value }}"}, {"value": "rendered"}) + assert result == {"key": "rendered"} + + def test_renders_list_values(self): + result = render_manifest_values(["{{ a }}", "{{ b }}"], {"a": "1", "b": "2"}) + assert result == ["1", "2"] + + def test_renders_nested(self): + result = render_manifest_values({"outer": {"inner": "{{ x }}"}}, {"x": "yes"}) + assert result == {"outer": {"inner": "yes"}} + + def test_passes_through_non_string(self): + result = render_manifest_values({"n": 42, "b": True, "l": [1, 2]}, {}) + assert result == {"n": 42, "b": True, "l": [1, 2]} + + def test_empty_dict(self): + assert render_manifest_values({}, {}) == {} + + def test_empty_list(self): + assert render_manifest_values([], {}) == [] diff --git a/tests/unit/test_utils_ui.py b/tests/unit/test_utils_ui.py new file mode 100644 index 0000000..32a4aae --- /dev/null +++ b/tests/unit/test_utils_ui.py @@ -0,0 +1,101 @@ +"""Unit tests for devx.utils.ui.""" + +from __future__ import annotations + +import logging +from unittest.mock import patch + +import pytest + +import devx.utils.ui as ui_mod +from devx.utils.ui import _console_level, configure_ui, say + + +class TestConsoleLevel: + def test_default_is_info(self) -> None: + with patch.dict("os.environ", {}, clear=True): + assert _console_level() == logging.INFO + + def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DEVX_LOG_LEVEL", "DEBUG") + assert _console_level() == logging.DEBUG + + def test_invalid_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DEVX_LOG_LEVEL", "VERBOSE") + assert _console_level() == logging.INFO + + def test_custom_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: + configure_ui(log_level_env_var="GRM_LOG_LEVEL") + try: + monkeypatch.setenv("GRM_LOG_LEVEL", "DEBUG") + monkeypatch.delenv("DEVX_LOG_LEVEL", raising=False) + assert _console_level() == logging.DEBUG + finally: + configure_ui() + + +class TestSay: + def test_echoes_to_console(self) -> None: + with patch("devx.utils.ui.click.echo") as mock_echo: + say("hello") + mock_echo.assert_called_once_with("hello", err=False) + + def test_logs_at_info_level(self) -> None: + with ( + patch("devx.utils.ui.click.echo"), + patch("devx.utils.ui.logging.getLogger") as mock_get_logger, + ): + mock_logger = mock_get_logger.return_value + say("hello") + mock_logger.log.assert_called_once_with(logging.INFO, "hello") + + def test_passes_level_and_err(self) -> None: + with ( + patch("devx.utils.ui.click.echo") as mock_echo, + patch("devx.utils.ui.logging.getLogger") as mock_get_logger, + ): + mock_logger = mock_get_logger.return_value + say("error msg", level=logging.ERROR, err=True) + mock_echo.assert_called_once_with("error msg", err=True) + mock_logger.log.assert_called_once_with(logging.ERROR, "error msg") + + def test_suppresses_console_below_level(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DEVX_LOG_LEVEL", "WARNING") + with ( + patch("devx.utils.ui.click.echo") as mock_echo, + patch("devx.utils.ui.logging.getLogger") as mock_get_logger, + ): + mock_logger = mock_get_logger.return_value + say("debug msg", level=logging.DEBUG) + mock_echo.assert_not_called() + mock_logger.log.assert_called_once_with(logging.DEBUG, "debug msg") + + def test_color_applied(self) -> None: + with ( + patch("devx.utils.ui.click.echo") as mock_echo, + patch("devx.utils.ui.click.style") as mock_style, + ): + mock_style.return_value = "styled-output" + say("success", color="green") + mock_style.assert_called_once_with("success", fg="green") + mock_echo.assert_called_once_with("styled-output", err=False) + + def test_custom_logger_name(self) -> None: + configure_ui(logger_name="grm") + try: + with ( + patch("devx.utils.ui.click.echo"), + patch("devx.utils.ui.logging.getLogger") as mock_get_logger, + ): + say("hello") + mock_get_logger.assert_called_with("grm") + finally: + configure_ui() + + +class TestConfigureUi: + def test_reset_to_defaults(self) -> None: + configure_ui(log_level_env_var="GRM_LOG_LEVEL", logger_name="grm") + configure_ui() + assert ui_mod._LOG_LEVEL_ENV_VAR == "DEVX_LOG_LEVEL" + assert ui_mod._LOGGER_NAME == "devx"