Compare commits

..
12 Commits
Author SHA1 Message Date
devx-ci-bot 8f3c483eff release: v0.48.0 [skip ci] 2026-08-08 21:25:56 +00:00
emilandemo 30389ff3e7 DEVX-150: feat(setup): mirror Ansible collections from Gitea registry with auth
Post-merge / detect-and-configure (push) Successful in 26s
Post-merge / release-and-maintain (push) Successful in 1m0s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-08-08 21:25:00 +00:00
gitea-actions-bot ef1ff15593 chore: update badge URLs to commit 8cca99ef [skip ci] 2026-08-05 19:04:47 +00:00
devx-ci-bot 0d0580c4fd release: v0.47.10 [skip ci] 2026-08-05 19:03:37 +00:00
kiretoandemo ed3bd75367 DEVX-149: fix: unique molecule container names per CI runner
Post-merge / detect-and-configure (push) Successful in 30s
Post-merge / release-and-maintain (push) Successful in 2m22s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-05 19:01:54 +00:00
gitea-actions-bot ed0a282a52 chore: update badge URLs to commit ef796e01 [skip ci] 2026-08-03 23:02:08 +00:00
devx-ci-bot e4e0a534ff release: v0.47.9 [skip ci] 2026-08-03 23:00:12 +00:00
kiretoandemo e35ee2d71a DEVX-148: fix: unique molecule container names per CI runner
Post-merge / detect-and-configure (push) Successful in 28s
Post-merge / release-and-maintain (push) Successful in 3m42s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-03 22:57:58 +00:00
gitea-actions-bot 1d9e505432 chore: update badge URLs to commit 7640b110 [skip ci] 2026-08-03 21:54:06 +00:00
devx-ci-bot ddb0f17886 release: v0.47.8 [skip ci] 2026-08-03 21:53:01 +00:00
kiretoandemo a8a8b743f3 DEVX-147: fix: increase CI_SCALE_FACTOR default from 4 to 6
Post-merge / detect-and-configure (push) Successful in 18s
Post-merge / release-and-maintain (push) Successful in 2m1s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-03 21:51:43 +00:00
gitea-actions-bot a487bddb09 chore: update badge URLs to commit 283191bf [skip ci] 2026-08-03 21:42:42 +00:00
42 changed files with 465 additions and 696 deletions
-9
View File
@@ -1,9 +0,0 @@
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
@@ -1,64 +0,0 @@
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
-8
View File
@@ -1,8 +0,0 @@
extends: existence
message: "'%s' should be in lowercase."
link: 'https://developers.google.com/style/colons'
nonword: true
level: warning
scope: sentence
tokens:
- '(?<!:[^ ]+?):\s[A-Z]'
-30
View File
@@ -1,30 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,13 +0,0 @@
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'
-12
View File
@@ -1,12 +0,0 @@
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|$)'
-13
View File
@@ -1,13 +0,0 @@
extends: existence
message: "Avoid first-person pronouns such as '%s'."
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
ignorecase: true
level: warning
nonword: true
tokens:
- (?:^|\s)I\s
- (?:^|\s)I,\s
- \bI'm\b
- \bme\b
- \bmy\b
- \bmine\b
-9
View File
@@ -1,9 +0,0 @@
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
@@ -1,43 +0,0 @@
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)
@@ -1,13 +0,0 @@
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*$'
-29
View File
@@ -1,29 +0,0 @@
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
indicators:
- ":"
exceptions:
- Azure
- CLI
- Cosmos
- Docker
- Emmet
- gRPC
- I
- Kubernetes
- Linux
- macOS
- Marketplace
- MongoDB
- REPL
- Studio
- TypeScript
- URLs
- Visual
- VS
- Windows
- JSON
-11
View File
@@ -1,11 +0,0 @@
extends: substitution
message: "Use '%s' instead of '%s'."
link: 'https://developers.google.com/style/abbreviations'
ignorecase: true
level: error
nonword: true
action:
name: replace
swap:
'\b(?:eg|e\.g\.)(?=[\s,;])': for example
'\b(?:ie|i\.e\.)(?=[\s,;])': that is
-14
View File
@@ -1,14 +0,0 @@
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
@@ -1,12 +0,0 @@
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
@@ -1,7 +0,0 @@
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)
-7
View File
@@ -1,7 +0,0 @@
extends: existence
message: "Use the Oxford comma in '%s'."
link: 'https://developers.google.com/style/commas'
scope: sentence
level: warning
tokens:
- '(?:[^,]+,){1,}\s\w+\s(?:and|or)'
-7
View File
@@ -1,7 +0,0 @@
extends: existence
message: "Use parentheses judiciously."
link: 'https://developers.google.com/style/parentheses'
nonword: true
level: suggestion
tokens:
- '\(.+\)'
-184
View File
@@ -1,184 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,8 +0,0 @@
extends: existence
message: "Use semicolons judiciously."
link: 'https://developers.google.com/style/semicolons'
nonword: true
scope: sentence
level: suggestion
tokens:
- ';'
-11
View File
@@ -1,11 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,10 +0,0 @@
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'
-8
View File
@@ -1,8 +0,0 @@
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\d+(?:ns|ms|s|min|h|d)
-11
View File
@@ -1,11 +0,0 @@
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
@@ -1,7 +0,0 @@
extends: existence
message: "Avoid using '%s'."
link: 'https://developers.google.com/style/tense'
ignorecase: true
level: warning
tokens:
- will
-80
View File
@@ -1,80 +0,0 @@
extends: substitution
message: "Use '%s' instead of '%s'."
link: "https://developers.google.com/style/word-list"
level: warning
ignorecase: false
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
"(?:OAuth ?2|Oauth)": 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
Ajax: AJAX
a\.k\.a|aka: or|also known as
Android device: Android-powered device
android: Android
API explorer: APIs Explorer
application: app
approx\.: approximately
authN: authentication
authZ: authorization
autoupdate: automatically update
cellular data: mobile data
cellular network: mobile network
chapter: documents|pages|sections
check box: checkbox
CLI: command-line tool
click on: click|click in
Cloud: Google Cloud Platform|GCP
Container Engine: Kubernetes Engine
content type: media type
curated roles: predefined roles
data are: data is
Developers Console: Google API Console|API Console
disabled?: turn off|off
ephemeral IP address: ephemeral external IP address
fewer data: less data
file name: filename
firewalls: firewall rules
functionality: capability|feature
Google account: Google Account
Google accounts: Google Accounts
Googling: search with Google
grayed-out: unavailable
HTTPs: HTTPS
in order to: to
ingest: import|load
k8s: Kubernetes
long press: touch & hold
network IP address: internal IP address
omnibox: address bar
open-source: open source
overview screen: recents screen
regex: regular expression
SHA1: SHA-1|HAS-SHA1
sign into: sign in to
sign-?on: single sign-on
static IP address: static external IP address
stylesheet: style sheet
synch: sync
tablename: table name
tablet: device
touch: tap
url: URL
vs\.: versus
World Wide Web: web
+24
View File
@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.48.0] - 2026-08-08
### Features
- *(setup)* Mirror Ansible collections from Gitea registry with auth
## [0.47.10] - 2026-08-05
### Bug Fixes
- Unique molecule container names per CI runner
## [0.47.9] - 2026-08-03
### Bug Fixes
- Unique molecule container names per CI runner
## [0.47.8] - 2026-08-03
### Bug Fixes
- Increase CI_SCALE_FACTOR default from 4 to 6
## [0.47.7] - 2026-08-03 ## [0.47.7] - 2026-08-03
### Bug Fixes ### Bug Fixes
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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) [![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/d49dc712ba721a4c7bdc376a2453113e76e56477/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.47.7", "devx>=0.48.0",
] ]
[tool.pip] [tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
``` ```
> **Note:** If your project requires a specific devx version, pin it in > **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.47.7"`) or use a version constraint > `dependencies` (for example, `"devx==0.48.0"`) or use a version constraint
> (for example, `"devx>=0.47.7,<0.48"`). > (for example, `"devx>=0.48.0,<0.49"`).
### Optional extras ### Optional extras
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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) [![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/d49dc712ba721a4c7bdc376a2453113e76e56477/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.47.7", "devx>=0.48.0",
] ]
[tool.pip] [tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
``` ```
Pin a specific version if needed: `"devx==0.47.7"` or `"devx>=0.47.7,<0.48"`. Pin a specific version if needed: `"devx==0.48.0"` or `"devx>=0.48.0,<0.49"`.
### Optional extras ### Optional extras
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.47.7", "devx>=0.48.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.47.7", "devx>=0.48.0",
] ]
``` ```
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.47.7" __version__ = "0.48.0"
+49
View File
@@ -125,6 +125,11 @@ def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
"""Build environment for a single molecule pair.""" """Build environment for a single molecule pair."""
_role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair) _role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair)
env = base_env.copy() env = base_env.copy()
# Append runner index to platform name when running in CI matrix to avoid
# Docker container name conflicts when multiple runners share the same Docker host.
matrix_index = env.get("MATRIX_INDEX")
if matrix_index:
platform_name = f"{platform_name}-r{matrix_index}"
env["MOLECULE_PLATFORM_NAME"] = platform_name env["MOLECULE_PLATFORM_NAME"] = platform_name
env["MOLECULE_PLATFORM_IMAGE"] = platform_image env["MOLECULE_PLATFORM_IMAGE"] = platform_image
if platform_command: if platform_command:
@@ -246,18 +251,62 @@ def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
with contextlib.suppress(ProcessLookupError): with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL) os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait() process.wait()
# Clean up containers left behind by the killed test.
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
destroy_cmd = ["molecule", "destroy"]
if scenario != "default":
destroy_cmd.extend(["-s", scenario])
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
destroy_cmd,
cwd=str(cwd),
env=env,
check=False,
capture_output=True,
timeout=120,
)
sys.exit(1) sys.exit(1)
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
with contextlib.suppress(ProcessLookupError): with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM) os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait() process.wait()
# Clean up containers left behind by the interrupted test.
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
destroy_cmd = ["molecule", "destroy"]
if scenario != "default":
destroy_cmd.extend(["-s", scenario])
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
destroy_cmd,
cwd=str(cwd),
env=env,
check=False,
capture_output=True,
timeout=120,
)
sys.exit(1) sys.exit(1)
rc = process.returncode rc = process.returncode
if rc != 0: if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc)) click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
# Run molecule destroy to clean up containers left behind by the
# failed test. Without this, containers stay running and accumulate
# on the runner, consuming disk/memory and degrading CI performance.
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
destroy_cmd = ["molecule", "destroy"]
if scenario != "default":
destroy_cmd.extend(["-s", scenario])
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
destroy_cmd,
cwd=str(cwd),
env=env,
check=False,
capture_output=True,
timeout=120,
)
sys.exit(rc) sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair)) click.echo(_("PASSED: {pair}", pair=pair))
+4 -4
View File
@@ -14,11 +14,11 @@ total wall-clock time and individual test durations are parsed and validated.
CI runner scaling CI runner scaling
----------------- -----------------
CI runners (Gitea Actions Docker containers) are typically 3-4x slower than CI runners (Gitea Actions Docker containers) are typically 5-8x slower than
local development machines due to shared CPU, fewer cores, and container local development machines due to shared CPU, fewer cores, and container
overhead. When the ``CI`` environment variable is set (standard CI overhead. When the ``CI`` environment variable is set (standard CI
convention), both the total and per-test limits are multiplied by convention), both the total and per-test limits are multiplied by
``CI_SCALE_FACTOR`` (default 4) to account for this. This keeps the local ``CI_SCALE_FACTOR`` (default 6) to account for this. This keeps the local
budget strict while preventing false failures on slower CI runners. budget strict while preventing false failures on slower CI runners.
The scale factor can be overridden via the ``DEVX_CI_SCALE_FACTOR`` The scale factor can be overridden via the ``DEVX_CI_SCALE_FACTOR``
@@ -39,10 +39,10 @@ DEFAULT_MAX_SECONDS = 10.0
DEFAULT_MAX_SINGLE_SECONDS = 0.5 DEFAULT_MAX_SINGLE_SECONDS = 0.5
TEST_COMMAND = ["make", "test-unit"] TEST_COMMAND = ["make", "test-unit"]
# CI runners are typically 3-4x slower than local machines (shared CPU, # CI runners are typically 5-8x slower than local machines (shared CPU,
# fewer cores, container overhead). Scale limits up when running on CI # fewer cores, container overhead). Scale limits up when running on CI
# so the gate catches real regressions, not infrastructure slowness. # so the gate catches real regressions, not infrastructure slowness.
CI_SCALE_FACTOR = float(os.environ.get("DEVX_CI_SCALE_FACTOR", "4")) CI_SCALE_FACTOR = float(os.environ.get("DEVX_CI_SCALE_FACTOR", "6"))
_IS_CI = bool(os.environ.get("CI") or os.environ.get("GITEA_ACTIONS")) _IS_CI = bool(os.environ.get("CI") or os.environ.get("GITEA_ACTIONS"))
# Matches pytest summary line: "234 passed in 0.70s" # Matches pytest summary line: "234 passed in 0.70s"
+102 -2
View File
@@ -15,6 +15,7 @@ from pathlib import Path
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from tenacity import retry, stop_after_attempt, wait_exponential
from devx.tokens import get_developer_token from devx.tokens import get_developer_token
@@ -56,13 +57,112 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
def _install_ansible_collections(bin_dir: str) -> None: def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections if requirements exist.""" """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.
"""
galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy") galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy")
requirements = Path("ansible/requirements.yml") requirements = Path("ansible/requirements.yml")
if not requirements.exists(): if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.") click.echo(" ansible/requirements.yml not found — skipping collections.")
return return
_run([galaxy, "collection", "install", "-r", str(requirements)])
# 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)])
_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
local_entries.append(
{
"name": entry["name"],
"version": 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: def _configure_tea_login() -> None:
+9 -1
View File
@@ -3830,5 +3830,13 @@
"pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)", "pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)", "ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)" "zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)"
},
"Cleaning up: running molecule destroy for {scenario}": {
"en": "Cleaning up: running molecule destroy for {scenario}",
"bg": "Изчистване: изпълнение на molecule destroy за {scenario}",
"de": "Aufräumen: molecule destroy wird ausgeführt für {scenario}",
"pl": "Czyszczenie: uruchamianie molecule destroy dla {scenario}",
"ru": "Очистка: запуск molecule destroy для {scenario}",
"zh": "清理:正在为 {scenario} 运行 molecule destroy"
} }
} }
+10
View File
@@ -102,6 +102,16 @@ class TestBuildEnvForPair:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"}) env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"})
assert env["MOLECULE_HOME"] == "/custom/home" assert env["MOLECULE_HOME"] == "/custom/home"
def test_appends_matrix_index_to_platform_name(self) -> None:
"""When MATRIX_INDEX is set, platform name gets a unique suffix."""
env = build_env_for_pair("default|ubuntu-2604|img:latest|sleep infinity", {"MATRIX_INDEX": "3"})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2604-r3"
def test_no_matrix_index_keeps_platform_name(self) -> None:
"""Without MATRIX_INDEX, platform name is unchanged."""
env = build_env_for_pair("default|ubuntu-2604|img:latest|sleep infinity", {})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2604"
class TestPollForOtherFailures: class TestPollForOtherFailures:
def test_sets_failed_event_when_other_runner_fails(self) -> None: def test_sets_failed_event_when_other_runner_fails(self) -> None:
+247
View File
@@ -14,6 +14,7 @@ from devx.tools.setup import (
_install_pre_commit_hooks, _install_pre_commit_hooks,
_install_python_deps, _install_python_deps,
_run, _run,
_try_gitea_mirror_install,
_verify, _verify,
main, main,
) )
@@ -106,6 +107,7 @@ class TestInstallAnsibleCollections:
with patch("devx.tools.setup.Path") as mock_path: with patch("devx.tools.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True mock_path.return_value.exists.return_value = True
mock_path.return_value.__str__ = lambda _: str(req) mock_path.return_value.__str__ = lambda _: str(req)
mock_path.return_value.read_text = lambda: req.read_text()
_install_ansible_collections(".venv/bin") _install_ansible_collections(".venv/bin")
mock_run.assert_called_once() mock_run.assert_called_once()
@@ -114,6 +116,251 @@ class TestInstallAnsibleCollections:
_install_ansible_collections(".venv/bin") _install_ansible_collections(".venv/bin")
mock_run.assert_not_called() mock_run.assert_not_called()
@patch("tenacity.nap.time.sleep")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
@patch("devx.tools.setup._run")
def test_retries_on_transient_failure(
self, mock_run: MagicMock, mock_which: MagicMock, mock_sleep: MagicMock, tmp_path: Path
) -> None:
"""ansible-galaxy install should retry on transient network errors."""
import subprocess as _subprocess
req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True)
req.write_text("collections: []")
# First call fails (timeout), second succeeds
mock_run.side_effect = [
_subprocess.CalledProcessError(1, ["ansible-galaxy", "collection", "install"]),
None,
]
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
@patch("tenacity.nap.time.sleep")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
@patch("devx.tools.setup._run")
def test_exhausts_retries_then_raises(
self, mock_run: MagicMock, mock_which: MagicMock, mock_sleep: MagicMock, tmp_path: Path
) -> None:
"""After 3 attempts, the error should propagate."""
import subprocess as _subprocess
req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True)
req.write_text("collections: []")
mock_run.side_effect = _subprocess.CalledProcessError(1, ["ansible-galaxy"])
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: class TestConfigureTeaLogin:
@patch("devx.tools.setup.shutil.which", return_value=None) @patch("devx.tools.setup.shutil.which", return_value=None)