Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d0580c4fd | ||
|
|
ed3bd75367 | ||
|
|
ed0a282a52 | ||
|
|
e4e0a534ff | ||
|
|
e35ee2d71a | ||
|
|
1d9e505432 | ||
|
|
ddb0f17886 | ||
|
|
a8a8b743f3 | ||
|
|
a487bddb09 | ||
|
|
e3a37c95c1 | ||
|
|
9bb461e12f | ||
|
|
32193a0e6d | ||
|
|
155c4a204a | ||
|
|
491137f944 | ||
|
|
48cd33be22 | ||
|
|
2fae9bc723 | ||
|
|
bfc2ebec81 | ||
|
|
e01c39b4b8 | ||
|
|
aa93e894a6 | ||
|
|
004b890463 | ||
|
|
587906f518 | ||
|
|
d743ba93eb | ||
|
|
c7351a495a | ||
|
|
4de11bfc18 | ||
|
|
a02bf6d70e | ||
|
|
368c87aabf | ||
|
|
4f982dc3ba | ||
|
|
a7a8637244 | ||
|
|
cdf3408a35 | ||
|
|
8fcac10286 | ||
|
|
c62c560c85 | ||
|
|
08b781f978 | ||
|
|
ea7566fe6b | ||
|
|
d8ceb6c8a1 | ||
|
|
748baf17eb | ||
|
|
f339df3562 | ||
|
|
db38453a54 | ||
|
|
5d78377152 | ||
|
|
b8b21cccd5 | ||
|
|
326eccfd2f | ||
|
|
076b470344 |
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Check unit test speed
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 8 --max-single-seconds 0.5
|
||||
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
|
||||
env:
|
||||
DEVX_DOC_COVERAGE_STRICT: "1"
|
||||
|
||||
@@ -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]\.'
|
||||
@@ -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
|
||||
@@ -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]'
|
||||
@@ -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
|
||||
@@ -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}'
|
||||
@@ -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:
|
||||
- '\.\.\.'
|
||||
@@ -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'
|
||||
|
||||
@@ -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|$)'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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*$'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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\)'
|
||||
@@ -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)
|
||||
@@ -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)'
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Use parentheses judiciously."
|
||||
link: 'https://developers.google.com/style/parentheses'
|
||||
nonword: true
|
||||
level: suggestion
|
||||
tokens:
|
||||
- '\(.+\)'
|
||||
@@ -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
|
||||
@@ -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,}'
|
||||
@@ -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:
|
||||
- '"[^"]+"[.,?]'
|
||||
@@ -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+'
|
||||
@@ -1,8 +0,0 @@
|
||||
extends: existence
|
||||
message: "Use semicolons judiciously."
|
||||
link: 'https://developers.google.com/style/semicolons'
|
||||
nonword: true
|
||||
scope: sentence
|
||||
level: suggestion
|
||||
tokens:
|
||||
- ';'
|
||||
@@ -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
|
||||
@@ -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]'
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Avoid using '%s'."
|
||||
link: 'https://developers.google.com/style/tense'
|
||||
ignorecase: true
|
||||
level: warning
|
||||
tokens:
|
||||
- will
|
||||
@@ -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
|
||||
@@ -71,7 +71,7 @@ src/devx/
|
||||
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
|
||||
├── ci/ # CI/CD automation modules (run by workflows)
|
||||
│ ├── release.py # Automated versioning, tagging, changelog
|
||||
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
|
||||
│ ├── publish.py # Build, publish to Gitea PyPI registry, create Gitea release (with retry)
|
||||
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
|
||||
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
|
||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
||||
@@ -310,6 +310,20 @@ by `python -m devx.tools.install_tools` and configured by
|
||||
- `create_pr()` / `merge_pr()` / `review_pr()` — Pull request operations
|
||||
- `create_release()` / `list_releases()` — Release management
|
||||
|
||||
**`devx.gitea_cli.configure_tea_login()`** — Configures tea login in
|
||||
containerized CI environments where `make setup` was not called. Used by
|
||||
`publish.py` (`--auto-login`) and `notify_failure.py` (`--auto-login`).
|
||||
Raises `TeaCLIError` if login configuration fails — this prevents cryptic
|
||||
"no available login" errors from subsequent tea commands.
|
||||
|
||||
**Error handling**: `TeaCLI._run()` includes both stdout and stderr in
|
||||
`TeaCLIError` messages, because `tea` writes some errors (for example,
|
||||
"no available login") to stdout, not stderr.
|
||||
|
||||
**Release creation retry**: `publish.py` retries Gitea release creation
|
||||
up to 3 times with exponential backoff (2s, 4s) on transient failures.
|
||||
"Already exists" errors are treated as success (idempotent).
|
||||
|
||||
### git-cliff Commit Preprocessing
|
||||
|
||||
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
|
||||
|
||||
@@ -2,6 +2,90 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Scale check_test_speed limits on CI runners
|
||||
|
||||
## [0.47.6] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Configure git auth in setup_image for git+https deps
|
||||
|
||||
## [0.47.5] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Push wiki to main branch instead of master
|
||||
|
||||
## [0.47.4] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add User-Agent header to _download in install_tools
|
||||
|
||||
## [0.47.3] - 2026-07-17
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Bake promtool into ci-full image, add download timeout, speed up tests
|
||||
|
||||
## [0.47.2] - 2026-07-17
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add retry logic to TeaCLI for transient HTTP errors (502/503/504/429)
|
||||
|
||||
## [0.47.1] - 2026-07-16
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Tea CLI login failure handling, error messages, release retry
|
||||
|
||||
## [0.47.0] - 2026-07-14
|
||||
|
||||
### Features
|
||||
|
||||
- Add promtool to install_tools for alert rule validation
|
||||
|
||||
## [0.46.0] - 2026-07-14
|
||||
|
||||
### Features
|
||||
|
||||
- Make check_test_isolation configurable via pyproject.toml
|
||||
|
||||
## [0.45.1] - 2026-07-14
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- URL-encode package names and versions in clean_images API calls
|
||||
|
||||
## [0.45.0] - 2026-07-14
|
||||
|
||||
### Features
|
||||
|
||||
- Add IO_INTERNAL_CALLS to check_test_isolation
|
||||
|
||||
## [0.44.2] - 2026-07-14
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.44.2",
|
||||
"devx>=0.47.10",
|
||||
]
|
||||
|
||||
[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.44.2"`) or use a version constraint
|
||||
> (for example, `"devx>=0.44.2,<0.45"`).
|
||||
> `dependencies` (for example, `"devx==0.47.10"`) or use a version constraint
|
||||
> (for example, `"devx>=0.47.10,<0.48"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@ COPY . /tmp/devx
|
||||
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
|
||||
&& rm -rf /tmp/devx
|
||||
|
||||
# Install git-cliff (changelog generator for release job) and OpenTofu (for infra deploy jobs)
|
||||
RUN python3 -m devx.tools.install_tools --tool git-cliff --tool tofu
|
||||
# Install git-cliff (changelog generator for release job), OpenTofu (for infra deploy jobs),
|
||||
# and promtool (Prometheus rule validator — used by every infra CI run for alert validation)
|
||||
RUN python3 -m devx.tools.install_tools --tool git-cliff --tool tofu --tool promtool
|
||||
|
||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](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.44.2",
|
||||
"devx>=0.47.10",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||
```
|
||||
|
||||
Pin a specific version if needed: `"devx==0.44.2"` or `"devx>=0.44.2,<0.45"`.
|
||||
Pin a specific version if needed: `"devx==0.47.10"` or `"devx>=0.47.10,<0.48"`.
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.44.2",
|
||||
"devx>=0.47.10",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx>=0.44.2",
|
||||
"devx>=0.47.10",
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.44.2"
|
||||
__version__ = "0.47.10"
|
||||
|
||||
+37
-7
@@ -4,6 +4,10 @@
|
||||
Uses git-cliff to generate the release notes from conventional commits.
|
||||
Uses the ``tea`` Gitea CLI for release creation.
|
||||
|
||||
Gitea release creation is retried up to 3 times with exponential backoff
|
||||
(2s, 4s) to handle transient failures (network timeouts, 5xx errors).
|
||||
If the release already exists, it is treated as success (idempotent).
|
||||
|
||||
Publishing destinations (checked in order):
|
||||
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
|
||||
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
||||
@@ -27,6 +31,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||
@@ -312,13 +317,7 @@ def main(
|
||||
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
try:
|
||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||
except TeaCLIError as e:
|
||||
if "already" in str(e).lower() and "release" in str(e).lower():
|
||||
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
|
||||
return
|
||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
||||
_create_release_with_retry(tea, repo, tag, release_body)
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
@@ -328,5 +327,36 @@ def main(
|
||||
)
|
||||
|
||||
|
||||
def _create_release_with_retry(tea: TeaCLI, repo: str, tag: str, release_body: str) -> None:
|
||||
"""Create a Gitea release with retry for transient failures.
|
||||
|
||||
Retries up to 3 times with exponential backoff (2s, 4s) on TeaCLIError
|
||||
unless the error indicates the release already exists (which is treated
|
||||
as success). This handles transient issues like network timeouts, Gitea
|
||||
rate limiting, or temporary 5xx errors that caused CI run #2822 to fail.
|
||||
"""
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=10),
|
||||
retry=retry_if_exception_type(TeaCLIError),
|
||||
reraise=True,
|
||||
)
|
||||
def _attempt() -> None:
|
||||
try:
|
||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||
except TeaCLIError as e:
|
||||
error_str = str(e).lower()
|
||||
if "already" in error_str and "release" in error_str:
|
||||
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
|
||||
return
|
||||
raise
|
||||
|
||||
try:
|
||||
_attempt()
|
||||
except TeaCLIError as e:
|
||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
|
||||
@@ -252,7 +252,7 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
|
||||
|
||||
# Push
|
||||
result = subprocess.run( # nosec
|
||||
["git", "push", "--force", wiki_url, "HEAD:master"],
|
||||
["git", "push", "--force", wiki_url, "HEAD:main"],
|
||||
cwd=wiki_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
+78
-17
@@ -40,27 +40,46 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
logger = logging.getLogger("gitea_cli")
|
||||
|
||||
|
||||
class TeaCLIError(Exception):
|
||||
"""Raised when a tea CLI command fails."""
|
||||
|
||||
|
||||
class _TransientTeaError(TeaCLIError):
|
||||
"""Tea CLI error caused by a transient HTTP status (502/503/504/429)."""
|
||||
|
||||
|
||||
def configure_tea_login(login_name: str = "devx") -> None:
|
||||
"""Configure tea CLI login from CI_GITEA_API_TOKEN and DEVX_GITEA_API_URL.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips silently if tea is not installed or no token is set.
|
||||
|
||||
Raises ``TeaCLIError`` if the login add or default command fails. This is
|
||||
critical because subsequent tea commands (e.g. ``releases create``) will
|
||||
fail with a cryptic "no available login" error if the login was not
|
||||
configured successfully.
|
||||
|
||||
Used by CI scripts (publish, notify_failure) that need tea login but
|
||||
run in containerized environments where ``make setup`` was not called.
|
||||
"""
|
||||
@@ -88,18 +107,31 @@ def configure_tea_login(login_name: str = "devx") -> None:
|
||||
return
|
||||
|
||||
click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url))
|
||||
subprocess.run( # nosec B603
|
||||
add_result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
subprocess.run( # nosec B603
|
||||
if add_result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea login add failed (rc={add_result.returncode})\n"
|
||||
f"stdout: {add_result.stdout.strip()}\n"
|
||||
f"stderr: {add_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
default_result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "default", login_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if default_result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea login default failed (rc={default_result.returncode})\n"
|
||||
f"stdout: {default_result.stdout.strip()}\n"
|
||||
f"stderr: {default_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
class TeaCLI:
|
||||
@@ -122,6 +154,10 @@ class TeaCLI:
|
||||
def _run(self, args: list[str], json_output: bool = True) -> str:
|
||||
"""Run a tea command and return stdout.
|
||||
|
||||
Retries up to ``MAX_RETRIES`` times on transient HTTP errors
|
||||
(502/503/504/429) detected in stderr/stdout, with exponential
|
||||
backoff. Non-transient errors fail immediately.
|
||||
|
||||
Args:
|
||||
args: Command arguments (without the leading ``tea``).
|
||||
json_output: If True, append ``--output json`` to the command.
|
||||
@@ -130,25 +166,50 @@ class TeaCLI:
|
||||
stdout as a string.
|
||||
|
||||
Raises:
|
||||
TeaCLIError: If the command fails.
|
||||
TeaCLIError: If the command fails after retries are exhausted.
|
||||
"""
|
||||
cmd = [self._tea, *args]
|
||||
if json_output:
|
||||
cmd.extend(["--output", "json"])
|
||||
|
||||
def _execute() -> str:
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
|
||||
if result.returncode != 0:
|
||||
parts = [
|
||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}",
|
||||
f"stdout: {result.stdout.strip()}" if result.stdout.strip() else "",
|
||||
f"stderr: {result.stderr.strip()}" if result.stderr.strip() else "",
|
||||
]
|
||||
msg = "\n".join(p for p in parts if p)
|
||||
combined = f"{result.stdout} {result.stderr}".lower()
|
||||
if any(str(code) in combined for code in RETRY_STATUS_CODES):
|
||||
raise _TransientTeaError(msg)
|
||||
raise TeaCLIError(msg)
|
||||
return result.stdout.strip()
|
||||
|
||||
retry_decorator = retry(
|
||||
stop=stop_after_attempt(MAX_RETRIES),
|
||||
wait=wait_exponential(
|
||||
multiplier=RETRY_BACKOFF_BASE,
|
||||
min=RETRY_BACKOFF_BASE,
|
||||
max=RETRY_BACKOFF_BASE**MAX_RETRIES,
|
||||
),
|
||||
retry=retry_if_exception_type(_TransientTeaError),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
|
||||
if result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
return retry_decorator(_execute)()
|
||||
except _TransientTeaError as e:
|
||||
raise TeaCLIError(str(e)) from e
|
||||
|
||||
def _run_raw(self, args: list[str]) -> str:
|
||||
"""Run a tea command without JSON output and return stdout."""
|
||||
|
||||
@@ -309,7 +309,7 @@ devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-
|
||||
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
devx-test-unit:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov -n 8
|
||||
|
||||
devx-pytest-cov:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -n auto --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||
|
||||
@@ -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."""
|
||||
_role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair)
|
||||
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_IMAGE"] = platform_image
|
||||
if platform_command:
|
||||
|
||||
@@ -25,6 +25,25 @@ This module is used in two ways:
|
||||
findings are reported as advisories (exit 0) since static analysis
|
||||
can't predict early exits — the runtime audit is authoritative.
|
||||
|
||||
Project-Specific Configuration
|
||||
-------------------------------
|
||||
|
||||
Projects can extend the built-in rule sets via ``[tool.devx.check_test_isolation]``
|
||||
in ``pyproject.toml``. Entries are merged on top of the defaults — they
|
||||
add to (not replace) the built-in rules::
|
||||
|
||||
[tool.devx.check_test_isolation]
|
||||
# Functions known to do filesystem or network I/O
|
||||
io_functions = { "my_func" = "reads config from disk", ... }
|
||||
# Functions known to spawn subprocesses
|
||||
subprocess_helpers = { "my_helper" = "calls subprocess.run", ... }
|
||||
# Transitive deps: if a helper calls these, patching any of them is safe
|
||||
helper_internal_calls = { "my_helper" = ["subprocess", "run_cmd"], ... }
|
||||
# I/O function internal deps: patching any of these makes the call safe
|
||||
io_internal_calls = { "my_func" = ["open", "yaml"], ... }
|
||||
# Heavy modules slow to import at module level in test files
|
||||
heavy_module_imports = { "mymodule" = 150.0, ... }
|
||||
|
||||
Patterns detected:
|
||||
|
||||
1. **Unpatched subprocess calls** — test functions that call
|
||||
@@ -60,6 +79,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import _load_pyproject_devx
|
||||
from devx.i18n import _
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||
@@ -71,7 +91,7 @@ DEFAULT_MAX_LOOP_ITERATIONS = 100
|
||||
# Maps module name → approximate import time in milliseconds.
|
||||
# NOTE: ``requests`` is excluded because it's a core devx dependency —
|
||||
# it's loaded during collection regardless of whether test files import it.
|
||||
HEAVY_MODULE_IMPORTS: dict[str, float] = {
|
||||
_DEFAULT_HEAVY_MODULE_IMPORTS: dict[str, float] = {
|
||||
"httpx": 80.0,
|
||||
"aiohttp": 120.0,
|
||||
"docker": 90.0,
|
||||
@@ -96,7 +116,7 @@ HEAVY_MODULE_IMPORTS: dict[str, float] = {
|
||||
# Functions known to spawn subprocesses. When a test calls any of these
|
||||
# without patching them, the real subprocess runs.
|
||||
# Maps function name → human-readable description.
|
||||
KNOWN_SUBPROCESS_HELPERS: dict[str, str] = {
|
||||
_DEFAULT_SUBPROCESS_HELPERS: dict[str, str] = {
|
||||
"update_doc_versions": "calls subprocess.run to run check_doc_versions --fix",
|
||||
"run_tests": "calls run_cmd to run make lint-ruff and make pytest-cov",
|
||||
"run_cmd": "calls subprocess.run for shell commands",
|
||||
@@ -105,7 +125,7 @@ KNOWN_SUBPROCESS_HELPERS: dict[str, str] = {
|
||||
# Functions known to do filesystem or network I/O that should be mocked in tests.
|
||||
# Maps function name → description of what I/O it does.
|
||||
# If a test calls one of these without a corresponding @patch, it's a violation.
|
||||
KNOWN_IO_FUNCTIONS: dict[str, str] = { # nosec B105 — descriptions, not passwords
|
||||
_DEFAULT_IO_FUNCTIONS: dict[str, str] = { # nosec B105 — descriptions, not passwords
|
||||
"get_pat": "reads ZITADEL PAT from filesystem/env (ZitadelAuth._iter_sources)",
|
||||
"load_secrets": "reads YAML config file from disk",
|
||||
"get_customer_secret": "reads customer-specific config from disk",
|
||||
@@ -124,12 +144,102 @@ KNOWN_IO_FUNCTIONS: dict[str, str] = { # nosec B105 — descriptions, not passw
|
||||
# Transitive dependencies: if a helper calls another helper that is patched,
|
||||
# the call is safe. Maps helper → set of function names it internally calls.
|
||||
# If ANY of these are in the test's patches, the helper call is safe.
|
||||
HELPER_INTERNAL_CALLS: dict[str, set[str]] = {
|
||||
_DEFAULT_HELPER_INTERNAL_CALLS: dict[str, set[str]] = {
|
||||
"run_tests": {"run_cmd", "subprocess"},
|
||||
"update_doc_versions": {"subprocess"},
|
||||
"run_cmd": {"subprocess"},
|
||||
}
|
||||
|
||||
# I/O function internal dependencies: if a test patches one of these
|
||||
# internal dependencies, the I/O function call is considered safe.
|
||||
# Maps I/O function name → set of internal function/method names it calls.
|
||||
_DEFAULT_IO_INTERNAL_CALLS: dict[str, set[str]] = {
|
||||
"get_customer_vm_ip": {"get_tofu_output", "get_tofu_vm_ip", "subprocess"},
|
||||
"get_observability_vm_ip": {"get_tofu_output", "get_tofu_vm_ip", "subprocess"},
|
||||
"get_pat": {
|
||||
"_iter_sources",
|
||||
"_local_pat_path",
|
||||
"_secrets_path",
|
||||
"_read_secrets_pat",
|
||||
"validate_pat",
|
||||
"ZitadelAuth",
|
||||
"load_secrets",
|
||||
"os.environ",
|
||||
},
|
||||
"load_secrets": {"load_vault_yaml", "REPO_ROOT", "open", "yaml", "safe_load"},
|
||||
"get_customer_secret": {"load_customer_secrets", "load_vault_yaml", "load_secrets", "REPO_ROOT", "open"},
|
||||
}
|
||||
|
||||
|
||||
def _load_test_isolation_config() -> None:
|
||||
"""Merge project-specific rules from ``[tool.devx.check_test_isolation]``.
|
||||
|
||||
Reads from pyproject.toml and merges with defaults. Project-specific
|
||||
entries are added on top of (not replacing) the built-in defaults.
|
||||
|
||||
Supported keys::
|
||||
|
||||
[tool.devx.check_test_isolation]
|
||||
io_functions = { "my_func" = "does network I/O", ... }
|
||||
subprocess_helpers = { "my_helper" = "calls subprocess.run", ... }
|
||||
helper_internal_calls = { "my_helper" = ["subprocess", "run_cmd"], ... }
|
||||
io_internal_calls = { "my_func" = ["open", "yaml"], ... }
|
||||
heavy_module_imports = { "mymodule" = 150.0, ... }
|
||||
"""
|
||||
devx_cfg = _load_pyproject_devx()
|
||||
cfg_raw = devx_cfg.get("check_test_isolation", {})
|
||||
if not isinstance(cfg_raw, dict):
|
||||
return
|
||||
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
|
||||
|
||||
# io_functions: {name: description}
|
||||
io_extra = cfg.get("io_functions", {})
|
||||
if isinstance(io_extra, dict):
|
||||
for name, desc in io_extra.items():
|
||||
if isinstance(name, str) and isinstance(desc, str):
|
||||
KNOWN_IO_FUNCTIONS[name] = desc
|
||||
|
||||
# subprocess_helpers: {name: description}
|
||||
sp_extra = cfg.get("subprocess_helpers", {})
|
||||
if isinstance(sp_extra, dict):
|
||||
for name, desc in sp_extra.items():
|
||||
if isinstance(name, str) and isinstance(desc, str):
|
||||
KNOWN_SUBPROCESS_HELPERS[name] = desc
|
||||
|
||||
# helper_internal_calls: {name: [deps]}
|
||||
hic_extra = cfg.get("helper_internal_calls", {})
|
||||
if isinstance(hic_extra, dict):
|
||||
for name, deps in hic_extra.items():
|
||||
if isinstance(name, str) and isinstance(deps, list):
|
||||
deps_set = {str(d) for d in deps if isinstance(d, str)}
|
||||
HELPER_INTERNAL_CALLS.setdefault(name, set()).update(deps_set)
|
||||
|
||||
# io_internal_calls: {name: [deps]}
|
||||
iic_extra = cfg.get("io_internal_calls", {})
|
||||
if isinstance(iic_extra, dict):
|
||||
for name, deps in iic_extra.items():
|
||||
if isinstance(name, str) and isinstance(deps, list):
|
||||
deps_set = {str(d) for d in deps if isinstance(d, str)}
|
||||
IO_INTERNAL_CALLS.setdefault(name, set()).update(deps_set)
|
||||
|
||||
# heavy_module_imports: {name: ms}
|
||||
hmi_extra = cfg.get("heavy_module_imports", {})
|
||||
if isinstance(hmi_extra, dict):
|
||||
for name, ms in hmi_extra.items():
|
||||
if isinstance(name, str) and isinstance(ms, (int, float)):
|
||||
HEAVY_MODULE_IMPORTS[name] = float(ms)
|
||||
|
||||
|
||||
# Active rule sets — start with defaults, merged with project config at import.
|
||||
HEAVY_MODULE_IMPORTS: dict[str, float] = dict(_DEFAULT_HEAVY_MODULE_IMPORTS)
|
||||
KNOWN_SUBPROCESS_HELPERS: dict[str, str] = dict(_DEFAULT_SUBPROCESS_HELPERS)
|
||||
KNOWN_IO_FUNCTIONS: dict[str, str] = dict(_DEFAULT_IO_FUNCTIONS)
|
||||
HELPER_INTERNAL_CALLS: dict[str, set[str]] = {k: set(v) for k, v in _DEFAULT_HELPER_INTERNAL_CALLS.items()}
|
||||
IO_INTERNAL_CALLS: dict[str, set[str]] = {k: set(v) for k, v in _DEFAULT_IO_INTERNAL_CALLS.items()}
|
||||
|
||||
# Merge project-specific configuration from pyproject.toml
|
||||
_load_test_isolation_config()
|
||||
|
||||
# subprocess functions that the runtime audit wraps.
|
||||
_SUBPROCESS_FUNCS = ("run", "call", "check_call", "check_output", "Popen")
|
||||
|
||||
@@ -817,6 +927,9 @@ class TestIsolationVisitor(ast.NodeVisitor):
|
||||
or sn in all_patches
|
||||
or any(io_key in p or sn in p for p in all_patches)
|
||||
or any(p.endswith(f".{sn}") for p in all_patches)
|
||||
or any(
|
||||
dep in all_patches or any(dep in p for p in all_patches) for dep in IO_INTERNAL_CALLS.get(io_key, set())
|
||||
)
|
||||
):
|
||||
self.violations.append(
|
||||
Violation(
|
||||
|
||||
@@ -11,6 +11,18 @@ Usage:
|
||||
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
|
||||
that pytest emits per-test timing lines alongside the summary. Both the
|
||||
total wall-clock time and individual test durations are parsed and validated.
|
||||
|
||||
CI runner scaling
|
||||
-----------------
|
||||
CI runners (Gitea Actions Docker containers) are typically 5-8x slower than
|
||||
local development machines due to shared CPU, fewer cores, and container
|
||||
overhead. When the ``CI`` environment variable is set (standard CI
|
||||
convention), both the total and per-test limits are multiplied by
|
||||
``CI_SCALE_FACTOR`` (default 6) to account for this. This keeps the local
|
||||
budget strict while preventing false failures on slower CI runners.
|
||||
|
||||
The scale factor can be overridden via the ``DEVX_CI_SCALE_FACTOR``
|
||||
environment variable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +39,12 @@ DEFAULT_MAX_SECONDS = 10.0
|
||||
DEFAULT_MAX_SINGLE_SECONDS = 0.5
|
||||
TEST_COMMAND = ["make", "test-unit"]
|
||||
|
||||
# CI runners are typically 5-8x slower than local machines (shared CPU,
|
||||
# fewer cores, container overhead). Scale limits up when running on CI
|
||||
# so the gate catches real regressions, not infrastructure slowness.
|
||||
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"))
|
||||
|
||||
# Matches pytest summary line: "234 passed in 0.70s"
|
||||
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||
|
||||
@@ -38,6 +56,13 @@ _TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+call\s+(.+)$")
|
||||
|
||||
|
||||
def _ci_scale_limit(limit: float) -> float:
|
||||
"""Scale a time limit by the CI factor when running on CI."""
|
||||
if _IS_CI:
|
||||
return limit * CI_SCALE_FACTOR
|
||||
return limit
|
||||
|
||||
|
||||
def run_tests() -> tuple[str, str]:
|
||||
"""Execute the unit-test suite and return (stdout, stderr).
|
||||
|
||||
@@ -123,21 +148,38 @@ def check_per_test_speed(
|
||||
|
||||
def main(max_seconds: float, max_single_seconds: float) -> None:
|
||||
"""Run tests, parse timings, and enforce both budgets."""
|
||||
# Scale limits for CI runners (slower CPU, fewer workers).
|
||||
effective_max = _ci_scale_limit(max_seconds)
|
||||
effective_single = _ci_scale_limit(max_single_seconds)
|
||||
|
||||
if _IS_CI:
|
||||
click.echo(
|
||||
_(
|
||||
"[check-test-speed] CI environment detected — scaling limits by {factor}x "
|
||||
"(total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
factor=CI_SCALE_FACTOR,
|
||||
orig=max_seconds,
|
||||
eff=effective_max,
|
||||
orig_s=max_single_seconds,
|
||||
eff_s=effective_single,
|
||||
)
|
||||
)
|
||||
|
||||
stdout, stderr = run_tests()
|
||||
combined = stdout + "\n" + stderr
|
||||
click.echo(combined, err=False)
|
||||
|
||||
duration = parse_duration(combined)
|
||||
check_speed(duration, max_seconds)
|
||||
check_speed(duration, effective_max)
|
||||
|
||||
if max_single_seconds > 0:
|
||||
if effective_single > 0:
|
||||
per_test = parse_per_test_durations(combined)
|
||||
violations = check_per_test_speed(per_test, max_single_seconds)
|
||||
violations = check_per_test_speed(per_test, effective_single)
|
||||
if violations:
|
||||
msg = _(
|
||||
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
count=len(violations),
|
||||
limit=max_single_seconds,
|
||||
limit=effective_single,
|
||||
)
|
||||
click.echo(f"\n{msg}", err=True)
|
||||
for v in violations:
|
||||
@@ -148,8 +190,8 @@ def main(max_seconds: float, max_single_seconds: float) -> None:
|
||||
_(
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||
duration=duration,
|
||||
max=max_seconds,
|
||||
single=max_single_seconds,
|
||||
max=effective_max,
|
||||
single=effective_single,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ Queries the Gitea API for all versions of a package (container type) and
|
||||
deletes all but the most recent N versions. The ``latest`` tag is always
|
||||
preserved if present.
|
||||
|
||||
.. note::
|
||||
This tool only deletes package versions via the Gitea API. The underlying
|
||||
blob files on the Gitea server's filesystem are NOT removed by this tool
|
||||
(Gitea 1.26.x has no built-in garbage collection). The production VM's
|
||||
daily cleanup script (``cleanup_gitea.py``) handles filesystem blob GC
|
||||
by querying the database for referenced blobs and removing orphaned files.
|
||||
|
||||
Usage::
|
||||
|
||||
# Clean up ci-base images, keep last 2 versions
|
||||
@@ -57,7 +64,10 @@ def list_package_versions(
|
||||
Returns a list of version dicts, each containing at least ``version``
|
||||
and ``created_at`` fields.
|
||||
"""
|
||||
url = f"{api_url}/packages/{owner}?type=container&name={name}"
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_name = quote(name, safe="")
|
||||
url = f"{api_url}/packages/{owner}?type=container&name={encoded_name}"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
all_versions: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
@@ -96,7 +106,11 @@ def delete_package_version(
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_name = quote(name, safe="")
|
||||
encoded_version = quote(version, safe="")
|
||||
url = f"{api_url}/packages/{owner}/{package_type}/{encoded_name}/{encoded_version}"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
|
||||
@@ -8,6 +8,7 @@ Handles installation of:
|
||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||
- hadolint (Dockerfile linter)
|
||||
- vale (prose linter for documentation quality)
|
||||
- promtool (Prometheus rule validator)
|
||||
|
||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||
Idempotent: skips tools that are already available.
|
||||
@@ -47,6 +48,8 @@ TOFU_VERSION = "1.12.3"
|
||||
|
||||
VALE_VERSION = "3.15.1"
|
||||
|
||||
PROMTOOL_VERSION = "3.5.5"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
||||
@@ -62,8 +65,14 @@ def _ensure_target_dir() -> Path:
|
||||
|
||||
|
||||
def _download(url: str, dest: Path) -> None:
|
||||
"""Download a file from ``url`` to ``dest``."""
|
||||
urllib.request.urlretrieve(url, dest) # nosec B310
|
||||
"""Download a file from ``url`` to ``dest`` with a 60s timeout.
|
||||
|
||||
A User-Agent header is set because some CDNs (e.g. dl.gitea.com)
|
||||
return 403 to requests with Python's default User-Agent.
|
||||
"""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "devx/install-tools"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp, open(dest, "wb") as f: # nosec B310
|
||||
shutil.copyfileobj(resp, f)
|
||||
|
||||
|
||||
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
||||
@@ -212,7 +221,26 @@ def install_vale() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu", "vale"]
|
||||
def install_promtool() -> bool:
|
||||
"""Install promtool (Prometheus rule validator) if not already present.
|
||||
|
||||
Downloads the official Prometheus release tarball from GitHub and
|
||||
extracts the ``promtool`` binary to ``~/.local/bin``.
|
||||
"""
|
||||
if _is_installed("promtool"):
|
||||
click.echo("promtool: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://github.com/prometheus/prometheus/releases/download/"
|
||||
f"v{PROMTOOL_VERSION}/prometheus-{PROMTOOL_VERSION}.linux-{arch}.tar.gz"
|
||||
)
|
||||
dest = _download_and_extract_tarball(url, "promtool")
|
||||
click.echo(f"promtool: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu", "vale", "promtool"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
@@ -231,6 +259,8 @@ def _install_tool(name: str) -> bool:
|
||||
return install_tofu()
|
||||
if name == "vale":
|
||||
return install_vale()
|
||||
if name == "promtool":
|
||||
return install_promtool()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def _install_ansible_collections(bin_dir: str) -> None:
|
||||
if not requirements.exists():
|
||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||
return
|
||||
_run([galaxy, "collection", "install", "-r", str(requirements)])
|
||||
_run([galaxy, "collection", "install", "-r", str(requirements), "--no-cache"])
|
||||
|
||||
|
||||
def _configure_tea_login() -> None:
|
||||
|
||||
@@ -81,6 +81,17 @@ def _install_in_image(
|
||||
username,
|
||||
token,
|
||||
)
|
||||
# Configure git URL rewrite so git+https dependencies can authenticate
|
||||
subprocess.run( # nosec B603, B607
|
||||
[
|
||||
"git",
|
||||
"config",
|
||||
"--global",
|
||||
f"url.https://{username}:{token}@{gitea_host}/.insteadOf",
|
||||
f"https://{gitea_host}/",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
click.echo(f"[setup-image] Linked {opt_venv}" + (f" with [{extras}]" if extras else "") + ".")
|
||||
subprocess.run(cmd, check=True, env=env) # nosec B603
|
||||
|
||||
@@ -3822,5 +3822,13 @@
|
||||
"pl": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||
"ru": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||
"zh": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally."
|
||||
},
|
||||
"[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)": {
|
||||
"en": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
"bg": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
"de": "[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)",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,19 @@ import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_test_isolation import (
|
||||
HEAVY_MODULE_IMPORTS,
|
||||
HELPER_INTERNAL_CALLS,
|
||||
IO_INTERNAL_CALLS,
|
||||
KNOWN_IO_FUNCTIONS,
|
||||
KNOWN_SUBPROCESS_HELPERS,
|
||||
CallGraph,
|
||||
_extract_patch_targets,
|
||||
_is_integration_test,
|
||||
_load_test_isolation_config,
|
||||
_SubprocessAudit,
|
||||
analyze_file,
|
||||
analyze_test_files,
|
||||
@@ -1667,3 +1672,110 @@ class TestIsIntegrationTest:
|
||||
item.keywords = {}
|
||||
item.fspath = "tests/unit/test_foo.py"
|
||||
assert _is_integration_test(item) is False
|
||||
|
||||
|
||||
class TestLoadTestIsolationConfig:
|
||||
"""Tests for _load_test_isolation_config — project-specific rule merging."""
|
||||
|
||||
def test_merges_io_functions(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Project-specific io_functions are added to KNOWN_IO_FUNCTIONS."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
'[tool.devx.check_test_isolation]\nio_functions = { "my_custom_io" = "reads from disk" }\n'
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_load_test_isolation_config()
|
||||
assert "my_custom_io" in KNOWN_IO_FUNCTIONS
|
||||
assert KNOWN_IO_FUNCTIONS["my_custom_io"] == "reads from disk"
|
||||
|
||||
def test_merges_subprocess_helpers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Project-specific subprocess_helpers are added."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
'[tool.devx.check_test_isolation]\nsubprocess_helpers = { "my_sp_helper" = "calls subprocess.run" }\n'
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_load_test_isolation_config()
|
||||
assert "my_sp_helper" in KNOWN_SUBPROCESS_HELPERS
|
||||
|
||||
def test_merges_helper_internal_calls(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Project-specific helper_internal_calls are merged."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
'[tool.devx.check_test_isolation]\nhelper_internal_calls = { "my_helper" = ["subprocess", "run_cmd"] }\n'
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_load_test_isolation_config()
|
||||
assert "my_helper" in HELPER_INTERNAL_CALLS
|
||||
assert HELPER_INTERNAL_CALLS["my_helper"] == {"subprocess", "run_cmd"}
|
||||
|
||||
def test_merges_io_internal_calls(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Project-specific io_internal_calls are merged."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
'[tool.devx.check_test_isolation]\nio_internal_calls = { "my_io_func" = ["open", "yaml"] }\n'
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_load_test_isolation_config()
|
||||
assert "my_io_func" in IO_INTERNAL_CALLS
|
||||
assert IO_INTERNAL_CALLS["my_io_func"] == {"open", "yaml"}
|
||||
|
||||
def test_merges_heavy_module_imports(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Project-specific heavy_module_imports are merged."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.check_test_isolation]\nheavy_module_imports = { "mymodule" = 150.0 }\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_load_test_isolation_config()
|
||||
assert "mymodule" in HEAVY_MODULE_IMPORTS
|
||||
assert HEAVY_MODULE_IMPORTS["mymodule"] == 150.0
|
||||
|
||||
def test_no_config_section_is_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Missing [tool.devx.check_test_isolation] section is a no-op."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx]\nother_key = "value"\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
before_io = dict(KNOWN_IO_FUNCTIONS)
|
||||
_load_test_isolation_config()
|
||||
assert before_io == KNOWN_IO_FUNCTIONS
|
||||
|
||||
def test_no_pyproject_is_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No pyproject.toml at all is a no-op."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
before = dict(KNOWN_SUBPROCESS_HELPERS)
|
||||
_load_test_isolation_config()
|
||||
assert before == KNOWN_SUBPROCESS_HELPERS
|
||||
|
||||
def test_non_dict_config_is_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A non-dict check_test_isolation section is a no-op."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx]\ncheck_test_isolation = "not_a_dict"\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
before = dict(HEAVY_MODULE_IMPORTS)
|
||||
_load_test_isolation_config()
|
||||
assert before == HEAVY_MODULE_IMPORTS
|
||||
|
||||
def test_invalid_entry_types_are_skipped(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Entries with wrong types (non-str values) are silently skipped."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
"[tool.devx.check_test_isolation]\n"
|
||||
'io_functions = { "good_func" = "desc", "bad_func" = 123 }\n'
|
||||
'heavy_module_imports = { "good_mod" = 100.0, "bad_mod" = "fast" }\n'
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_load_test_isolation_config()
|
||||
assert "good_func" in KNOWN_IO_FUNCTIONS
|
||||
assert "bad_func" not in KNOWN_IO_FUNCTIONS
|
||||
assert "good_mod" in HEAVY_MODULE_IMPORTS
|
||||
assert "bad_mod" not in HEAVY_MODULE_IMPORTS
|
||||
|
||||
def test_extends_without_replacing_defaults(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Project config adds to defaults without removing them."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.check_test_isolation]\nio_functions = { "project_func" = "project I/O" }\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_load_test_isolation_config()
|
||||
# Default entries still present
|
||||
assert "get_pat" in KNOWN_IO_FUNCTIONS
|
||||
# Project entry added
|
||||
assert "project_func" in KNOWN_IO_FUNCTIONS
|
||||
|
||||
@@ -10,6 +10,7 @@ from devx.tools.check_test_speed import (
|
||||
DEFAULT_MAX_SECONDS,
|
||||
DEFAULT_MAX_SINGLE_SECONDS,
|
||||
TEST_COMMAND,
|
||||
_ci_scale_limit,
|
||||
check_per_test_speed,
|
||||
check_speed,
|
||||
cli,
|
||||
@@ -144,7 +145,26 @@ def test_main_module_block() -> None:
|
||||
mock_cli.assert_called_once_with([])
|
||||
|
||||
|
||||
class TestCiScaleLimit:
|
||||
def test_no_scaling_when_not_ci(self) -> None:
|
||||
with patch("devx.tools.check_test_speed._IS_CI", False):
|
||||
assert _ci_scale_limit(10.0) == 10.0
|
||||
assert _ci_scale_limit(0.5) == 0.5
|
||||
|
||||
def test_scales_when_ci(self) -> None:
|
||||
with patch("devx.tools.check_test_speed._IS_CI", True):
|
||||
with patch("devx.tools.check_test_speed.CI_SCALE_FACTOR", 4.0):
|
||||
assert _ci_scale_limit(10.0) == 40.0
|
||||
assert _ci_scale_limit(0.5) == 2.0
|
||||
|
||||
def test_custom_scale_factor(self) -> None:
|
||||
with patch("devx.tools.check_test_speed._IS_CI", True):
|
||||
with patch("devx.tools.check_test_speed.CI_SCALE_FACTOR", 2.5):
|
||||
assert _ci_scale_limit(10.0) == 25.0
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -174,6 +194,7 @@ class TestMain:
|
||||
mock_parse_per.assert_called_once()
|
||||
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
def test_slow_total_exits(
|
||||
@@ -189,6 +210,7 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "too slow" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -213,6 +235,7 @@ class TestMain:
|
||||
assert "Per-test speed check FAILED" in result.output
|
||||
assert "test_slow" in result.output
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
def test_parse_failure_exits(
|
||||
self,
|
||||
@@ -225,6 +248,7 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "Could not parse" in result.output
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -248,6 +272,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_check.assert_called_once_with(0.5, 1.5)
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -270,6 +295,7 @@ class TestMain:
|
||||
mock_parse_per.assert_not_called()
|
||||
mock_check_per.assert_not_called()
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -292,3 +318,32 @@ class TestMain:
|
||||
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
|
||||
assert result.exit_code == 0
|
||||
mock_check_per.assert_called_once_with([], 1.0)
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", True)
|
||||
@patch("devx.tools.check_test_speed.CI_SCALE_FACTOR", 4.0)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||
def test_ci_scales_limits(
|
||||
self,
|
||||
mock_check_per: MagicMock,
|
||||
mock_parse_per: MagicMock,
|
||||
mock_check: MagicMock,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("out\n", "err\n")
|
||||
mock_parse.return_value = 30.0 # would fail local (10s) but pass CI (40s)
|
||||
mock_parse_per.return_value = []
|
||||
mock_check_per.return_value = []
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "CI environment detected" in result.output
|
||||
assert "scaling limits by 4.0x" in result.output
|
||||
# check_speed called with scaled limit
|
||||
mock_check.assert_called_once_with(30.0, 40.0)
|
||||
mock_check_per.assert_called_once_with([], 2.0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/gitea_cli.py."""
|
||||
"""Unit tests for devx/gitea_cli.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,7 +7,13 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number, configure_tea_login
|
||||
from devx.gitea_cli import (
|
||||
TeaCLI,
|
||||
TeaCLIError,
|
||||
_extract_issue_number,
|
||||
_extract_pr_number,
|
||||
configure_tea_login,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractIssueNumber:
|
||||
@@ -77,6 +83,25 @@ class TestTeaCLIRun:
|
||||
with pytest.raises(TeaCLIError, match="auth error"):
|
||||
cli._run(["labels", "list"])
|
||||
|
||||
def test_run_failure_includes_stdout(self) -> None:
|
||||
"""tea writes some errors to stdout (e.g. 'no available login')."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=1, stdout="no available login", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(TeaCLIError, match="no available login"):
|
||||
cli._run(["releases", "create"])
|
||||
|
||||
def test_run_failure_includes_both_stdout_and_stderr(self) -> None:
|
||||
"""When both stdout and stderr have content, both are included."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=1, stdout="partial error", stderr="auth error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(TeaCLIError, match="partial error"):
|
||||
cli._run(["labels", "list"])
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(TeaCLIError, match="auth error"):
|
||||
cli._run(["labels", "list"])
|
||||
|
||||
def test_run_tea_not_found_raises_tea_error(self) -> None:
|
||||
cli = TeaCLI(tea_bin="tea")
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
|
||||
@@ -100,6 +125,46 @@ class TestTeaCLIRun:
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--output" not in cmd
|
||||
|
||||
def test_run_retries_on_502(self) -> None:
|
||||
"""Transient 502 errors should be retried, then succeed."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="502 Bad Gateway")
|
||||
success_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
|
||||
with patch("subprocess.run", side_effect=[fail_result, success_result]) as mock_run:
|
||||
with patch("tenacity.nap.time.sleep"):
|
||||
output = cli._run(["labels", "list"])
|
||||
assert output == '[{"id": 1}]'
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_run_retries_on_503_then_fails(self) -> None:
|
||||
"""If all retries are exhausted on 503, raise TeaCLIError."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="503 Service Unavailable")
|
||||
with patch("subprocess.run", return_value=fail_result):
|
||||
with patch("tenacity.nap.time.sleep"):
|
||||
with pytest.raises(TeaCLIError, match="503"):
|
||||
cli._run(["issues", "create"])
|
||||
# MAX_RETRIES=3, so 3 attempts total
|
||||
|
||||
def test_run_no_retry_on_non_transient_error(self) -> None:
|
||||
"""Non-transient errors (e.g. auth) should fail immediately without retry."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="auth error")
|
||||
with patch("subprocess.run", return_value=fail_result) as mock_run:
|
||||
with pytest.raises(TeaCLIError, match="auth error"):
|
||||
cli._run(["labels", "list"])
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_run_retries_on_429_in_stdout(self) -> None:
|
||||
"""429 rate limit in stdout should trigger retry."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
fail_result = MagicMock(returncode=1, stdout="429 Too Many Requests", stderr="")
|
||||
success_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", side_effect=[fail_result, success_result]):
|
||||
with patch("tenacity.nap.time.sleep"):
|
||||
output = cli._run(["releases", "create"])
|
||||
assert output == "ok"
|
||||
|
||||
|
||||
class TestRepoArg:
|
||||
def test_with_repo_arg(self) -> None:
|
||||
@@ -380,9 +445,11 @@ class TestConfigureTeaLogin:
|
||||
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login adds login when not already configured."""
|
||||
mock_list = MagicMock(returncode=0, stdout="")
|
||||
mock_subprocess.return_value = mock_list
|
||||
mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="")
|
||||
mock_default = MagicMock(returncode=0, stdout="", stderr="")
|
||||
mock_subprocess.side_effect = [mock_list, mock_add, mock_default]
|
||||
configure_tea_login()
|
||||
assert mock_subprocess.call_count >= 2 # login list + login add + login default
|
||||
assert mock_subprocess.call_count == 3 # login list + login add + login default
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@@ -393,3 +460,37 @@ class TestConfigureTeaLogin:
|
||||
mock_subprocess.return_value = mock_list
|
||||
configure_tea_login()
|
||||
assert mock_subprocess.call_count == 1 # only login list, no add
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
def test_raises_on_login_add_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login raises TeaCLIError if tea login add fails."""
|
||||
mock_list = MagicMock(returncode=0, stdout="")
|
||||
mock_add = MagicMock(returncode=1, stdout="", stderr="invalid token")
|
||||
mock_subprocess.side_effect = [mock_list, mock_add]
|
||||
with pytest.raises(TeaCLIError, match="login add failed"):
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
def test_raises_on_login_default_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login raises TeaCLIError if tea login default fails."""
|
||||
mock_list = MagicMock(returncode=0, stdout="")
|
||||
mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="")
|
||||
mock_default = MagicMock(returncode=1, stdout="", stderr="login not found")
|
||||
mock_subprocess.side_effect = [mock_list, mock_add, mock_default]
|
||||
with pytest.raises(TeaCLIError, match="login default failed"):
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
def test_login_add_failure_includes_stdout(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""Error message includes stdout when tea writes errors there."""
|
||||
mock_list = MagicMock(returncode=0, stdout="")
|
||||
mock_add = MagicMock(returncode=1, stdout="Error: invalid username", stderr="")
|
||||
mock_subprocess.side_effect = [mock_list, mock_add]
|
||||
with pytest.raises(TeaCLIError, match="invalid username"):
|
||||
configure_tea_login()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -47,13 +48,29 @@ class TestDownload:
|
||||
def test_download(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "file.bin"
|
||||
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"data")
|
||||
return str(path), None
|
||||
class _FakeResponse:
|
||||
def __init__(self) -> None:
|
||||
self._sent = False
|
||||
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
def __enter__(self) -> _FakeResponse:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
if self._sent:
|
||||
return b""
|
||||
self._sent = True
|
||||
return b"data"
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=_FakeResponse()) as mock_urlopen:
|
||||
install_tools._download("https://example.com/file", dest)
|
||||
mock_retrieve.assert_called_once()
|
||||
mock_urlopen.assert_called_once()
|
||||
call_args = mock_urlopen.call_args
|
||||
req = call_args.args[0]
|
||||
assert isinstance(req, urllib.request.Request)
|
||||
assert req.get_header("User-agent") == "devx/install-tools"
|
||||
assert dest.read_bytes() == b"data"
|
||||
|
||||
|
||||
@@ -297,6 +314,47 @@ class TestInstallVale:
|
||||
assert (tmp_path / "vale").exists()
|
||||
|
||||
|
||||
class TestInstallPromtool:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_promtool() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake promtool"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
info = tarfile.TarInfo(name="promtool")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(install_tools, "_arch", return_value="amd64"):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
assert install_tools.install_promtool() is True
|
||||
assert (tmp_path / "promtool").exists()
|
||||
|
||||
def test_url_contains_version(self, tmp_path: Path) -> None:
|
||||
"""Verify the download URL includes the correct promtool version."""
|
||||
captured_url = []
|
||||
|
||||
def fake_extract(url: str, binary_name: str) -> Path:
|
||||
captured_url.append(url)
|
||||
return tmp_path / binary_name
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "_download_and_extract_tarball", side_effect=fake_extract):
|
||||
install_tools.install_promtool()
|
||||
assert any(f"v{install_tools.PROMTOOL_VERSION}" in url for url in captured_url)
|
||||
|
||||
|
||||
class TestListTools:
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
@@ -341,6 +399,11 @@ class TestInstallTool:
|
||||
assert install_tools._install_tool("vale") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_promtool(self) -> None:
|
||||
with patch.object(install_tools, "install_promtool", return_value=True) as mock:
|
||||
assert install_tools._install_tool("promtool") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_unknown_tool(self) -> None:
|
||||
with pytest.raises(ClickException, match="Unknown tool"):
|
||||
install_tools._install_tool("unknown")
|
||||
@@ -359,7 +422,7 @@ class TestMain:
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 7
|
||||
assert mock_install.call_count == 8
|
||||
|
||||
def test_install_specific_tool(self) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -102,6 +102,16 @@ class TestBuildEnvForPair:
|
||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"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:
|
||||
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
||||
|
||||
@@ -387,8 +387,10 @@ class TestMain:
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@patch("time.sleep")
|
||||
def test_release_failure_raises_click(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
@@ -397,6 +399,7 @@ class TestMain:
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""Release creation failure after retries raises ClickException."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
||||
@@ -405,6 +408,8 @@ class TestMain:
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "Release creation failed" in result.output
|
||||
# Retried 3 times (stop_after_attempt(3))
|
||||
assert mock_tea.create_release.call_count == 3
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@@ -496,8 +501,10 @@ class TestMain:
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@patch("time.sleep")
|
||||
def test_create_release_already_exists_is_idempotent(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_gitea_pub: MagicMock,
|
||||
@@ -507,7 +514,7 @@ class TestMain:
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""If create_release fails with 'already exists', treat as success."""
|
||||
"""If create_release fails with 'already exists', treat as success (no retry)."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
|
||||
@@ -516,6 +523,8 @@ class TestMain:
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "already exists" in result.output
|
||||
# "already exists" is caught immediately — no retry
|
||||
assert mock_tea.create_release.call_count == 1
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@@ -526,8 +535,10 @@ class TestMain:
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@patch("time.sleep")
|
||||
def test_create_release_other_error_raises(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_gitea_pub: MagicMock,
|
||||
@@ -537,7 +548,7 @@ class TestMain:
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""If create_release fails with a non-'already exists' error, raise."""
|
||||
"""If create_release fails with a non-'already exists' error, raise after retries."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||
mock_tea.create_release.side_effect = TeaCLIError("network error")
|
||||
@@ -546,6 +557,75 @@ class TestMain:
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code != 0
|
||||
assert "Release creation failed" in result.output
|
||||
# Retried 3 times before giving up
|
||||
assert mock_tea.create_release.call_count == 3
|
||||
|
||||
|
||||
class TestReleaseRetry:
|
||||
"""Tests for retry logic on transient release creation failures."""
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@patch("time.sleep")
|
||||
def test_transient_failure_retried_and_succeeds(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""Transient failure on first attempt succeeds on retry."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
mock_tea.create_release.side_effect = [
|
||||
TeaCLIError("connection timeout"),
|
||||
None, # second attempt succeeds
|
||||
]
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
||||
assert result.exit_code == 0
|
||||
assert "Gitea release v1.0.0 created" in result.output
|
||||
assert mock_tea.create_release.call_count == 2
|
||||
mock_sleep.assert_called() # slept between attempts
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@patch("time.sleep")
|
||||
def test_all_retries_exhausted_raises(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""All 3 retry attempts fail — raises ClickException."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
mock_tea.create_release.side_effect = TeaCLIError("503 service unavailable")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
||||
assert result.exit_code == 1
|
||||
assert "Release creation failed" in result.output
|
||||
assert mock_tea.create_release.call_count == 3
|
||||
assert mock_sleep.call_count == 2 # slept between 3 attempts (2 sleeps)
|
||||
|
||||
|
||||
class TestFromTag:
|
||||
|
||||
@@ -108,6 +108,8 @@ class TestInstallAnsibleCollections:
|
||||
mock_path.return_value.__str__ = lambda _: str(req)
|
||||
_install_ansible_collections(".venv/bin")
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args[0][0]
|
||||
assert "--no-cache" in args, "ansible-galaxy must use --no-cache to avoid concurrent cache corruption"
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
def test_skips_when_no_requirements(self, mock_run: MagicMock) -> None:
|
||||
|
||||
Reference in New Issue
Block a user