DEVX-153: feat: sync missing features from v0.49.x line to master
Post-merge / detect-and-configure (push) Successful in 16s
Post-merge / release-and-maintain (push) Successful in 1m2s

Co-authored-by: emil User <emil.simeonov@tutanota.com>
This commit was merged in pull request #267.
This commit is contained in:
2026-08-09 01:09:20 +00:00
committed by emo
parent d2aa4c6298
commit 3d4b4940ff
79 changed files with 4697 additions and 361 deletions
+10
View File
@@ -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
+11 -3
View File
@@ -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:
+10 -2
View File
@@ -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 }}
+1 -1
View File
@@ -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
+9
View File
@@ -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]\.'
+64
View File
@@ -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
+12
View File
@@ -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
+13
View File
@@ -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:
- '(?<!Note: )(?<!Caution: )(?<!Warning: )(?<!Success: )(?<=:\s)[A-Z]\w+'
+30
View File
@@ -0,0 +1,30 @@
extends: substitution
message: "Use '%s' instead of '%s'."
link: 'https://developers.google.com/style/contractions'
level: suggestion
ignorecase: true
action:
name: replace
swap:
are not: aren't
cannot: can't
could not: couldn't
did not: didn't
do not: don't
does not: doesn't
has not: hasn't
have not: haven't
how is: how's
is not: isn't
it is: it's
should not: shouldn't
that is: that's
they are: they're
was not: wasn't
we are: we're
we have: we've
were not: weren't
what is: what's
when is: when's
where is: where's
will not: won't
+9
View File
@@ -0,0 +1,9 @@
extends: existence
message: "Use 'July 31, 2016' format, not '%s'."
link: 'https://developers.google.com/style/dates-times'
ignorecase: true
level: error
nonword: true
tokens:
- '\d{1,2}(?:\.|/)\d{1,2}(?:\.|/)\d{4}'
- '\d{1,2} (?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?) \d{4}'
+9
View File
@@ -0,0 +1,9 @@
extends: existence
message: "In general, don't use an ellipsis."
link: 'https://developers.google.com/style/ellipses'
nonword: true
level: warning
action:
name: remove
tokens:
- '\.\.\.'
+13
View File
@@ -0,0 +1,13 @@
extends: existence
message: "Don't put a space before or after a dash."
link: "https://developers.google.com/style/dashes"
nonword: true
level: error
action:
name: edit
params:
- trim
- " "
tokens:
- '\s[—–]\s'
+14
View File
@@ -0,0 +1,14 @@
extends: existence
message: "Avoid the unverifiable claim '%s'."
link: https://developers.google.com/style/excessive-claims
level: suggestion
ignorecase: true
# The guide also names 'never', 'always', and 'ensure', but in technical writing
# those are usually legitimate instructions ('never commit secrets') rather than
# product claims: they accounted for 125 of 142 hits on a 950-file corpus.
# 'best practices' is a fixed term, not a superlative.
tokens:
- 'best(?! practices?)'
- simplest
- fastest
- guarantees?
+12
View File
@@ -0,0 +1,12 @@
extends: existence
message: "Don't use exclamation points in text."
link: "https://developers.google.com/style/exclamation-points"
nonword: true
level: error
action:
name: edit
params:
- trim_right
- "!"
tokens:
- '\w+!(?:\s|$)'
+15
View File
@@ -0,0 +1,15 @@
extends: existence
message: "Avoid first-person pronouns such as '%s'."
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
ignorecase: true
level: warning
# The 'I' tokens use lookaround rather than consuming the surrounding
# whitespace. Matching ' I ' made the alert span cover both spaces, which shows
# up as a too-wide underline in editors, and read as "such as ' I '". Dropping
# `nonword` also lets a project Vocab apply, which it can't when set. See PR #50.
tokens:
- '(?<=^|\s)I(?=[\s,])'
- "\\bI'm\\b"
- \bme\b
- \bmy\b
- \bmine\b
+9
View File
@@ -0,0 +1,9 @@
extends: existence
message: "Don't use '%s' as a gender-neutral pronoun."
link: 'https://developers.google.com/style/pronouns#gender-neutral-pronouns'
level: error
ignorecase: true
tokens:
- he/she
- s/he
- \(s\)he
+43
View File
@@ -0,0 +1,43 @@
extends: substitution
message: "Consider using '%s' instead of '%s'."
ignorecase: true
link: "https://developers.google.com/style/inclusive-documentation"
level: error
action:
name: replace
swap:
(?:alumna|alumnus): graduate
(?:alumnae|alumni): graduates
air(?:m[ae]n|wom[ae]n): pilot(s)
anchor(?:m[ae]n|wom[ae]n): anchor(s)
authoress: author
camera(?:m[ae]n|wom[ae]n): camera operator(s)
door(?:m[ae]|wom[ae]n): concierge(s)
draft(?:m[ae]n|wom[ae]n): drafter(s)
fire(?:m[ae]n|wom[ae]n): firefighter(s)
fisher(?:m[ae]n|wom[ae]n): fisher(s)
fresh(?:m[ae]n|wom[ae]n): first-year student(s)
garbage(?:m[ae]n|wom[ae]n): waste collector(s)
lady lawyer: lawyer
ladylike: courteous
mail(?:m[ae]n|wom[ae]n): mail carriers
man and wife: husband and wife
man enough: strong enough
mankind: human kind|humanity
manmade: manufactured
manpower: personnel
middle(?:m[ae]n|wom[ae]n): intermediary
news(?:m[ae]n|wom[ae]n): journalist(s)
ombuds(?:man|woman): ombuds
oneupmanship: upstaging
poetess: poet
police(?:m[ae]n|wom[ae]n): police officer(s)
repair(?:m[ae]n|wom[ae]n): technician(s)
sales(?:m[ae]n|wom[ae]n): salesperson or sales people
service(?:m[ae]n|wom[ae]n): soldier(s)
steward(?:ess)?: flight attendant
tribes(?:m[ae]n|wom[ae]n): tribe member(s)
waitress: waiter
woman doctor: doctor
woman scientist[s]?: scientist(s)
work(?:m[ae]n|wom[ae]n): worker(s)
@@ -0,0 +1,13 @@
extends: existence
message: "Don't put a period at the end of a heading."
link: "https://developers.google.com/style/capitalization#capitalization-in-titles-and-headings"
nonword: true
level: warning
scope: heading
action:
name: edit
params:
- trim_right
- "."
tokens:
- '[a-z0-9][.]\s*$'
+32
View File
@@ -0,0 +1,32 @@
extends: capitalization
message: "'%s' should use sentence-style capitalization."
link: "https://developers.google.com/style/capitalization#capitalization-in-titles-and-headings"
level: warning
scope: heading
match: $sentence
# No `indicators: [":"]` here. That makes Vale require a capital after a colon,
# which is the Microsoft convention this rule was originally copied from. This
# guide says the opposite: "the first word after a colon is generally
# lowercase" (developers.google.com/style/colons), and Colons.yml enforces
# exactly that. See issue #58.
exceptions:
- Azure
- CLI
- Cosmos
- Docker
- Emmet
- gRPC
- I
- Kubernetes
- Linux
- macOS
- Marketplace
- MongoDB
- REPL
- Studio
- TypeScript
- URLs
- Visual
- VS
- Windows
- JSON
+13
View File
@@ -0,0 +1,13 @@
extends: existence
message: "Avoid the jargon '%s'."
link: https://developers.google.com/style/jargon
level: suggestion
ignorecase: true
# The guide also cites 'solution', 'support', and 'workload' as overloaded
# terms, but those have ordinary technical meanings and accounted for every hit
# on a 950-file corpus, so only the unambiguous figurative terms are listed.
tokens:
- break-glass
- camel ?case
- out-of-the-box
- swim ?lane
+15
View File
@@ -0,0 +1,15 @@
extends: substitution
message: "Use '%s' instead of '%s'."
link: 'https://developers.google.com/style/abbreviations'
ignorecase: true
level: error
nonword: true
action:
name: replace
# The delimiter is a lookahead so the replacement doesn't swallow the comma or
# space that follows (issue #18). `$` is included so the abbreviation is still
# caught at the end of a heading, table cell, or block, which accounted for 8
# of 10 occurrences on a 950-file corpus.
swap:
'\b(?:eg|e\.g\.)(?=[\s,;]|$)': for example
'\b(?:ie|i\.e\.)(?=[\s,;]|$)': that is
+14
View File
@@ -0,0 +1,14 @@
extends: existence
message: "'%s' doesn't need a hyphen."
link: "https://developers.google.com/style/hyphens"
level: error
ignorecase: false
nonword: true
action:
name: edit
params:
- regex
- "-"
- " "
tokens:
- '\b[^\s-]+ly-\w+\b'
+12
View File
@@ -0,0 +1,12 @@
extends: existence
message: "Don't use plurals in parentheses such as in '%s'."
link: "https://developers.google.com/style/plurals-parentheses"
level: error
nonword: true
action:
name: edit
params:
- trim_right
- "(s)"
tokens:
- '\b\w+\(s\)'
+7
View File
@@ -0,0 +1,7 @@
extends: existence
message: "Spell out all ordinal numbers ('%s') in text."
link: 'https://developers.google.com/style/numbers'
level: error
nonword: true
tokens:
- \d+(?:st|nd|rd|th)
+28
View File
@@ -0,0 +1,28 @@
extends: existence
message: "Use the Oxford comma in '%s'."
link: 'https://developers.google.com/style/commas'
scope: sentence
level: warning
nonword: true
# List items may be several words long, not just one. Four guards keep the
# false-positive rate down:
#
# 1. The comma can't be the one closing a fronted subordinate clause
# ('When your alarm rings, you turn it off and tumble out of bed.') --
# that comma separates clauses, not list items. Only the first comma of
# such a sentence is exempt, so 'When it rains, apples, pears or bananas
# get wet.' is still caught.
# 2. The item can't open with a clause-introducer (', which ...',
# ', specifically ...').
# 3. The item can't open with a subject pronoun followed by a verb, which
# marks a compound predicate rather than a list ('..., you walk to the
# fridge and get a snack.'). A pronoun directly followed by 'and'/'or'
# is a real list item, so ', you and me.' still matches.
# 4. Neither item may contain an auxiliary verb, which is another compound
# predicate signal (', it has some downsides and is officially
# discouraged.').
#
# The trailing anchor allows end-of-scope so list fragments ('Apples, pears
# or bananas') are still caught.
tokens:
- '(?<!^(?i:when|whenever|while|if|unless|until|although|though|because|since|after|before|once|whereas|whether|as)\b[^,]{0,80}),\s(?!(?:which|who|whom|whose|that|where|when|while|because|since|although|though|if|unless|so|but|and|or|however|therefore|thus|specifically|especially|namely|then|take|see|note|consider|make|use|either|neither)\b)(?!(?i:i|you|we|they|he|she|it)\s+(?!(?:and|or)\b))(?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+ (?:and|or) (?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+(?:[.?!]|$)'
+15
View File
@@ -0,0 +1,15 @@
extends: existence
message: "Use parentheses judiciously."
link: 'https://developers.google.com/style/parentheses'
nonword: true
level: suggestion
# `[^)]` rather than `.+`: a greedy match ran from the first '(' on a line to
# the last ')', so 'Text (one) and more (two).' produced a single alert
# covering everything between them. See issue #30.
# A bare 3-5 letter acronym is skipped: Acronyms.yml requires acronyms to be
# defined as 'Spelled Out Term (ACRONYM)', so flagging those parentheses would
# put the two rules in direct conflict. The acronym has to be the whole
# parenthetical — '(NASA rocket program)' is an ordinary aside and still
# flags. Length matches the {3,5} in Acronyms.yml. See PR #59.
tokens:
- '\((?![A-Z]{3,5}\))[^)]+\)'
+184
View File
@@ -0,0 +1,184 @@
extends: existence
link: 'https://developers.google.com/style/voice'
message: "In general, use active voice instead of passive voice ('%s')."
ignorecase: true
level: suggestion
raw:
- \b(am|are|were|being|is|been|was|be)\b\s*
tokens:
- '[\w]+ed'
- awoken
- beat
- become
- been
- begun
- bent
- beset
- bet
- bid
- bidden
- bitten
- bled
- blown
- born
- bought
- bound
- bred
- broadcast
- broken
- brought
- built
- burnt
- burst
- cast
- caught
- chosen
- clung
- come
- cost
- crept
- cut
- dealt
- dived
- done
- drawn
- dreamt
- driven
- drunk
- dug
- eaten
- fallen
- fed
- felt
- fit
- fled
- flown
- flung
- forbidden
- foregone
- forgiven
- forgotten
- forsaken
- fought
- found
- frozen
- given
- gone
- gotten
- ground
- grown
- heard
- held
- hidden
- hit
- hung
- hurt
- kept
- knelt
- knit
- known
- laid
- lain
- leapt
- learnt
- led
- left
- lent
- let
- lighted
- lost
- made
- meant
- met
- misspelt
- mistaken
- mown
- overcome
- overdone
- overtaken
- overthrown
- paid
- pled
- proven
- put
- quit
- read
- rid
- ridden
- risen
- run
- rung
- said
- sat
- sawn
- seen
- sent
- set
- sewn
- shaken
- shaven
- shed
- shod
- shone
- shorn
- shot
- shown
- shrunk
- shut
- slain
- slept
- slid
- slit
- slung
- smitten
- sold
- sought
- sown
- sped
- spent
- spilt
- spit
- split
- spoken
- spread
- sprung
- spun
- stolen
- stood
- stridden
- striven
- struck
- strung
- stuck
- stung
- stunk
- sung
- sunk
- swept
- swollen
- sworn
- swum
- swung
- taken
- taught
- thought
- thrived
- thrown
- thrust
- told
- torn
- trodden
- understood
- upheld
- upset
- wed
- wept
- withheld
- withstood
- woken
- won
- worn
- wound
- woven
- written
- wrung
+7
View File
@@ -0,0 +1,7 @@
extends: existence
message: "Don't use periods with acronyms or initialisms such as '%s'."
link: 'https://developers.google.com/style/abbreviations'
level: error
nonword: true
tokens:
- '\b(?:[A-Z]\.){3,}'
+7
View File
@@ -0,0 +1,7 @@
extends: existence
message: "Commas and periods go inside quotation marks."
link: 'https://developers.google.com/style/quotation-marks'
level: error
nonword: true
tokens:
- '"[^"]+"[.,?]'
+7
View File
@@ -0,0 +1,7 @@
extends: existence
message: "Don't add words such as 'from' or 'between' to describe a range of numbers."
link: 'https://developers.google.com/style/hyphens'
nonword: true
level: warning
tokens:
- '(?:from|between)\s\d+\s?-\s?\d+'
+8
View File
@@ -0,0 +1,8 @@
extends: existence
message: "Use semicolons judiciously."
link: 'https://developers.google.com/style/semicolons'
nonword: true
scope: sentence
level: suggestion
tokens:
- ';'
+11
View File
@@ -0,0 +1,11 @@
extends: existence
message: "Don't use internet slang abbreviations such as '%s'."
link: 'https://developers.google.com/style/abbreviations'
ignorecase: true
level: error
tokens:
- 'tl;dr'
- ymmv
- rtfm
- imo
- fwiw
+10
View File
@@ -0,0 +1,10 @@
extends: existence
message: "'%s' should have one space."
link: 'https://developers.google.com/style/sentence-spacing'
level: error
nonword: true
action:
name: remove
tokens:
- '[a-z][.?!] {2,}[A-Z]'
- '[a-z][.?!][A-Z]'
+10
View File
@@ -0,0 +1,10 @@
extends: existence
message: "In general, use American spelling instead of '%s'."
link: 'https://developers.google.com/style/spelling'
ignorecase: true
level: warning
tokens:
- '(?:\w+)nised?'
- 'colour'
- 'labour'
- 'centre'
+13
View File
@@ -0,0 +1,13 @@
extends: existence
message: "Avoid time-based words like '%s' in product documentation."
link: https://developers.google.com/style/timeless-documentation
level: suggestion
ignorecase: true
# The guide also names 'now' and 'new', but both have common senses that aren't
# time-anchored ('create a new project'): adding them took a 950-file corpus of
# technical documentation from 14 hits to 117. 'recently' is left out too — every
# hit in that corpus was the UI idiom 'recently used'.
tokens:
- currently
- latest
- soon
+10
View File
@@ -0,0 +1,10 @@
extends: existence
message: "Put a nonbreaking space between the number and the unit in '%s'."
link: "https://developers.google.com/style/units-of-measure"
nonword: true
level: error
tokens:
- '\b\d+(?:B|kB|MB|GB|TB)\b'
- '\b\d+(?:ns|ms|min|h|d)\b'
# Seconds are split out so a decade ('1990s') isn't read as a unit.
- '\b\d+s\b(?<!\b(?:19|20)\d\ds\b)'
+11
View File
@@ -0,0 +1,11 @@
extends: existence
message: "Try to avoid using first-person plural like '%s'."
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
level: warning
ignorecase: true
tokens:
- we
- we'(?:ve|re)
- ours?
- us
- let's
+7
View File
@@ -0,0 +1,7 @@
extends: existence
message: "Avoid using '%s'."
link: 'https://developers.google.com/style/tense'
ignorecase: true
level: warning
tokens:
- will
+29
View File
@@ -0,0 +1,29 @@
extends: substitution
message: "Use '%s' instead of '%s'."
link: "https://developers.google.com/style/word-list"
level: warning
# Case matters here: each key's own capitalization is what's being corrected,
# so ignorecase would make these match their own replacements. The rest of the
# word list lives in WordListCase.yml.
ignorecase: false
action:
name: replace
swap:
Ajax: AJAX
Android device: Android-powered device
android: Android
API explorer: APIs Explorer
authN: authentication
authZ: authorization
CLI: command-line tool
Cloud: Google Cloud Platform|GCP
Container Engine: Kubernetes Engine
Developers Console: Google API Console|API Console
Google account: Google Account
Google accounts: Google Accounts
Googling: search with Google
HTTPs: HTTPS
k8s: Kubernetes
SHA1: SHA-1|HAS-SHA1
url: URL
World Wide Web: web
+68
View File
@@ -0,0 +1,68 @@
extends: substitution
message: "Use '%s' instead of '%s'."
link: "https://developers.google.com/style/word-list"
level: warning
# The case-insensitive half of the word list, so sentence-initial use is caught
# ('Touch the screen', not only 'touch the screen'). Entries that must stay
# case-sensitive are in WordList.yml.
ignorecase: true
action:
name: replace
swap:
"(?:API Console|dev|developer) key": API key
"(?:cell ?phone|smart ?phone)": phone|mobile phone
"(?:dev|developer|APIs) console": API console
"(?:e-mail|Email|E-mail)": email
"(?:file ?path|path ?name)": path
"(?:kill|terminate|abort)": stop|exit|cancel|end
# Longest form first: with the shortest alternative leading, 'OAuth 2' matched
# only 'OAuth', so applying the suggestion produced 'OAuth 2.0 2'. The rule is
# already case-insensitive, so the inline (?i) is redundant. See issue #41.
'\bOauth2\.0\b|\bOAuth ?2\b(?!\.0)|\bOauth\b(?! ?2)': OAuth 2.0
"(?:ok|Okay)": OK|okay
"(?:WiFi|wifi)": Wi-Fi
'[\.]+apk': APK
'3\-D': 3D
'Google (?:I\-O|IO)': Google I/O
"tap (?:&|and) hold": touch & hold
"un(?:check|select)": clear
above: preceding
account name: username
action bar: app bar
admin: administrator
a\.k\.a|aka: or|also known as
application: app
approx\.: approximately
autoupdate: automatically update
cellular data: mobile data
cellular network: mobile network
chapter: documents|pages|sections
check box: checkbox
click on: click|click in
content type: media type
curated roles: predefined roles
data are: data is
disabled?: turn off|off
ephemeral IP address: ephemeral external IP address
fewer data: less data
file name: filename
firewalls: firewall rules
functionality: capability|feature
grayed-out: unavailable
in order to: to
ingest: import|load
long press: touch & hold
network IP address: internal IP address
omnibox: address bar
open-source: open source
overview screen: recents screen
regex: regular expression
sign into: sign in to
'(?<!single )sign-?on': single sign-on
static IP address: static external IP address
stylesheet: style sheet
synch: sync
tablename: table name
tablet: device
'touch(?! ?(?:&|and) hold)': tap
vs\.: versus
+16 -3
View File
@@ -28,6 +28,11 @@ make workflow-check # workflow-lint + workflow-dryrun
make devx-check-doc-versions # Verify docs version refs match __version__
make devx-vale # Run Vale prose linter on docs and README
make clean # Remove caches, build artifacts, coverage data
make check-workflow-artifact-deps # Verify artifact download jobs depend on upload jobs
make check-workflow-tofu-init # Verify tofu-state jobs have a tofu-init step
make check-docker-init # Check Docker Compose services with healthchecks have init: true
make check-ansible-set-fact-to-json # Check set_fact tasks don't misuse to_json
make check-alert-rules # Validate Prometheus alert rules with promtool
```
`make setup` automatically installs all development tools:
@@ -90,7 +95,10 @@ src/devx/
│ ├── doc_coverage.py # Documentation coverage check
│ ├── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
│ ├── validate_deploy_ref.py # Validate git tag for deployments (--github-output)
── record_deployed_tag.py # Record deployed tag to Gitea repo variable
── record_deployed_tag.py # Record deployed tag to Gitea repo variable
│ ├── cancel_superseded_runs.py # Cancel in-flight CI runs for the same PR branch
│ ├── check_workflow_artifact_deps.py # Verify artifact download jobs depend on upload jobs
│ └── check_workflow_tofu_init.py # Verify tofu-state jobs have a tofu-init step
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
@@ -113,10 +121,13 @@ src/devx/
│ ├── pr_logs.py # Fetch logs for failed CI jobs
│ ├── pr_label.py # Add labels to PRs (idempotent)
│ ├── pre_push_check.py # Validate Vikunja task existence before push
│ ├── check_docker_init.py # Check Docker Compose services with healthchecks have init: true
│ ├── check_ansible_set_fact_to_json.py # Check set_fact tasks don't misuse to_json
│ ├── check_alert_rules.py # Validate Prometheus alert rules with promtool
│ └── _shared.py # Shared tool utilities
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
├── utils/ # Shared utilities (reusable across projects)
│ ├── api.py # API response helpers (is_truthy, is_falsy)
│ ├── api.py # API response helpers (is_truthy, is_falsy) + APIClient base class
│ ├── ssh.py # SSH exec + wait_for_ssh (pure-Python socket check)
│ ├── crypto.py # Secret generation (shell-safe passwords)
│ ├── vault.py # Ansible vault encrypt/decrypt helpers
@@ -124,7 +135,9 @@ src/devx/
│ ├── confirm.py # Typed confirmation validation for destructive ops
│ ├── json_registry.py # File-locked JSON registry for local state
│ ├── step_tracker.py # Multi-step operation tracking with reports
── logging.py # XDG-compliant logging configuration
── logging.py # XDG-compliant logging configuration
│ ├── ui.py # say() — unified click.echo + logging output
│ └── jinja.py # Jinja2 environment helpers + Ansible-compatible filters
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
+27 -1
View File
@@ -1,4 +1,5 @@
.PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all lint-dockerfiles test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images
.PHONY: check-workflow-artifact-deps check-workflow-tofu-init check-docker-init check-ansible-set-fact-to-json check-alert-rules
PYTHON := python3
VENV := .venv
@@ -65,7 +66,7 @@ setup-release: $(VENV)/bin/activate .env
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir --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:
+10 -10
View File
@@ -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
+9 -9
View File
@@ -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
@@ -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.
+6 -6
View File
@@ -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`
+104 -4
View File
@@ -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 <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 <owner/repo>` — repository (required)
- `--current-run-id <id>` — current run ID, not cancelled (required)
- `--head-branch <branch>` — PR head branch name (required)
- `--dry-run` — list superseded runs without cancelling
- `--base-url <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 <path>` — check a specific workflow file
- `--workflows-dir <path>` — 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 <path>` — check a specific workflow file
- `--workflows-dir <path>` — override workflows directory
- `--state-script <name>` — 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 <prefix>` — task ID prefix (default: `DEVX_TASK_PREFIX` env var
or `DEVX`)
- `--output <file>` — output file path (default: `cliff.toml`)
- `--output <file>` — 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 <N>` — 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 <path>` — check a specific file or directory
- `--templates-dir <path>` — 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 <path>` — check a specific file or directory
- `--ansible-dir <path>` — 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>` — path to templates directory (required)
- `--template-name <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]`).
+2 -2
View File
@@ -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",
]
```
+9 -4
View File
@@ -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 = [
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.48.2"
__version__ = "0.49.5"
+185
View File
@@ -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())
+163
View File
@@ -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()
+145
View File
@@ -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()
+42
View File
@@ -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])."""
+34 -5
View File
@@ -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)
+42 -2
View File
@@ -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}")
+86
View File
@@ -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()
@@ -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()
+166
View File
@@ -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()
+26 -2
View File
@@ -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
-94
View File
@@ -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.
+3 -1
View File
@@ -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:
+94 -6
View File
@@ -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.
+133
View File
@@ -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)
+79
View File
@@ -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)
@@ -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()
@@ -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
@@ -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
+55
View File
@@ -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
+100
View File
@@ -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)
+27
View File
@@ -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:
-204
View File
@@ -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)
+1
View File
@@ -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 "."
+103
View File
@@ -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
@@ -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
+291
View File
@@ -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
+171
View File
@@ -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
+161
View File
@@ -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([], {}) == []
+101
View File
@@ -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"