looks magnificent

This commit is contained in:
CarterPerez-dev 2026-01-28 21:13:19 -05:00
parent 2d84ef62f7
commit 47cb20d23f
14 changed files with 866 additions and 348 deletions

View File

@ -12,10 +12,18 @@ bin/
# Test
*.test
coverage.out
*.prof
trace.out
# Build
tmp/
# Cache
.ruff_cache/
__pycache__/
*.pyc
# IDE
.idea/
.vscode/

View File

@ -1,23 +1,23 @@
# angela
A fast CLI tool that updates Python dependencies in `pyproject.toml` and scans for known vulnerabilities using [OSV.dev](https://osv.dev).
A fast Python dependency updater and vulnerability scanner, written in Go.
Built in Go for speed — parallel HTTP requests, local caching with ETag support, and a single binary with zero runtime dependencies.
angela scans your `pyproject.toml` or `requirements.txt`, updates all dependencies to their latest stable versions, and checks for known CVEs using [OSV.dev](https://osv.dev) — in a single command.
---
## Highlights
## Why
- **One command updates everything** in `pyproject.toml` or `requirements.txt`, like `pnpm update` for Python.
- Fast parallel queries against the [PyPI Simple API](https://peps.python.org/pep-0691/) with local ETag caching.
- Built-in vulnerability scanning via [OSV.dev](https://osv.dev) batch queries — no API key required.
- Full [PEP 440](https://peps.python.org/pep-0440/) version parsing with pre-release filtering and epoch support.
- Comment-preserving file updates — inline comments, section headers, and formatting stay intact.
- Configurable via `.angela.toml` or `[tool.angela]` in `pyproject.toml`.
- Single binary with zero runtime dependencies.
The Python ecosystem lacks a single tool that updates all dependencies in `pyproject.toml` the way `pnpm update` works for JavaScript. Existing options either show outdated packages without updating them (`pip list --outdated`), upgrade one at a time (`pip install --upgrade`), or require CI infrastructure (Dependabot, Renovate).
**angela** fills this gap: one command updates everything, with built-in CVE scanning.
---
## Install
## Installation
```bash
go install github.com/CarterPerez-dev/angela@latest
go install github.com/CarterPerez-dev/angela/cmd/angela@latest
```
Or build from source:
@ -25,119 +25,211 @@ Or build from source:
```bash
git clone https://github.com/CarterPerez-dev/angela.git
cd angela
go build -o bin/angela ./cmd/angela
go build -o angela ./cmd/angela
```
---
## Usage
### Update all dependencies
## Quick start
```bash
# Initialize a new pyproject.toml
angela init
# Update all dependencies to latest stable versions
angela update
```
Reads `pyproject.toml` in the current directory, queries PyPI for the latest stable versions, and writes the updates back to the file.
### Dry run (show what would change)
```bash
# Preview updates without writing changes
angela check
```
Shows available updates and vulnerabilities without modifying any files.
### Scan for vulnerabilities only
```bash
# Scan for known vulnerabilities
angela scan
# Update + scan for vulnerabilities in one pass
angela update --vulns
```
Checks all pinned dependencies against the OSV.dev vulnerability database.
## Commands
### Update with vulnerability scan
### `angela init`
```bash
angela update --scan-vulns
Create a new `pyproject.toml` with angela configuration in the current directory.
```console
$ angela init
Created pyproject.toml
```
Updates dependencies and reports any known CVEs in your current versions.
### `angela update`
### Skip major version bumps
Update all dependencies in `pyproject.toml` to their latest stable versions.
```bash
angela update --safe
```
```console
$ angela update
Only applies minor and patch updates, skipping anything that crosses a major version boundary.
### Include pre-release versions
```bash
angela update --include-prerelease
```
Considers alpha, beta, release candidate, and dev versions when resolving the latest.
### Specify a different file
```bash
angela update --file path/to/pyproject.toml
```
### Clear the local cache
```bash
angela cache clear
```
Removes all cached PyPI responses from `~/.angela/cache/`.
---
## Example output
```
Scanning 9 dependencies...
Updates available:
django 3.2.0 -> 5.1.5 (major)
requests 2.28.0 -> 2.32.3 (minor)
click 8.0.0 -> 8.1.8 (patch)
pydantic 2.0.0 -> 2.10.6 (minor)
django 3.2.0 -> 6.0.1 (major)
requests 2.28.0 -> 2.32.5 (minor)
flask 2.0.0 -> 3.1.0 (major)
pytest 7.0.0 -> 8.3.4 (major)
black 23.0.0 -> 25.1.0 (major)
ruff 0.1.0 -> 0.9.4 (minor)
mypy 1.0.0 -> 1.14.1 (minor)
Vulnerabilities found:
django
GHSA-2hrw-hx67-34x6 [CRITICAL] Potential denial-of-service in django.utils.text.Truncator
Fixed in: 4.2.16
requests
GHSA-9wx4-h78v-vm56 [MEDIUM] Requests `Session` object does not verify requests after making first request with verify=False
Fixed in: 2.32.0
click 8.0.0 -> 8.1.8 (patch)
Updated pyproject.toml
9 packages checked
9 updated
4 updated
Done in 1.8s
```
Flags:
```
-f, --file string Path to dependency file (default "pyproject.toml")
--safe Skip major version bumps
--vulns Also scan for vulnerabilities
--include-prerelease Include pre-release versions (alpha, beta, rc, dev)
```
### `angela check`
Show available updates without modifying any files.
```console
$ angela check
```
Accepts the same flags as `update`. Useful for CI or previewing changes before applying them.
### `angela scan`
Scan all dependencies for known vulnerabilities via OSV.dev.
```console
$ angela scan
Scanning 9 dependencies...
Vulnerabilities: 36 across 4 packages
django (30 vulns: 5 critical, 15 high, 10 moderate)
CRITICAL CVE-2022-28346 SQL Injection in Django Fixed: 2.2.28
CRITICAL CVE-2022-34265 Trunc()/Extract() SQL Injection Fixed: 3.2.14
HIGH CVE-2023-24580 Resource exhaustion Fixed: 3.2.18
HIGH CVE-2022-36359 Reflected File Download attack Fixed: 3.2.15
HIGH CVE-2023-46695 Potential denial-of-service Fixed: 4.2.8
...and 25 more
requests (3 vulns: 3 moderate)
MODERATE CVE-2024-35195 Session verify=False bypass Fixed: 2.32.0
MODERATE CVE-2023-32681 Proxy-Authorization header leak Fixed: 2.31.0
MODERATE CVE-2024-47081 .netrc credentials leak via URLs Fixed: 2.32.4
Run angela scan -v for full details.
9 packages checked
36 vulnerabilities found
Done in 2.4s
```
---
Use `-v` for full vulnerability details including advisory links:
```console
$ angela scan -v
```
Flags:
```
-f, --file string Path to dependency file (default "pyproject.toml")
```
### `angela cache clear`
Remove all cached PyPI responses from `~/.angela/cache/`.
### Global flags
```
-v, --verbose Show full vulnerability details
--min-severity Minimum severity to report (critical, high, moderate, low)
```
### Subcommand aliases
| Command | Alias |
|---------|-------|
| `angela update` | `angela u` |
| `angela check` | `angela c` |
| `angela scan` | `angela s` |
## File support
angela works with both `pyproject.toml` and `requirements.txt`. The file type is detected automatically by extension:
```bash
# pyproject.toml (default)
angela update
# requirements.txt
angela update -f requirements.txt
```
For `pyproject.toml`, angela reads `[project.dependencies]` and `[project.optional-dependencies]`. For `requirements.txt`, it parses standard pip-format lines, skipping comments, blank lines, and pip options (`-r`, `-e`, `--index-url`).
Both parsers handle PEP 508 dependency strings including extras (`[security,socks]`) and environment markers (`; python_version >= "3.8"`).
## Configuration
angela supports project-level configuration through either a standalone `.angela.toml` file or a `[tool.angela]` section in `pyproject.toml`.
### `.angela.toml`
```toml
min-severity = "moderate"
ignore = [
"django",
"numpy",
]
ignore-vulns = [
"CVE-2024-3772",
]
```
### `[tool.angela]` in pyproject.toml
```toml
[tool.angela]
min-severity = "moderate"
ignore = ["django", "numpy"]
ignore-vulns = ["CVE-2024-3772"]
```
### Options
| Key | Type | Description |
|-----|------|-------------|
| `min-severity` | string | Minimum severity to report: `critical`, `high`, `moderate`, `low` |
| `ignore` | string array | Package names to skip during updates |
| `ignore-vulns` | string array | Vulnerability IDs to suppress (CVE or GHSA) |
### Resolution order
1. CLI flags (`--min-severity`)
2. `.angela.toml`
3. `[tool.angela]` in `pyproject.toml`
4. Defaults
## How it works
```
1. Parse pyproject.toml
Extract all [project.dependencies] and [project.optional-dependencies]
1. Parse dependency file
Read pyproject.toml or requirements.txt
Extract package names and version specifiers
2. Query PyPI Simple API (parallel)
Fetch version lists using the lightweight JSON format
ETag-based caching avoids redundant downloads
2. Query PyPI (parallel)
Fetch version lists from the Simple API with JSON format
Cache responses locally with ETag support (1-hour TTL)
3. Resolve updates
Parse versions per PEP 440 (epochs, pre-releases, post-releases)
@ -145,40 +237,43 @@ Removes all cached PyPI responses from `~/.angela/cache/`.
Classify changes as major, minor, or patch
4. Scan for vulnerabilities (optional)
Batch query OSV.dev for all dependencies
Batch query OSV.dev for all dependencies in a single request
Hydrate results with full advisory details
Deduplicate overlapping CVE/GHSA identifiers
5. Write updates
Regex-based surgery preserves comments and formatting
Regex-based surgery preserves all comments and formatting
Atomic write via temp file + rename
```
---
## Architecture
## Project structure
```
cmd/angela/main.go CLI entry point
cmd/angela/main.go Entry point
internal/
cli/
update.go Command definitions and orchestration
output.go Terminal formatting with color
update.go Commands and orchestration
output.go Terminal formatting
config/
config.go Configuration loading
pypi/
version.go PEP 440 parser and comparator
client.go PyPI Simple API client with retry
cache.go File-backed ETag cache
client.go PyPI Simple API client with retry
version.go PEP 440 parser and comparator
cache.go File-backed ETag cache
osv/
client.go OSV.dev batch vulnerability scanner
client.go OSV.dev batch vulnerability scanner
pyproject/
parser.go TOML parsing and PEP 508 splitting
writer.go Comment-preserving regex updater
parser.go TOML parsing and PEP 508 splitting
writer.go Comment-preserving regex updater
requirements/
parser.go requirements.txt parsing
writer.go requirements.txt updater
pkg/types/
types.go Shared domain types
types.go Shared domain types
testdata/ Test fixtures
learn/ Educational deep dives
```
---
## Development
Requires Go 1.24+ and [just](https://github.com/casey/just).
@ -187,20 +282,16 @@ Requires Go 1.24+ and [just](https://github.com/casey/just).
just test # Run all tests with race detector
just lint # Run golangci-lint
just build # Build binary to bin/angela
just cover # Generate HTML coverage report
just check # Lint + test in one step
just cover # Generate coverage report
just ci # Lint + test in one step
just run check # Run angela check via go run
```
---
## Part of Cybersecurity-Projects
This tool is project #1 in the [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) repository — a collection of 60 security-focused projects built for learning and reference. The code is written to be educational: clear structure, proper error handling, and thorough testing.
This tool is project #1 in [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) — a collection of 60 security-focused projects built for learning and reference.
See the [`learn/`](learn/) directory for deep dives into the techniques used here.
---
The code is written to be educational: clear structure, proper error handling, and thorough testing. See the [`learn/`](learn/) directory for deep dives into the techniques used.
## License

View File

@ -11,8 +11,8 @@ require (
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.25.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.40.0 // indirect
)

View File

@ -3,9 +3,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@ -13,13 +12,13 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -9,31 +9,25 @@ import (
"strings"
"github.com/CarterPerez-dev/angela/internal/pypi"
"github.com/CarterPerez-dev/angela/internal/ui"
"github.com/CarterPerez-dev/angela/pkg/types"
"github.com/fatih/color"
)
var (
bold = color.New(color.Bold).SprintFunc()
dim = color.New(color.Faint).SprintFunc()
cyan = color.New(color.FgCyan, color.Bold).SprintFunc()
green = color.New(color.FgGreen).SprintFunc()
yellow = color.New(color.FgYellow).SprintFunc()
red = color.New(color.FgRed).SprintFunc()
redBold = color.New(color.FgRed, color.Bold).SprintFunc()
greenBold = color.New(color.FgGreen, color.Bold).SprintFunc()
white = color.New(color.FgWhite, color.Bold).SprintFunc()
)
func printDivider() {
fmt.Printf("\n %s\n", ui.HiBlack(ui.HRule(44)))
}
// PrintScanning announces the start of a scan
func PrintScanning(count int) {
func printSectionHeader(
symbol, title string,
symbolColor, titleColor func(a ...any) string,
) {
fmt.Printf(
"\n %s %d dependencies...\n\n",
cyan("Scanning"), count,
"\n %s %s\n",
symbolColor(symbol),
titleColor(title),
)
}
// PrintUpdates displays a formatted table of available dependency updates
func PrintUpdates(updates []types.UpdateResult) {
var actionable []types.UpdateResult
for _, u := range updates {
@ -43,11 +37,22 @@ func PrintUpdates(updates []types.UpdateResult) {
}
if len(actionable) == 0 {
fmt.Printf(" %s\n\n", dim("All dependencies are up to date."))
printDivider()
fmt.Printf(
"\n %s %s\n",
ui.HiGreen(ui.Check),
ui.HiBlackItalic(
"All dependencies are up to date.",
),
)
return
}
fmt.Printf(" %s\n", cyan("Updates available:"))
printDivider()
printSectionHeader(
ui.Diamond, "Updates available",
ui.Cyan, ui.Red,
)
nameWidth := 0
oldWidth := 0
@ -63,18 +68,19 @@ func PrintUpdates(updates []types.UpdateResult) {
for _, u := range actionable {
changeColor := changeColorFn(u.Change)
fmt.Printf(
" %-*s %s %s %s %s\n",
nameWidth, white(u.Name),
dim(padRight(u.OldVer, oldWidth)),
dim("->"),
" %-*s %s %s %s %s %s\n",
nameWidth, ui.HiBlue(u.Name),
ui.HiBlackCrossed(
padRight(u.OldVer, oldWidth),
),
ui.Green(ui.Arrow),
changeColor(u.NewVer),
dim("("+u.Change+")"),
ui.HiBlack(ui.ArrowRight),
changeColor(u.Change),
)
}
fmt.Println()
}
// PrintSkipped displays dependencies that were not updated with reasons
func PrintSkipped(updates []types.UpdateResult) {
var skipped []types.UpdateResult
for _, u := range updates {
@ -86,14 +92,21 @@ func PrintSkipped(updates []types.UpdateResult) {
return
}
fmt.Printf(" %s\n", dim("Skipped:"))
printDivider()
printSectionHeader(
ui.ArrowRight, "Skipped",
ui.Yellow, ui.Red,
)
for _, u := range skipped {
fmt.Printf(" %s %s\n", dim(u.Name), dim(u.Reason))
fmt.Printf(
" %s %s\n",
ui.HiBlue(u.Name),
ui.HiBlackItalic(u.Reason),
)
}
fmt.Println()
}
// PrintVulnerabilities displays security advisories in compact or verbose mode
func PrintVulnerabilities(
vulns map[string][]types.Vulnerability,
) {
@ -106,12 +119,15 @@ func PrintVulnerabilities(
total += len(vl)
}
printDivider()
fmt.Printf(
" %s %d across %d %s\n",
redBold("Vulnerabilities:"),
total,
len(vulns),
pluralize("package", len(vulns)),
"\n %s %s %s %s %s %s\n",
ui.Red(ui.TriangleUp),
ui.Red("Vulnerabilities:"),
ui.RedBold(fmt.Sprintf("%d", total)),
ui.HiBlack("across"),
ui.RedBold(fmt.Sprintf("%d", len(vulns))),
ui.HiBlack(pluralize("package", len(vulns))),
)
pkgs := sortedVulnPackages(vulns)
@ -134,11 +150,15 @@ func printVulnsCompact(
sortVulnsBySeverity(vlist)
fmt.Printf(
"\n %s (%d %s: %s)\n",
white(pkg),
len(vlist),
pluralize("vuln", len(vlist)),
severityBreakdown(vlist),
"\n %s %s %s\n",
ui.Blue(ui.Gem),
ui.HiCyan(pkg),
ui.HiBlack(fmt.Sprintf(
"(%d %s: %s)",
len(vlist),
pluralize("vuln", len(vlist)),
severityBreakdown(vlist),
)),
)
limit := min(maxVulnsPerPackage, len(vlist))
@ -148,8 +168,8 @@ func printVulnsCompact(
if len(vlist) > maxVulnsPerPackage {
fmt.Printf(
" %s\n",
dim(fmt.Sprintf(
" %s\n",
ui.HiBlackItalic(fmt.Sprintf(
"...and %d more",
len(vlist)-maxVulnsPerPackage,
)),
@ -158,8 +178,10 @@ func printVulnsCompact(
}
fmt.Printf(
"\n %s\n\n",
dim("Run angela scan -v for full details."),
"\n %s\n",
ui.HiBlackItalic(
"Run angela scan -v for full details.",
),
)
}
@ -172,11 +194,15 @@ func printVulnsVerbose(
sortVulnsBySeverity(vlist)
fmt.Printf(
"\n %s (%d %s: %s)\n",
white(pkg),
len(vlist),
pluralize("vuln", len(vlist)),
severityBreakdown(vlist),
"\n %s %s %s\n",
ui.Magenta(ui.Gem),
ui.HiCyan(pkg),
ui.HiBlack(fmt.Sprintf(
"(%d %s: %s)",
len(vlist),
pluralize("vuln", len(vlist)),
severityBreakdown(vlist),
)),
)
for _, v := range vlist {
@ -184,22 +210,28 @@ func printVulnsVerbose(
sevColor := severityColorFn(v.Severity)
fmt.Printf(
"\n %s %s\n",
"\n %s %s\n",
sevColor(
padRight(strings.ToUpper(v.Severity), 8),
),
bold(id),
ui.Magenta(id),
)
fmt.Printf(
" %s\n",
ui.HiBlack(truncate(v.Summary, 72)),
)
fmt.Printf(" %s\n", truncate(v.Summary, 72))
if v.FixedIn != "" {
fmt.Printf(
" %s %s\n",
dim("Fixed in:"),
green(v.FixedIn),
" %s %s\n",
ui.HiBlack("Fixed in:"),
ui.Green(v.FixedIn),
)
}
if v.Link != "" {
fmt.Printf(" %s\n", dim(v.Link))
fmt.Printf(
" %s\n",
ui.BlueItalic(v.Link),
)
}
}
}
@ -212,14 +244,17 @@ func printVulnLine(v types.Vulnerability) {
fixedStr := ""
if v.FixedIn != "" {
fixedStr = dim("Fixed: ") + green(v.FixedIn)
fixedStr = ui.HiBlack("Fixed: ") +
ui.Green(v.FixedIn)
}
fmt.Printf(
" %s %s %s %s\n",
sevColor(padRight(strings.ToUpper(v.Severity), 8)),
bold(padRight(id, 16)),
truncate(v.Summary, 38),
" %s %s %s %s\n",
sevColor(
padRight(strings.ToUpper(v.Severity), 8),
),
ui.Blue(padRight(id, 16)),
ui.HiBlack(truncate(v.Summary, 38)),
fixedStr,
)
}
@ -242,7 +277,9 @@ func sortedVulnPackages(
return pkgs
}
func sortVulnsBySeverity(vulns []types.Vulnerability) {
func sortVulnsBySeverity(
vulns []types.Vulnerability,
) {
sort.Slice(vulns, func(i, j int) bool {
ri := severityRank(vulns[i].Severity)
rj := severityRank(vulns[j].Severity)
@ -283,12 +320,13 @@ func severityBreakdown(
var parts []string
for _, sev := range order {
if n := counts[sev]; n > 0 {
parts = append(parts, fmt.Sprintf(
sevColor := severityColorFn(sev)
parts = append(parts, sevColor(fmt.Sprintf(
"%d %s", n, strings.ToLower(sev),
))
)))
}
}
return strings.Join(parts, ", ")
return strings.Join(parts, ui.HiBlack(", "))
}
func preferredID(v types.Vulnerability) string {
@ -336,7 +374,9 @@ func filterIgnoredVulns(
return filtered
}
func isVulnIgnored(v types.Vulnerability, ignored map[string]bool) bool {
func isVulnIgnored(
v types.Vulnerability, ignored map[string]bool,
) bool {
if ignored[v.ID] {
return true
}
@ -368,73 +408,106 @@ func filterVulnsBySeverity(
return filtered
}
// PrintSummary displays final counts after an update or scan operation
func PrintSummary(result types.ScanResult, updated bool) {
func PrintSummary(
result types.ScanResult, updated bool,
) {
printDivider()
printSectionHeader(
ui.Star, "Summary",
ui.Green, ui.RedItalic,
)
if updated {
fmt.Printf(" %s\n", greenBold("Updated pyproject.toml"))
fmt.Printf(
" %s %s\n",
ui.HiGreen(ui.Check),
ui.HiGreen("Updated pyproject.toml"),
)
}
fmt.Printf(
" %s packages checked\n",
bold(fmt.Sprintf("%d", result.TotalPackages)),
" %s %s %s\n",
ui.Green(ui.Check),
ui.Green(fmt.Sprintf("%d", result.TotalPackages)),
ui.GreenItalic("packages checked"),
)
if result.TotalUpdated > 0 {
fmt.Printf(
" %s updated\n",
bold(fmt.Sprintf("%d", result.TotalUpdated)),
" %s %s %s\n",
ui.Green(ui.ArrowUp),
ui.Green(
fmt.Sprintf("%d", result.TotalUpdated),
),
ui.GreenItalic("updated"),
)
}
if result.VulnsScanned {
if result.TotalVulns > 0 {
fmt.Printf(
" %s\n",
red(fmt.Sprintf(
"%d %s found",
result.TotalVulns,
pluralize("vulnerability", result.TotalVulns),
)),
" %s %s %s\n",
ui.HiRed(ui.Cross),
ui.RedBold(
fmt.Sprintf("%d", result.TotalVulns),
),
ui.HiRed(
pluralize(
"vulnerability",
result.TotalVulns,
)+" found",
),
)
} else {
fmt.Printf(
" %s\n",
green("No vulnerabilities found"),
" %s %s\n",
ui.HiGreen(ui.Check),
ui.HiGreen("No vulnerabilities found"),
)
}
}
fmt.Printf(
" %s %s\n\n",
dim("Done in"),
dim(result.Duration.Round(1e6).String()),
" %s %s\n\n",
ui.HiCyan(ui.Timer),
ui.HiBlueItalic(
result.Duration.Round(1e6).String(),
),
)
}
// PrintError displays a user-friendly error message
func PrintError(msg string) {
fmt.Printf("\n %s %s\n\n", red("error:"), msg)
fmt.Printf(
"\n %s %s %s\n\n",
ui.RedBold(ui.Cross),
ui.RedBold("error:"),
ui.HiRed(msg),
)
}
func changeColorFn(kind string) func(a ...any) string {
func changeColorFn(
kind string,
) func(a ...any) string {
switch kind {
case pypi.Major.String():
return red
return ui.Red
case pypi.Minor.String():
return yellow
return ui.HiYellow
default:
return green
return ui.Green
}
}
func severityColorFn(sev string) func(a ...any) string {
func severityColorFn(
sev string,
) func(a ...any) string {
switch strings.ToUpper(sev) {
case "CRITICAL":
return redBold
return ui.Red
case "HIGH":
return red
return ui.RedBold
case "MODERATE":
return yellow
return ui.HiYellow
case "LOW":
return cyan
return ui.Cyan
default:
return dim
return ui.HiBlack
}
}

View File

@ -17,6 +17,7 @@ import (
"github.com/CarterPerez-dev/angela/internal/pypi"
"github.com/CarterPerez-dev/angela/internal/pyproject"
"github.com/CarterPerez-dev/angela/internal/requirements"
"github.com/CarterPerez-dev/angela/internal/ui"
"github.com/CarterPerez-dev/angela/pkg/types"
"github.com/spf13/cobra"
)
@ -50,8 +51,25 @@ func Execute() {
latest stable versions, and checks for known CVEs using OSV.dev.`,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRun: func(
_ *cobra.Command, _ []string,
) {
ui.PrintBanner()
},
}
defaultHelp := root.HelpFunc()
root.SetHelpFunc(
func(cmd *cobra.Command, args []string) {
if cmd.Root() == cmd {
ui.PrintBannerWithArt()
} else {
ui.PrintBanner()
}
defaultHelp(cmd, args)
},
)
root.PersistentFlags().BoolVarP(
&verbose, "verbose", "v", false,
"show full vulnerability details",
@ -116,11 +134,19 @@ func runInit() error {
name := filepath.Base(dir)
content := fmt.Sprintf(pyprojectTemplate, name)
if err := os.WriteFile("pyproject.toml", []byte(content), 0o644); err != nil { //nolint:gosec
if err := os.WriteFile(
"pyproject.toml",
[]byte(content),
0o644,
); err != nil { //nolint:gosec
return fmt.Errorf("write pyproject.toml: %w", err)
}
fmt.Printf("\n %s pyproject.toml\n\n", greenBold("Created"))
fmt.Printf(
"\n %s %s\n\n",
ui.HiGreen(ui.Check),
ui.HiGreen("Created pyproject.toml"),
)
return nil
}
@ -128,17 +154,19 @@ func newUpdateCmd() *cobra.Command {
f := &updateFlags{}
cmd := &cobra.Command{
Use: "update",
Use: "update [path]",
Aliases: []string{"u"},
Short: "Update dependencies to latest stable versions",
RunE: func(cmd *cobra.Command, _ []string) error {
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
f.file = resolveFile(f.file, args)
return runUpdate(cmd.Context(), f, false)
},
}
cmd.Flags().StringVarP(
&f.file, "file", "f", "pyproject.toml",
"path to pyproject.toml",
"path to dependency file",
)
cmd.Flags().BoolVar(
&f.safe, "safe", false,
@ -159,17 +187,19 @@ func newCheckCmd() *cobra.Command {
f := &updateFlags{}
cmd := &cobra.Command{
Use: "check",
Use: "check [path]",
Aliases: []string{"c"},
Short: "Show available updates without modifying files",
RunE: func(cmd *cobra.Command, _ []string) error {
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
f.file = resolveFile(f.file, args)
return runUpdate(cmd.Context(), f, true)
},
}
cmd.Flags().StringVarP(
&f.file, "file", "f", "pyproject.toml",
"path to pyproject.toml",
"path to dependency file",
)
cmd.Flags().BoolVar(
&f.safe, "safe", false,
@ -190,17 +220,19 @@ func newScanCmd() *cobra.Command {
var file string
cmd := &cobra.Command{
Use: "scan",
Use: "scan [path]",
Aliases: []string{"s"},
Short: "Scan dependencies for known vulnerabilities",
RunE: func(cmd *cobra.Command, _ []string) error {
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
file = resolveFile(file, args)
return runScan(cmd.Context(), file)
},
}
cmd.Flags().StringVarP(
&file, "file", "f", "pyproject.toml",
"path to pyproject.toml",
"path to dependency file",
)
return cmd
}
@ -222,7 +254,11 @@ func newCacheCmd() *cobra.Command {
if err := client.ClearCache(); err != nil {
return err
}
fmt.Println(" Cache cleared.")
fmt.Printf(
" %s %s\n",
ui.HiGreen(ui.Check),
ui.HiGreen("Cache cleared."),
)
return nil
},
})
@ -243,10 +279,14 @@ func runUpdate(
return err
}
PrintScanning(len(deps))
spin := ui.NewSpinner(fmt.Sprintf(
"Scanning %d dependencies...", len(deps),
))
spin.Start()
client, err := pypi.NewClient(defaultCacheDir())
if err != nil {
spin.Stop()
return err
}
@ -271,12 +311,21 @@ func runUpdate(
sortUpdates(updates)
var vulns map[string][]types.Vulnerability
var scanErr error
if f.vulns {
vulns = scanForVulns(ctx, deps)
vulns, scanErr = scanForVulns(ctx, deps)
}
spin.Stop()
if scanErr != nil {
PrintError(scanErr.Error())
}
if !dryRun && len(updateSpecs) > 0 {
if err := updateDepsFile(f.file, updateSpecs); err != nil {
if err := updateDepsFile(
f.file, updateSpecs,
); err != nil {
return fmt.Errorf("write updates: %w", err)
}
}
@ -318,10 +367,21 @@ func runScan(ctx context.Context, file string) error {
return err
}
PrintScanning(len(deps))
spin := ui.NewSpinner(fmt.Sprintf(
"Scanning %d dependencies for vulnerabilities...",
len(deps),
))
spin.Start()
minSev := resolveMinSeverity(cfg.MinSeverity)
vulns := scanForVulns(ctx, deps)
vulns, scanErr := scanForVulns(ctx, deps)
spin.Stop()
if scanErr != nil {
PrintError(scanErr.Error())
}
vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns)
vulns = filterVulnsBySeverity(vulns, minSev)
PrintVulnerabilities(vulns)
@ -446,7 +506,7 @@ func resolveUpdates(
func scanForVulns(
ctx context.Context,
deps []types.Dependency,
) map[string][]types.Vulnerability {
) (map[string][]types.Vulnerability, error) {
var queries []osv.PackageQuery
for _, dep := range deps {
ver := pyproject.ExtractMinVersion(dep.Spec)
@ -460,16 +520,17 @@ func scanForVulns(
}
if len(queries) == 0 {
return nil
return nil, nil
}
client := osv.NewClient()
vulns, err := client.ScanPackages(ctx, queries)
if err != nil {
PrintError(fmt.Sprintf("vulnerability scan: %v", err))
return nil
return nil, fmt.Errorf(
"vulnerability scan: %w", err,
)
}
return vulns
return vulns, nil
}
func latestAny(versions []string) (pypi.Version, error) {
@ -493,6 +554,18 @@ func latestAny(versions []string) (pypi.Version, error) {
return latest, nil
}
func resolveFile(flagVal string, args []string) string {
if len(args) > 0 {
path := args[0]
info, err := os.Stat(path)
if err == nil && info.IsDir() {
return filepath.Join(path, "pyproject.toml")
}
return path
}
return flagVal
}
func isRequirementsTxt(path string) bool {
return strings.HasSuffix(strings.ToLower(path), ".txt")
}

View File

@ -0,0 +1,79 @@
// ©AngelaMos | 2026
// banner.go
package ui
import "fmt"
var angelaBanner = []string{
" ▄▀▄ █▄ █ ▄▀ ██▀ █ ▄▀▄ ",
" █▀█ █ ▀█ ▀▄█ █▄▄ █▄▄ █▀█ ",
}
var bannerColors = []func(a ...any) string{
Red,
Blue,
}
var animeArt = []string{
"⣿⣿⣿⡷⠊⡢⡹⣦⡑⢂⢕⢂⢕⢂⢕⢂⠕⠔⠌⠝⠛⠶⠶⢶⣦⣄⢂⢕⢂⢕",
"⣿⣿⠏⣠⣾⣦⡐⢌⢿⣷⣦⣅⡑⠕⠡⠐⢿⠿⣛⠟⠛⠛⠛⠛⠡⢷⡈⢂⢕⢂",
"⠟⣡⣾⣿⣿⣿⣿⣦⣑⠝⢿⣿⣿⣿⣿⣿⡵⢁⣤⣶⣶⣿⢿⢿⢿⡟⢻⣤⢑⢂",
"⣾⣿⣿⡿⢟⣛⣻⣿⣿⣿⣦⣬⣙⣻⣿⣿⣷⣿⣿⢟⢝⢕⢕⢕⢕⢽⣿⣿⣷⣔",
"⣿⣿⠵⠚⠉⢀⣀⣀⣈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣗⢕⢕⢕⢕⢕⢕⣽⣿⣿⣿⣿",
"⢷⣂⣠⣴⣾⡿⡿⡻⡻⣿⣿⣴⣿⣿⣿⣿⣿⣿⣷⣵⣵⣵⣷⣿⣿⣿⣿⣿⣿⡿",
"⢌⠻⣿⡿⡫⡪⡪⡪⡪⣺⣿⣿⣿⣿⣿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃",
"⠣⡁⠹⡪⡪⡪⡪⣪⣾⣿⣿⣿⣿⠋⠐⢉⢍⢄⢌⠻⣿⣿⣿⣿⣿⣿⣿⣿⠏⠈",
"⡣⡘⢄⠙⣾⣾⣾⣿⣿⣿⣿⣿⣿⡀⢐⢕⢕⢕⢕⢕⡘⣿⣿⣿⣿⣿⣿⠏⠠⠈",
"⠌⢊⢂⢣⠹⣿⣿⣿⣿⣿⣿⣿⣿⣧⢐⢕⢕⢕⢕⢕⢅⣿⣿⣿⣿⡿⢋⢜⠠⠈",
}
var artColors = []func(a ...any) string{
Red,
Red,
Blue,
Blue,
Red,
Red,
Blue,
Blue,
Blue,
Red,
}
func PrintBanner() {
fmt.Println()
fmt.Println()
for i, line := range angelaBanner {
c := bannerColors[i%len(bannerColors)]
fmt.Printf(" %s\n", c(line))
}
fmt.Printf(" %s\n", White(HRule(52)))
fmt.Printf(
" %s\n\n",
HiBlackItalic(
"Python dependency updater & vulnerability scanner",
),
)
}
func PrintBannerWithArt() {
fmt.Println()
for i, line := range angelaBanner {
c := bannerColors[i%len(bannerColors)]
fmt.Printf(" %s\n", c(line))
}
fmt.Printf(" %s\n", White(HRule(52)))
fmt.Printf(
" %s\n",
HiBlackItalic(
"Python dependency updater & vulnerability scanner",
),
)
fmt.Println()
for i, line := range animeArt {
c := artColors[i%len(artColors)]
fmt.Printf(" %s\n", c(line))
}
fmt.Println()
}

View File

@ -0,0 +1,94 @@
// ©AngelaMos | 2026
// color.go
package ui
import "github.com/fatih/color"
var (
Black = color.New(color.FgBlack).SprintFunc()
Red = color.New(color.FgRed).SprintFunc()
Green = color.New(color.FgGreen).SprintFunc()
Yellow = color.New(color.FgYellow).SprintFunc()
Blue = color.New(color.FgBlue).SprintFunc()
Magenta = color.New(color.FgMagenta).SprintFunc()
Cyan = color.New(color.FgCyan).SprintFunc()
White = color.New(color.FgWhite).SprintFunc()
HiBlack = color.New(color.FgHiBlack).SprintFunc()
HiRed = color.New(color.FgHiRed).SprintFunc()
HiGreen = color.New(color.FgHiGreen).SprintFunc()
HiYellow = color.New(color.FgHiYellow).SprintFunc()
HiBlue = color.New(color.FgHiBlue).SprintFunc()
HiMagenta = color.New(color.FgHiMagenta).SprintFunc()
HiCyan = color.New(color.FgHiCyan).SprintFunc()
HiWhite = color.New(color.FgHiWhite).SprintFunc()
RedBold = color.New(color.FgRed, color.Bold).SprintFunc()
GreenBold = color.New(color.FgGreen, color.Bold).SprintFunc()
YellowBold = color.New(color.FgYellow, color.Bold).SprintFunc()
BlueBold = color.New(color.FgBlue, color.Bold).SprintFunc()
MagentaBold = color.New(
color.FgMagenta, color.Bold,
).SprintFunc()
CyanBold = color.New(
color.FgCyan, color.Bold,
).SprintFunc()
HiRedBold = color.New(
color.FgHiRed, color.Bold,
).SprintFunc()
HiMagentaBold = color.New(
color.FgHiMagenta, color.Bold,
).SprintFunc()
HiCyanBold = color.New(
color.FgHiCyan, color.Bold,
).SprintFunc()
Dim = color.New(color.Faint).SprintFunc()
DimItalic = color.New(
color.Faint, color.Italic,
).SprintFunc()
CyanItalic = color.New(
color.FgCyan, color.Italic,
).SprintFunc()
MagentaItalic = color.New(
color.FgMagenta, color.Italic,
).SprintFunc()
RedItalic = color.New(
color.FgRed, color.Italic,
).SprintFunc()
GreenItalic = color.New(
color.FgGreen, color.Italic,
).SprintFunc()
HiBlackItalic = color.New(
color.FgHiBlack, color.Italic,
).SprintFunc()
BlueItalic = color.New(
color.FgBlue, color.Italic,
).SprintFunc()
HiBlueItalic = color.New(
color.FgHiBlue, color.Italic,
).SprintFunc()
CyanUnderline = color.New(
color.FgCyan, color.Underline,
).SprintFunc()
MagentaUnderline = color.New(
color.FgMagenta, color.Underline,
).SprintFunc()
HiCyanBoldItalic = color.New(
color.FgHiCyan, color.Bold, color.Italic,
).SprintFunc()
HiMagentaBoldItalic = color.New(
color.FgHiMagenta, color.Bold, color.Italic,
).SprintFunc()
HiRedBoldItalic = color.New(
color.FgHiRed, color.Bold, color.Italic,
).SprintFunc()
HiBlackCrossed = color.New(
color.FgHiBlack, color.CrossedOut,
).SprintFunc()
)

View File

@ -0,0 +1,83 @@
// ©AngelaMos | 2026
// spinner.go
package ui
import (
"fmt"
"strings"
"sync"
"time"
)
var frames = []string{
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
}
type Spinner struct {
msg string
done chan struct{}
wg sync.WaitGroup
mu sync.Mutex
active bool
}
func NewSpinner(msg string) *Spinner {
return &Spinner{msg: msg}
}
func (s *Spinner) Start() {
s.mu.Lock()
if s.active {
s.mu.Unlock()
return
}
s.active = true
s.done = make(chan struct{})
s.wg.Add(1)
s.mu.Unlock()
go s.run()
}
func (s *Spinner) Stop() {
s.mu.Lock()
if !s.active {
s.mu.Unlock()
return
}
s.active = false
close(s.done)
s.mu.Unlock()
s.wg.Wait()
}
func (s *Spinner) run() {
defer s.wg.Done()
fmt.Print("\033[?25l")
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
idx := 0
for {
select {
case <-s.done:
clearLine()
fmt.Print("\033[?25h")
return
case <-ticker.C:
frame := frames[idx%len(frames)]
fmt.Printf(
"\r %s %s",
CyanBold(frame),
HiMagenta(s.msg),
)
idx++
}
}
}
func clearLine() {
fmt.Print("\r" + strings.Repeat(" ", 80) + "\r")
}

View File

@ -0,0 +1,24 @@
// ©AngelaMos | 2026
// symbol.go
package ui
import "strings"
const (
Arrow = "→"
ArrowRight = "▸"
ArrowUp = "↑"
Diamond = "◆"
Gem = "◈"
Star = "✦"
TriangleUp = "▲"
Check = "✓"
Cross = "✗"
Timer = "⏱"
DividerChar = "━"
)
func HRule(width int) string {
return strings.Repeat(DividerChar, width)
}

View File

@ -1,20 +1,20 @@
# Go Concurrency Patterns Used in angela
This document explains the concurrency patterns angela uses to fetch data from PyPI and OSV.dev in parallel. These are production patterns used at Uber, Google, and Cloudflare — not textbook exercises.
angela fetches data from PyPI and OSV.dev in parallel. These patterns show up in production codebases at places like Uber and Cloudflare, but they're not complicated — they just require knowing which tool to reach for.
---
## The problem
angela needs to query PyPI for every dependency in your `pyproject.toml`. A typical project has 20-50 dependencies. Making those requests sequentially would take 10-25 seconds. Making them all at once would hammer PyPI with 50 simultaneous connections.
angela needs to query PyPI for every dependency in your `pyproject.toml` or `requirements.txt`. A typical project has 20-50 dependencies. Doing those requests one at a time would take 10-25 seconds. Doing them all at once would hammer PyPI with 50 simultaneous connections.
The solution: **bounded concurrency** — run up to N requests in parallel, queuing the rest.
The answer is **bounded concurrency** — run up to N requests in parallel, queue the rest.
---
## Pattern 1: errgroup.SetLimit
## errgroup.SetLimit
The `golang.org/x/sync/errgroup` package provides the cleanest API for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`:
The `golang.org/x/sync/errgroup` package is the go-to for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`:
```go
g, ctx := errgroup.WithContext(ctx)
@ -35,18 +35,18 @@ for _, name := range names {
_ = g.Wait()
```
Key decisions:
A few things to notice:
1. **`SetLimit(10)`** — caps concurrent HTTP requests. PyPI recommends 5-10.
2. **Always returns nil** — individual package failures are collected in results, not propagated. One failed package shouldn't cancel the others.
3. **Mutex protects shared slice**`results` is appended to from multiple goroutines.
- **`SetLimit(10)`** caps concurrent HTTP requests. PyPI recommends 5-10.
- **Always returns nil** — individual failures get collected in results, not propagated. One package failing shouldn't kill the others.
- **Mutex on the shared slice**`results` gets appended to from multiple goroutines, so it needs a lock.
### Why not channels?
A channel-based worker pool would work, but errgroup.SetLimit does the same thing with less code:
Channels would work, but errgroup gets you the same thing with half the code:
```go
// Channel approach: ~30 lines of setup
// Channel-based worker pool: ~30 lines
jobs := make(chan string, len(names))
results := make(chan FetchResult, len(names))
for range maxWorkers {
@ -57,9 +57,9 @@ for range maxWorkers {
}
}()
}
// ... send jobs, collect results ...
// ... send jobs, collect results, close channels ...
// errgroup approach: ~15 lines
// errgroup: ~15 lines
g.SetLimit(maxWorkers)
for _, name := range names {
g.Go(func() error { /* ... */ })
@ -67,13 +67,13 @@ for _, name := range names {
g.Wait()
```
The errgroup version is shorter, handles context cancellation automatically, and has no channel lifecycle to manage.
errgroup handles context cancellation for free and there's no channel lifecycle to think about.
---
## Pattern 2: Panic recovery in goroutines
## Panic recovery
Every goroutine angela launches includes panic recovery:
Every goroutine angela launches wraps itself in a recover:
```go
g.Go(func() (err error) {
@ -89,32 +89,33 @@ g.Go(func() (err error) {
})
```
**Why this matters**: an unrecovered panic in a goroutine kills the entire process. In a CLI tool, that means the user sees a stack trace instead of a helpful error message. The `defer recover()` converts panics into errors that flow through the normal error path.
An unrecovered panic in a goroutine kills the entire process. For a CLI tool, that means the user gets a stack trace dump instead of an actual error message. The `defer recover()` turns panics into errors that flow through the normal path.
The named return `(err error)` is essential — it lets the deferred function assign the recovered panic as the return value.
One subtle thing — the named return `(err error)` is what makes this work. Without it, the deferred function has no way to set the return value.
---
## Pattern 3: Context cancellation
## Context cancellation
Every HTTP request in angela uses context:
Every HTTP request uses context:
```go
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
```
This means:
- If the user presses Ctrl+C, pending requests are cancelled
- If `errgroup.WithContext` detects an error, remaining work stops
This does a lot of work for you:
- Ctrl+C cancels pending requests
- If errgroup's context gets cancelled, remaining work stops
- HTTP timeouts are enforced at the transport level
The context flows from `cobra.Command.Context()` through `runUpdate()` through `FetchAllVersions()` through `FetchVersions()` to the actual HTTP call. This is the standard Go pattern: context as the first parameter, threaded through the entire call chain.
The context flows from `cobra.Command.Context()` `runUpdate()``FetchAllVersions()``FetchVersions()` → the HTTP call itself. Standard Go pattern — context as the first parameter, threaded through everything.
---
## Pattern 4: Retry with exponential backoff
## Retry with exponential backoff
`internal/pypi/client.go` implements retry for transient failures:
`internal/pypi/client.go` retries transient failures:
```go
for attempt := range maxRetries {
@ -142,18 +143,13 @@ for attempt := range maxRetries {
}
```
Design choices:
1. **Exponential backoff**`1<<shift` doubles the delay each attempt (500ms, 1s, 2s)
2. **Context-aware wait** — the `select` respects cancellation even during the delay
3. **Only retry server errors** — 5xx is retryable, 4xx is not
4. **Max 3 attempts** — enough to survive a transient blip, not enough to block the user
The `1<<shift` doubles the delay each attempt — 500ms, 1s, 2s. The `select` means cancellation is still respected while waiting between retries. Only server errors (5xx) get retried; client errors (4xx) don't because retrying a 404 won't make the package appear. Three attempts is enough to ride out a transient blip without making the user wait forever.
---
## Pattern 5: Mutex vs channel for result collection
## Mutex vs channel
angela uses `sync.Mutex` to protect the results slice, not a channel:
angela uses `sync.Mutex` to protect the results slice:
```go
var mu sync.Mutex
@ -165,7 +161,7 @@ results = append(results, result)
mu.Unlock()
```
**When to use which:**
Quick rule of thumb:
| Use Mutex | Use Channel |
|-----------|-------------|
@ -173,18 +169,12 @@ mu.Unlock()
| Simple append/read operations | Producer-consumer pipelines |
| Order doesn't matter | Sequential processing needed |
For angela's case — appending results from concurrent workers — a mutex is simpler and allocates less.
For appending results where order doesn't matter, a mutex is simpler and cheaper.
---
## What to learn from this
## The bigger picture
1. **errgroup.SetLimit is the standard** — it replaced raw `go func()` + `sync.WaitGroup` + semaphore channels. Use it for any bounded concurrent work.
All of these patterns boil down to the same idea: **know when your goroutine exits**. Every goroutine angela spawns has a clear owner (the errgroup), a clear exit condition (the function returns), and panic protection. That's how you avoid leaks.
2. **Always recover panics in goroutines** — an unrecovered panic kills the process. Production code wraps every `g.Go` callback with `defer recover()`.
3. **Thread context through everything** — from CLI command to HTTP request, context enables cancellation and timeouts at every level.
4. **Choose the right concurrency primitive** — mutex for shared state, channels for data flow, errgroup for work coordination. Don't use channels when a mutex is clearer.
5. **`go test -race` is mandatory** — Go's race detector catches data races that code review misses. Run it in CI, always.
`go test -race` catches most of what code review misses. Run it in CI, always.

View File

@ -1,12 +1,12 @@
# PEP 440: Python Version Parsing from Scratch
This document explains how angela parses Python version strings — one of the hardest parts of the project. If you're building tools that interact with the Python ecosystem, understanding PEP 440 is non-negotiable.
Parsing Python version strings was one of the harder parts of building angela. If you're working on anything that touches the Python ecosystem, PEP 440 is worth understanding — it's weirder than you'd expect.
---
## The problem
Python versions are **not** semantic versioning. They follow [PEP 440](https://peps.python.org/pep-0440/), which allows components that semver doesn't:
Python versions are **not** semantic versioning. They follow [PEP 440](https://peps.python.org/pep-0440/), which has components semver doesn't:
```
[epoch!] release [pre-release] [.postN] [.devN] [+local]
@ -22,26 +22,26 @@ Real examples from PyPI:
| `1.0b2` | Beta pre-release |
| `1.0rc1` | Release candidate |
| `1.0.dev3` | Development snapshot |
| `1.0.post1` | Post-release (stable — fixes to docs or metadata) |
| `1.0+ubuntu1` | Local version label (never on PyPI) |
| `1.0.post1` | Post-release (stable — just a docs or metadata fix) |
| `1.0+ubuntu1` | Local version label (never shows up on PyPI) |
---
## Why this matters for dependency tools
## Why you can't just string-compare
If you naively compare version strings, `1.0a1` looks "newer" than `1.0` because `a` > `` in ASCII. But in Python's world:
If you naively compare version strings, `1.0a1` looks "newer" than `1.0` because `a` > empty string in ASCII. But Python's actual ordering is:
```
1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1
```
A tool that doesn't understand this will upgrade users to **unstable pre-releases**. That's why angela implements the full PEP 440 parser.
A tool that gets this wrong will upgrade users to unstable pre-releases. That's why angela implements the full spec.
---
## How angela's parser works
## How the parser works
The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to extract all components in one pass:
The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to pull out all the components in one pass:
```
(?i)^v?
@ -53,7 +53,7 @@ The parser lives in `internal/pypi/version.go`. It uses a single compiled regex
(?:\+([a-z0-9]...))?$ # local version
```
Each named section maps to a field in the `Version` struct:
Each section maps to a field in the `Version` struct:
```go
type Version struct {
@ -68,13 +68,13 @@ type Version struct {
}
```
The sentinel value `-1` for Post and Dev is critical — it distinguishes "not present" from "present with value 0". Both `1.0.post0` and `1.0.post` are valid (implicit zero), but they're different from `1.0` (no post-release at all).
The `-1` sentinel for Post and Dev is important — it's how you tell "not present" apart from "present with value 0". Both `1.0.post0` and `1.0.post` are valid PEP 440 (implicit zero), but they're different from `1.0` which has no post-release at all.
---
## Normalization edge cases
## Normalization
PEP 440 allows multiple spellings that all mean the same thing:
PEP 440 allows a bunch of different spellings that all mean the same thing:
| Input | Normalized |
|-------|-----------|
@ -86,13 +86,13 @@ PEP 440 allows multiple spellings that all mean the same thing:
| `1.0a` | `1.0a0` (implicit zero) |
| `v1.0` | `1.0` (strip leading v) |
The parser handles all of these by normalizing during extraction.
The parser normalizes all of these during extraction, so the rest of the codebase never has to think about variant spellings.
---
## Version comparison: the tuple trick
## Version comparison
Python's `packaging` library compares versions by converting them into tuples that sort naturally. angela uses the same idea:
Python's `packaging` library compares versions by converting them to tuples that sort naturally. angela does the same thing:
```go
func (v Version) Compare(other Version) int {
@ -104,7 +104,7 @@ func (v Version) Compare(other Version) int {
}
```
The key insight is the sentinel values for sorting:
The trick is in the sentinel values:
| Component state | Sort key |
|----------------|----------|
@ -114,13 +114,13 @@ The key insight is the sentinel values for sorting:
| Pre-release `rc1` | `2, 1` — rc rank 2 |
| Final release (no pre) | `MaxInt, MaxInt` — sorts after all pre-releases |
This produces the correct PEP 440 ordering without any special-case branching in the comparison loop.
Using `math.MinInt` and `math.MaxInt` means the comparison function is just a sequence of `cmp.Compare` calls — no special-case branching needed.
---
## Stability detection
A version is **stable** if it has no pre-release tag AND no dev tag:
A version is **stable** if it has no pre-release tag and no dev tag:
```go
func (v Version) IsStable() bool {
@ -128,16 +128,14 @@ func (v Version) IsStable() bool {
}
```
Post-releases are stable — `1.0.post1` is a documentation fix or metadata correction, not an unstable build.
Post-releases count as stable — `1.0.post1` is a docs fix or metadata correction, not an unstable build.
---
## What to learn from this
## Takeaways
1. **Regex can be the right tool** — a single compiled regex handles all PEP 440 forms in one pass. The alternative (hand-written state machine) would be 3x the code for no real benefit.
A single compiled regex handles every PEP 440 form in one pass. The alternative would be a hand-written state machine with 3x the code and no real upside.
2. **Sentinel values simplify comparison** — using `math.MinInt` and `math.MaxInt` lets the comparison function be a clean sequence of `cmp.Compare` calls without branches.
Normalizing at parse time (alpha→a, preview→rc) keeps things clean — nothing downstream ever has to handle variant spellings.
3. **Test-driven development** — the version parser has 67 test cases across 10 functions. When implementing something spec-driven like PEP 440, write the test cases first from the spec, then make them pass.
4. **Normalization at parse time** — by normalizing spellings during parsing (alpha→a, preview→rc), the rest of the codebase never has to think about variant forms.
The version parser currently has 90+ test cases across 10 functions. When you're implementing something spec-driven like this, writing the test cases from the spec first and then making them pass is the way to go.

View File

@ -1,6 +1,6 @@
# Preserving TOML Comments: Why Regex Surgery is the Right Call
# Preserving Comments in Dependency Files
When angela updates version specifiers in `pyproject.toml`, it must preserve every comment, blank line, and formatting choice the developer made. This is harder than it sounds. This document explains the approach and why alternatives don't work.
When angela updates version specifiers, it can't blow away the developer's comments, blank lines, and formatting. This turns out to be a surprisingly annoying problem. Here's how angela handles it for both `pyproject.toml` and `requirements.txt`.
---
@ -36,13 +36,13 @@ dependencies = [
]
```
Only the version numbers changed. Every comment, every space, every quote style — preserved.
Only the version numbers changed. Every comment, every space, every quote style — still there. Same goes for `requirements.txt` — inline comments, section headers, all of it should survive.
---
## Why TOML libraries can't do this
Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling TOML into Go structs, then marshaling back:
Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling into Go structs, then marshaling back:
```go
var proj PyProject
@ -51,32 +51,32 @@ proj.Dependencies[0] = "requests>=2.32.3"
output, _ := toml.Marshal(proj) // comments gone, formatting changed
```
Go's reflection system has no mechanism to store comment metadata on struct fields. The unmarshal/marshal round-trip fundamentally **destroys comments**. This isn't a bug — it's an architectural limitation of how Go TOML libraries work.
Go's reflection system has no way to store comment metadata on struct fields. The unmarshal/marshal round-trip just destroys them. This isn't a library bug — it's a fundamental limitation of how Go struct serialization works.
pelletier/go-toml v2 has an `unstable` package with AST access that preserves comments in the parse tree, but it provides no serialization — you'd have to write your own TOML emitter from the AST.
pelletier/go-toml v2 does have an `unstable` package that exposes AST access with comments preserved in the parse tree, but there's no serializer — you'd have to write your own TOML emitter from scratch.
---
## The regex surgery approach
## Regex surgery
angela's solution: don't parse and re-serialize. Instead, treat the file as a byte buffer and surgically replace only the version specifier substring.
angela's approach: don't parse and re-serialize. Treat the file as a byte buffer and surgically replace only the version specifier.
The implementation in `internal/pyproject/writer.go`:
The implementation for pyproject.toml lives in `internal/pyproject/writer.go`:
1. **Build a regex** that matches the full dependency string including quotes:
1. Build a regex that matches the full dependency string inside quotes:
```
"requests>=2.28.0"
```
2. **Capture groups** isolate the parts we need:
2. Capture groups isolate the pieces:
- Group 1: package name (`requests`)
- Group 2: extras (`[async]` or empty)
- Group 3: version specifier (`>=2.28.0`)
- Group 4: markers (`;python_version>='3.8'` or empty)
3. **Replace only group 3** (the version spec) while preserving everything else.
3. Replace only group 3, keep everything else.
4. **Validate before and after** — feed the result through go-toml v2's unmarshaler to catch syntax errors.
4. Validate before and after — feed the result through go-toml v2's unmarshaler to make sure the edit didn't break the TOML syntax.
```go
func (u *Updater) UpdateDependency(pkg, newSpec string) error {
@ -90,7 +90,6 @@ func (u *Updater) UpdateDependency(pkg, newSpec string) error {
},
)
if found {
// Re-validate TOML syntax
var probe map[string]any
if err := toml.Unmarshal(u.content, &probe); err != nil {
return fmt.Errorf("update produced invalid TOML: %w", err)
@ -104,6 +103,21 @@ func (u *Updater) UpdateDependency(pkg, newSpec string) error {
---
## requirements.txt uses the same idea
The `requirements.txt` writer in `internal/requirements/writer.go` works the same way — regex-based surgery on raw bytes. The difference is simpler: no quotes to worry about, no TOML validation needed. Lines look like:
```
django>=3.2.0
requests==2.28.1 # HTTP client
```
The regex matches the package name (with PEP 503 normalization) and its version specifier, replaces the spec, and leaves everything else alone — including inline comments.
Both writers share the same atomic write pattern (temp file + rename) and the same PEP 503 name normalization approach. The pyproject writer just has extra complexity for quote styles and TOML validation.
---
## The tricky parts
### PEP 503 name normalization
@ -114,7 +128,7 @@ Package names on PyPI are case-insensitive and treat `-`, `_`, and `.` as equiva
Some_Package == some-package == some.package
```
The regex must match all variants. angela splits the normalized name on `-` and joins with `[-_.]?`:
The regex has to match all variants. angela splits the normalized name on `-` and joins with `[-_.]?`:
```go
parts := strings.Split(normalized, "-")
@ -124,17 +138,17 @@ for i, p := range parts {
namePattern := strings.Join(parts, `[-_.]?`)
```
This means the pattern for `some-package` becomes `some[-_.]?package`, matching `some_package`, `some.package`, and `somepackage`.
So the pattern for `some-package` becomes `some[-_.]?package`, which matches `some_package`, `some.package`, and `somepackage`.
### Go RE2 doesn't support backreferences
The TOML research suggested using `\1` to match the closing quote with the opening quote. Go's `regexp` package uses RE2, which doesn't support backreferences.
The TOML research suggested using `\1` to match closing quotes with the opening quote. Go's `regexp` package uses RE2, which doesn't do backreferences.
The fix: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character.
The workaround: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character.
### Atomic file writes
Even the write is careful — angela writes to a `.tmp` file first, then renames over the original. If the process is killed mid-write, the original file is untouched:
angela writes to a `.tmp` file first, then renames over the original. If the process dies mid-write, the original is untouched:
```go
func (u *Updater) WriteFile(path string) error {
@ -150,20 +164,12 @@ func (u *Updater) WriteFile(path string) error {
}
```
It's two syscalls, but the file is never in a half-written state.
---
## This is how production tools do it
Renovate (GitHub's automated dependency updater) and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize — they surgically modify. The reason is the same: no TOML/YAML/JSON library in any language perfectly round-trips formatting and comments.
Renovate and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize either — they do the same kind of surgical modification. No TOML, YAML, or JSON library in any language perfectly round-trips formatting and comments. Regex surgery sounds hacky, but it's the approach that actually works.
---
## What to learn from this
1. **Sometimes the "crude" approach is correct** — regex surgery sounds hacky, but it's the only way to preserve comments in TOML. The fancier approaches (AST manipulation, custom serializer) are more complex and still lose whitespace.
2. **Validate at boundaries** — angela validates TOML syntax before and after every edit. The regex does the surgery; go-toml v2 confirms the patient survived.
3. **Atomic writes prevent corruption** — write to temp + rename is the standard pattern for file updates in Unix. It's two syscalls, but it guarantees the file is never half-written.
4. **Know your regex engine** — Go uses RE2, which guarantees linear-time matching but doesn't support backreferences or lookaheads. Design patterns accordingly.
Go's RE2 engine guarantees linear-time matching, so even on large files with complex patterns there's no risk of catastrophic backtracking. Just keep in mind it doesn't support backreferences or lookaheads — design your patterns around that.

View File

@ -4,7 +4,7 @@ name = "sample-app"
version = "0.1.0"
requires-python = ">=3.10"
# Core runtime dependencies
# Core runtime dependencies (outdated)
dependencies = [
"requests>=2.28.0",
"django>=3.2,<4.0",
@ -14,7 +14,7 @@ dependencies = [
]
[project.optional-dependencies]
# Development and testing tools
# Development and testing tools (outdated)
dev = [
"pytest>=7.0.0",
"black>=23.0.0", # Code formatter