Merge pull request #49 from CarterPerez-dev/project/simple-vulnerability-scanner

Project/simple vulnerability scanner
This commit is contained in:
Carter Perez 2026-01-28 22:08:02 -05:00 committed by GitHub
commit a6921c9672
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 5866 additions and 0 deletions

View File

@ -0,0 +1,44 @@
# AngelaMos | 2026
# .gitignore
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
.meep
# Test
*.test
coverage.out
*.prof
trace.out
# Build
tmp/
# Cache
.ruff_cache/
__pycache__/
*.pyc
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Debug
__debug_bin*

View File

@ -0,0 +1,113 @@
# AngelaMos | 2026
# .golangci.yml
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- gosec
- bodyclose
- nilerr
- errorlint
- exhaustive
- gocritic
- funlen
- gocognit
- dupl
- goconst
- ineffassign
- unused
- unconvert
- unparam
- testifylint
- fatcontext
settings:
errcheck:
check-type-assertions: true
check-blank: true
funlen:
lines: 100
statements: 50
gocognit:
min-complexity: 20
govet:
enable-all: true
disable:
- fieldalignment
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: increment-decrement
- name: var-declaration
- name: package-comments
disabled: true
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
- name: empty-block
- name: superfluous-else
- name: unreachable-code
staticcheck:
checks:
- all
gosec:
excludes:
- G104
sloglint:
no-mixed-args: true
kv-only: true
context: all
issues:
max-same-issues: 50
exclude-dirs:
- vendor
- testdata
exclude-rules:
- path: _test\.go
linters:
- funlen
- dupl
- goconst
formatters:
enable:
- gci # Groups imports
- gofumpt # Whitespace
- golines # Vertical wrap
settings:
golines:
max-len: 80
reformat-tags: true
goimports:
local-prefixes:
- github.com/carterperez-dev/angela
gci:
sections:
- standard
- default
- prefix(github.com/carterperez-dev)
custom-order: true
gofumpt:
extra-rules: true

View File

@ -0,0 +1,134 @@
# =============================================================================
# AngelaMos | 2026
# Justfile
# =============================================================================
# angela — Python dependency updater and vulnerability scanner
# =============================================================================
set export
set shell := ["bash", "-uc"]
project := file_name(justfile_directory())
version := `git describe --tags --always 2>/dev/null || echo "dev"`
# =============================================================================
# Default
# =============================================================================
default:
@just --list --unsorted
# =============================================================================
# Linting and Formatting
# =============================================================================
[group('lint')]
lint *ARGS:
golangci-lint run --timeout=5m {{ARGS}}
[group('lint')]
lint-fix:
golangci-lint run --timeout=5m --fix
[group('lint')]
fmt:
gofumpt -w .
golines -w --max-len=80 .
[group('lint')]
tidy:
go mod tidy
[group('lint')]
vet:
go vet ./...
# =============================================================================
# Testing
# =============================================================================
[group('test')]
test *ARGS:
go test -race ./... {{ARGS}}
[group('test')]
test-v *ARGS:
go test -race -v ./... {{ARGS}}
[group('test')]
cover:
go test -race -cover ./...
# =============================================================================
# CI / Quality
# =============================================================================
[group('ci')]
ci: lint test
@echo "All checks passed."
[group('ci')]
check: lint vet
# =============================================================================
# Development
# =============================================================================
[group('dev')]
run *ARGS:
go run ./cmd/angela {{ARGS}}
[group('dev')]
dev-check:
go run ./cmd/angela check --file testdata/pyproject.toml
[group('dev')]
dev-update:
go run ./cmd/angela update --file testdata/pyproject.toml
[group('dev')]
dev-scan:
go run ./cmd/angela scan --file testdata/pyproject.toml
[group('dev')]
dev-full:
go run ./cmd/angela update --vulns --file testdata/pyproject.toml
# =============================================================================
# Build (Production)
# =============================================================================
[group('prod')]
build:
go build -ldflags="-s -w" -o bin/angela ./cmd/angela
@echo "Built: bin/angela ($(du -h bin/angela | cut -f1))"
[group('prod')]
build-debug:
go build -o bin/angela ./cmd/angela
[group('prod')]
install:
go install ./cmd/angela
# =============================================================================
# Utilities
# =============================================================================
[group('util')]
info:
@echo "Project: {{project}}"
@echo "Version: {{version}}"
@echo "Go: $(go version | cut -d' ' -f3)"
@echo "OS: {{os()}} ({{arch()}})"
@echo "Module: $(head -1 go.mod | cut -d' ' -f2)"
[group('util')]
clean:
-rm -rf bin/
@echo "Cleaned build artifacts."
[group('util')]
reset-cache:
rm -rf ~/.angela/cache/
@echo "Cache cleared."

View File

@ -0,0 +1,298 @@
# angela
A fast Python dependency updater and vulnerability scanner, written in Go.
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
- **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.
## Installation
```bash
go install github.com/CarterPerez-dev/angela/cmd/angela@latest
```
Or build from source:
```bash
git clone https://github.com/CarterPerez-dev/angela.git
cd angela
go build -o angela ./cmd/angela
```
## Quick start
```bash
# Initialize a new pyproject.toml
angela init
# Update all dependencies to latest stable versions
angela update
# Preview updates without writing changes
angela check
# Scan for known vulnerabilities
angela scan
# Update + scan for vulnerabilities in one pass
angela update --vulns
```
## Commands
### `angela init`
Create a new `pyproject.toml` with angela configuration in the current directory.
```console
$ angela init
Created pyproject.toml
```
### `angela update`
Update all dependencies in `pyproject.toml` to their latest stable versions.
```console
$ angela update
Scanning 9 dependencies...
Updates available:
django 3.2.0 -> 6.0.1 (major)
requests 2.28.0 -> 2.32.5 (minor)
flask 2.0.0 -> 3.1.0 (major)
click 8.0.0 -> 8.1.8 (patch)
Updated pyproject.toml
9 packages checked
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 dependency file
Read pyproject.toml or requirements.txt
Extract package names and version specifiers
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)
Filter to stable releases by default
Classify changes as major, minor, or patch
4. Scan for vulnerabilities (optional)
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 all comments and formatting
Atomic write via temp file + rename
```
## Project structure
```
cmd/angela/main.go Entry point
internal/
cli/
update.go Commands and orchestration
output.go Terminal formatting
config/
config.go Configuration loading
pypi/
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
pyproject/
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
testdata/ Test fixtures
learn/ Educational deep dives
```
## Development
Requires Go 1.24+ and [just](https://github.com/casey/just).
```bash
just test # Run all tests with race detector
just lint # Run golangci-lint
just build # Build binary to bin/angela
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 [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) — 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. See the [`learn/`](learn/) directory for deep dives into the techniques used.
## License
MIT

View File

@ -0,0 +1,10 @@
// ©AngelaMos | 2026
// main.go
package main
import "github.com/CarterPerez-dev/angela/internal/cli"
func main() {
cli.Execute()
}

View File

@ -0,0 +1,18 @@
module github.com/CarterPerez-dev/angela
go 1.24.4
require (
github.com/fatih/color v1.18.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/spf13/cobra v1.10.2
golang.org/x/sync v0.19.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // 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.10 // indirect
golang.org/x/sys v0.40.0 // indirect
)

View File

@ -0,0 +1,24 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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.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=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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

@ -0,0 +1,529 @@
// ©AngelaMos | 2026
// output.go
package cli
import (
"fmt"
"sort"
"strings"
"github.com/CarterPerez-dev/angela/internal/pypi"
"github.com/CarterPerez-dev/angela/internal/ui"
"github.com/CarterPerez-dev/angela/pkg/types"
)
func printDivider() {
fmt.Printf("\n %s\n", ui.HiBlack(ui.HRule(44)))
}
func printSectionHeader(
symbol, title string,
symbolColor, titleColor func(a ...any) string,
) {
fmt.Printf(
"\n %s %s\n",
symbolColor(symbol),
titleColor(title),
)
}
func PrintUpdates(updates []types.UpdateResult) {
var actionable []types.UpdateResult
for _, u := range updates {
if !u.Skipped {
actionable = append(actionable, u)
}
}
if len(actionable) == 0 {
printDivider()
fmt.Printf(
"\n %s %s\n",
ui.HiGreen(ui.Check),
ui.HiBlackItalic(
"All dependencies are up to date.",
),
)
return
}
printDivider()
printSectionHeader(
ui.Diamond, "Updates available",
ui.Cyan, ui.Red,
)
nameWidth := 0
oldWidth := 0
for _, u := range actionable {
if len(u.Name) > nameWidth {
nameWidth = len(u.Name)
}
if len(u.OldVer) > oldWidth {
oldWidth = len(u.OldVer)
}
}
for _, u := range actionable {
changeColor := changeColorFn(u.Change)
fmt.Printf(
" %-*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),
ui.HiBlack(ui.ArrowRight),
changeColor(u.Change),
)
}
}
func PrintSkipped(updates []types.UpdateResult) {
var skipped []types.UpdateResult
for _, u := range updates {
if u.Skipped {
skipped = append(skipped, u)
}
}
if len(skipped) == 0 {
return
}
printDivider()
printSectionHeader(
ui.ArrowRight, "Skipped",
ui.Yellow, ui.Red,
)
for _, u := range skipped {
fmt.Printf(
" %s %s\n",
ui.HiBlue(u.Name),
ui.HiBlackItalic(u.Reason),
)
}
}
func PrintVulnerabilities(
vulns map[string][]types.Vulnerability,
) {
if len(vulns) == 0 {
return
}
total := 0
for _, vl := range vulns {
total += len(vl)
}
printDivider()
fmt.Printf(
"\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)
if verbose {
printVulnsVerbose(pkgs, vulns)
} else {
printVulnsCompact(pkgs, vulns)
}
}
const maxVulnsPerPackage = 5
func printVulnsCompact(
pkgs []string,
vulns map[string][]types.Vulnerability,
) {
for _, pkg := range pkgs {
vlist := vulns[pkg]
sortVulnsBySeverity(vlist)
fmt.Printf(
"\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))
for i := range limit {
printVulnLine(vlist[i])
}
if len(vlist) > maxVulnsPerPackage {
fmt.Printf(
" %s\n",
ui.HiBlackItalic(fmt.Sprintf(
"...and %d more",
len(vlist)-maxVulnsPerPackage,
)),
)
}
}
fmt.Printf(
"\n %s\n",
ui.HiBlackItalic(
"Run angela scan -v for full details.",
),
)
}
func printVulnsVerbose(
pkgs []string,
vulns map[string][]types.Vulnerability,
) {
for _, pkg := range pkgs {
vlist := vulns[pkg]
sortVulnsBySeverity(vlist)
fmt.Printf(
"\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 {
id := preferredID(v)
sevColor := severityColorFn(v.Severity)
fmt.Printf(
"\n %s %s\n",
sevColor(
padRight(strings.ToUpper(v.Severity), 8),
),
ui.Magenta(id),
)
fmt.Printf(
" %s\n",
ui.HiBlack(truncate(v.Summary, 72)),
)
if v.FixedIn != "" {
fmt.Printf(
" %s %s\n",
ui.HiBlack("Fixed in:"),
ui.Green(v.FixedIn),
)
}
if v.Link != "" {
fmt.Printf(
" %s\n",
ui.BlueItalic(v.Link),
)
}
}
}
fmt.Println()
}
func printVulnLine(v types.Vulnerability) {
id := preferredID(v)
sevColor := severityColorFn(v.Severity)
fixedStr := ""
if v.FixedIn != "" {
fixedStr = ui.HiBlack("Fixed: ") +
ui.Green(v.FixedIn)
}
fmt.Printf(
" %s %s %s %s\n",
sevColor(
padRight(strings.ToUpper(v.Severity), 8),
),
ui.Blue(padRight(id, 16)),
ui.HiBlack(truncate(v.Summary, 38)),
fixedStr,
)
}
func sortedVulnPackages(
vulns map[string][]types.Vulnerability,
) []string {
pkgs := make([]string, 0, len(vulns))
for k := range vulns {
pkgs = append(pkgs, k)
}
sort.Slice(pkgs, func(i, j int) bool {
li := len(vulns[pkgs[i]])
lj := len(vulns[pkgs[j]])
if li != lj {
return li > lj
}
return pkgs[i] < pkgs[j]
})
return pkgs
}
func sortVulnsBySeverity(
vulns []types.Vulnerability,
) {
sort.Slice(vulns, func(i, j int) bool {
ri := severityRank(vulns[i].Severity)
rj := severityRank(vulns[j].Severity)
if ri != rj {
return ri < rj
}
return vulns[i].ID < vulns[j].ID
})
}
func severityRank(sev string) int {
switch strings.ToUpper(sev) {
case "CRITICAL":
return 0
case "HIGH":
return 1
case "MODERATE":
return 2
case "LOW":
return 3
default:
return 4
}
}
func severityBreakdown(
vulns []types.Vulnerability,
) string {
counts := make(map[string]int)
for _, v := range vulns {
counts[strings.ToUpper(v.Severity)]++
}
order := []string{
"CRITICAL", "HIGH", "MODERATE", "LOW", "UNKNOWN",
}
var parts []string
for _, sev := range order {
if n := counts[sev]; n > 0 {
sevColor := severityColorFn(sev)
parts = append(parts, sevColor(fmt.Sprintf(
"%d %s", n, strings.ToLower(sev),
)))
}
}
return strings.Join(parts, ui.HiBlack(", "))
}
func preferredID(v types.Vulnerability) string {
for _, alias := range v.Aliases {
if strings.HasPrefix(alias, "CVE-") {
return alias
}
}
return v.ID
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func filterIgnoredVulns(
vulns map[string][]types.Vulnerability,
ignoreIDs []string,
) map[string][]types.Vulnerability {
if len(ignoreIDs) == 0 {
return vulns
}
ignored := make(map[string]bool, len(ignoreIDs))
for _, id := range ignoreIDs {
ignored[id] = true
}
filtered := make(map[string][]types.Vulnerability)
for pkg, vlist := range vulns {
var kept []types.Vulnerability
for _, v := range vlist {
if isVulnIgnored(v, ignored) {
continue
}
kept = append(kept, v)
}
if len(kept) > 0 {
filtered[pkg] = kept
}
}
return filtered
}
func isVulnIgnored(
v types.Vulnerability, ignored map[string]bool,
) bool {
if ignored[v.ID] {
return true
}
for _, alias := range v.Aliases {
if ignored[alias] {
return true
}
}
return false
}
func filterVulnsBySeverity(
vulns map[string][]types.Vulnerability,
minSev string,
) map[string][]types.Vulnerability {
threshold := severityRank(minSev)
filtered := make(map[string][]types.Vulnerability)
for pkg, vlist := range vulns {
var kept []types.Vulnerability
for _, v := range vlist {
if severityRank(v.Severity) <= threshold {
kept = append(kept, v)
}
}
if len(kept) > 0 {
filtered[pkg] = kept
}
}
return filtered
}
func PrintSummary(
result types.ScanResult, updated bool,
) {
printDivider()
printSectionHeader(
ui.Star, "Summary",
ui.Green, ui.RedItalic,
)
if updated {
fmt.Printf(
" %s %s\n",
ui.HiGreen(ui.Check),
ui.HiGreen("Updated pyproject.toml"),
)
}
fmt.Printf(
" %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 %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 %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 %s\n",
ui.HiGreen(ui.Check),
ui.HiGreen("No vulnerabilities found"),
)
}
}
fmt.Printf(
" %s %s\n\n",
ui.HiCyan(ui.Timer),
ui.HiBlueItalic(
result.Duration.Round(1e6).String(),
),
)
}
func PrintError(msg string) {
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 {
switch kind {
case pypi.Major.String():
return ui.Red
case pypi.Minor.String():
return ui.HiYellow
default:
return ui.Green
}
}
func severityColorFn(
sev string,
) func(a ...any) string {
switch strings.ToUpper(sev) {
case "CRITICAL":
return ui.Red
case "HIGH":
return ui.RedBold
case "MODERATE":
return ui.HiYellow
case "LOW":
return ui.Cyan
default:
return ui.HiBlack
}
}
func padRight(s string, width int) string {
if len(s) >= width {
return s
}
return s + strings.Repeat(" ", width-len(s))
}
func pluralize(word string, n int) string {
if n == 1 {
return word
}
if strings.HasSuffix(word, "y") {
return word[:len(word)-1] + "ies"
}
return word + "s"
}

View File

@ -0,0 +1,614 @@
// ©AngelaMos | 2026
// update.go
package cli
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/CarterPerez-dev/angela/internal/config"
"github.com/CarterPerez-dev/angela/internal/osv"
"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"
)
var (
verbose bool
minSeverity string
)
type updateFlags struct {
file string
safe bool
vulns bool
includePrerelease bool
}
func defaultCacheDir() string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".angela", "cache")
}
// Execute sets up the CLI and runs the root command
func Execute() {
root := &cobra.Command{
Use: "angela",
Short: "Python dependency updater and vulnerability scanner",
Long: `angela scans your pyproject.toml, updates dependencies to their
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",
)
root.PersistentFlags().StringVar(
&minSeverity, "min-severity", "",
"minimum severity to report (critical, high, moderate, low)",
)
root.AddCommand(
newInitCmd(),
newUpdateCmd(),
newCheckCmd(),
newScanCmd(),
newCacheCmd(),
)
if err := root.Execute(); err != nil {
PrintError(err.Error())
os.Exit(1)
}
}
const pyprojectTemplate = `[project]
name = "%s"
version = "0.1.0"
description = ""
requires-python = ">=3.13"
dependencies = []
[tool.angela]
# Minimum severity to report (critical, high, moderate, low)
# min-severity = "moderate"
# Dependencies to skip during updates
# ignore = []
# Vulnerability IDs to suppress (accepted risk)
# ignore-vulns = []
`
func newInitCmd() *cobra.Command {
return &cobra.Command{
Use: "init",
Short: "Create a new pyproject.toml with angela configuration",
RunE: func(_ *cobra.Command, _ []string) error {
return runInit()
},
}
}
func runInit() error {
if _, err := os.Stat("pyproject.toml"); err == nil {
return fmt.Errorf("pyproject.toml already exists")
}
dir, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
name := filepath.Base(dir)
content := fmt.Sprintf(pyprojectTemplate, name)
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 %s\n\n",
ui.HiGreen(ui.Check),
ui.HiGreen("Created pyproject.toml"),
)
return nil
}
func newUpdateCmd() *cobra.Command {
f := &updateFlags{}
cmd := &cobra.Command{
Use: "update [path]",
Aliases: []string{"u"},
Short: "Update dependencies to latest stable versions",
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 dependency file",
)
cmd.Flags().BoolVar(
&f.safe, "safe", false,
"skip major version bumps",
)
cmd.Flags().BoolVar(
&f.vulns, "vulns", false,
"also scan for vulnerabilities",
)
cmd.Flags().BoolVar(
&f.includePrerelease, "include-prerelease", false,
"include pre-release versions",
)
return cmd
}
func newCheckCmd() *cobra.Command {
f := &updateFlags{}
cmd := &cobra.Command{
Use: "check [path]",
Aliases: []string{"c"},
Short: "Show available updates without modifying files",
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 dependency file",
)
cmd.Flags().BoolVar(
&f.safe, "safe", false,
"skip major version bumps",
)
cmd.Flags().BoolVar(
&f.vulns, "vulns", false,
"also scan for vulnerabilities",
)
cmd.Flags().BoolVar(
&f.includePrerelease, "include-prerelease", false,
"include pre-release versions",
)
return cmd
}
func newScanCmd() *cobra.Command {
var file string
cmd := &cobra.Command{
Use: "scan [path]",
Aliases: []string{"s"},
Short: "Scan dependencies for known vulnerabilities",
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 dependency file",
)
return cmd
}
func newCacheCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "Manage the local response cache",
}
cmd.AddCommand(&cobra.Command{
Use: "clear",
Short: "Remove all cached PyPI responses",
RunE: func(_ *cobra.Command, _ []string) error {
client, err := pypi.NewClient(defaultCacheDir())
if err != nil {
return err
}
if err := client.ClearCache(); err != nil {
return err
}
fmt.Printf(
" %s %s\n",
ui.HiGreen(ui.Check),
ui.HiGreen("Cache cleared."),
)
return nil
},
})
return cmd
}
func runUpdate(
ctx context.Context,
f *updateFlags,
dryRun bool,
) error {
start := time.Now()
cfg := config.Load(f.file)
deps, err := parseDeps(f.file)
if err != nil {
return err
}
spin := ui.NewSpinner(fmt.Sprintf(
"Scanning %d dependencies...", len(deps),
))
spin.Start()
client, err := pypi.NewClient(defaultCacheDir())
if err != nil {
spin.Stop()
return err
}
names := make([]string, len(deps))
for i, d := range deps {
names[i] = d.Name
}
fetched := client.FetchAllVersions(ctx, names)
versionMap := make(map[string][]string, len(fetched))
for _, r := range fetched {
if r.Err == nil {
versionMap[r.Name] = r.Versions
}
}
updates, updateSpecs := resolveUpdates(
deps, versionMap, f.safe,
f.includePrerelease, cfg.Ignore,
)
sortUpdates(updates)
var vulns map[string][]types.Vulnerability
var scanErr error
if f.vulns {
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 {
return fmt.Errorf("write updates: %w", err)
}
}
minSev := resolveMinSeverity(cfg.MinSeverity)
if vulns != nil {
vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns)
vulns = filterVulnsBySeverity(vulns, minSev)
PrintVulnerabilities(vulns)
}
PrintUpdates(updates)
PrintSkipped(updates)
totalVulns := 0
for _, vl := range vulns {
totalVulns += len(vl)
}
PrintSummary(types.ScanResult{
Updates: updates,
Vulnerabilities: vulns,
TotalPackages: len(deps),
TotalUpdated: len(updateSpecs),
TotalVulns: totalVulns,
VulnsScanned: f.vulns,
Duration: time.Since(start),
}, !dryRun && len(updateSpecs) > 0)
return nil
}
func runScan(ctx context.Context, file string) error {
start := time.Now()
cfg := config.Load(file)
deps, err := parseDeps(file)
if err != nil {
return err
}
spin := ui.NewSpinner(fmt.Sprintf(
"Scanning %d dependencies for vulnerabilities...",
len(deps),
))
spin.Start()
minSev := resolveMinSeverity(cfg.MinSeverity)
vulns, scanErr := scanForVulns(ctx, deps)
spin.Stop()
if scanErr != nil {
PrintError(scanErr.Error())
}
vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns)
vulns = filterVulnsBySeverity(vulns, minSev)
PrintVulnerabilities(vulns)
totalVulns := 0
for _, vl := range vulns {
totalVulns += len(vl)
}
PrintSummary(types.ScanResult{
TotalPackages: len(deps),
TotalVulns: totalVulns,
VulnsScanned: true,
Duration: time.Since(start),
}, false)
return nil
}
func resolveUpdates(
deps []types.Dependency,
versionMap map[string][]string,
safe bool,
includePrerelease bool,
ignoreDeps []string,
) ([]types.UpdateResult, map[string]string) {
var results []types.UpdateResult
specs := make(map[string]string)
ignoreSet := make(map[string]bool, len(ignoreDeps))
for _, name := range ignoreDeps {
ignoreSet[pypi.NormalizeName(name)] = true
}
for _, dep := range deps {
if ignoreSet[pypi.NormalizeName(dep.Name)] {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: "ignored in config",
})
continue
}
versions, ok := versionMap[dep.Name]
if !ok {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: "not found on PyPI",
})
continue
}
currentStr := pyproject.ExtractMinVersion(dep.Spec)
if currentStr == "" {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: "no version specifier",
})
continue
}
current, err := pypi.ParseVersion(currentStr)
if err != nil {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: "unparseable version",
})
continue
}
var latest pypi.Version
if includePrerelease {
latest, err = latestAny(versions)
} else {
latest, err = pypi.LatestStable(versions)
}
if err != nil {
results = append(results, types.UpdateResult{
Name: dep.Name,
Skipped: true,
Reason: err.Error(),
})
continue
}
if latest.Compare(current) <= 0 {
continue
}
change := pypi.ClassifyChange(current, latest)
if safe && change == pypi.Major {
results = append(results, types.UpdateResult{
Name: dep.Name,
OldVer: current.String(),
NewVer: latest.String(),
Change: change.String(),
Skipped: true,
Reason: "major bump (use --all to include)",
})
continue
}
newSpec := ">=" + latest.String()
results = append(results, types.UpdateResult{
Name: dep.Name,
OldVer: current.String(),
NewVer: latest.String(),
OldSpec: dep.Spec,
NewSpec: newSpec,
Change: change.String(),
})
specs[dep.Name] = newSpec
}
return results, specs
}
func scanForVulns(
ctx context.Context,
deps []types.Dependency,
) (map[string][]types.Vulnerability, error) {
var queries []osv.PackageQuery
for _, dep := range deps {
ver := pyproject.ExtractMinVersion(dep.Spec)
if ver == "" {
continue
}
queries = append(queries, osv.PackageQuery{
Name: dep.Name,
Version: ver,
})
}
if len(queries) == 0 {
return nil, nil
}
client := osv.NewClient()
vulns, err := client.ScanPackages(ctx, queries)
if err != nil {
return nil, fmt.Errorf(
"vulnerability scan: %w", err,
)
}
return vulns, nil
}
func latestAny(versions []string) (pypi.Version, error) {
var latest pypi.Version
var found bool
for _, raw := range versions {
v, err := pypi.ParseVersion(raw)
if err != nil {
continue
}
if !found || v.Compare(latest) > 0 {
latest = v
found = true
}
}
if !found {
return pypi.Version{}, fmt.Errorf("no versions found")
}
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")
}
func parseDeps(file string) ([]types.Dependency, error) {
if isRequirementsTxt(file) {
return requirements.ParseFile(file)
}
return pyproject.ParseFile(file)
}
func updateDepsFile(file string, specs map[string]string) error {
if isRequirementsTxt(file) {
return requirements.UpdateFile(file, specs)
}
return pyproject.UpdateFile(file, specs)
}
func resolveMinSeverity(configVal string) string {
if minSeverity != "" {
return minSeverity
}
if configVal != "" {
return configVal
}
return "low"
}
func sortUpdates(updates []types.UpdateResult) {
order := map[string]int{
pypi.Major.String(): 0,
pypi.Minor.String(): 1,
pypi.Patch.String(): 2,
}
sort.Slice(updates, func(i, j int) bool {
if updates[i].Skipped != updates[j].Skipped {
return !updates[i].Skipped
}
oi := order[updates[i].Change]
oj := order[updates[j].Change]
if oi != oj {
return oi < oj
}
return updates[i].Name < updates[j].Name
})
}

View File

@ -0,0 +1,80 @@
// ©AngelaMos | 2026
// config.go
package config
import (
"os"
"strings"
toml "github.com/pelletier/go-toml/v2"
)
// Config holds project-level angela settings loaded from .angela.toml
// or [tool.angela] in pyproject.toml
type Config struct {
MinSeverity string `toml:"min-severity"`
Ignore []string `toml:"ignore"`
IgnoreVulns []string `toml:"ignore-vulns"`
}
type pyprojectWrapper struct {
Tool struct {
Angela Config `toml:"angela"`
} `toml:"tool"`
}
// Load reads angela configuration using a cascading resolution order:
// .angela.toml in current directory, then [tool.angela] in pyproject.toml
func Load(pyprojectPath string) Config {
if cfg, err := loadFile(".angela.toml"); err == nil {
return cfg
}
if cfg, ok := loadFromPyproject(pyprojectPath); ok {
return cfg
}
return Config{}
}
func loadFile(path string) (Config, error) {
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return Config{}, err
}
var cfg Config
if err := toml.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
cfg.MinSeverity = strings.ToLower(
strings.TrimSpace(cfg.MinSeverity),
)
return cfg, nil
}
func loadFromPyproject(path string) (Config, bool) {
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return Config{}, false
}
var wrapper pyprojectWrapper
if err := toml.Unmarshal(data, &wrapper); err != nil {
return Config{}, false
}
cfg := wrapper.Tool.Angela
if cfg.MinSeverity == "" &&
len(cfg.Ignore) == 0 &&
len(cfg.IgnoreVulns) == 0 {
return Config{}, false
}
cfg.MinSeverity = strings.ToLower(
strings.TrimSpace(cfg.MinSeverity),
)
return cfg, true
}

View File

@ -0,0 +1,401 @@
// ©AngelaMos | 2026
// client.go
package osv
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/CarterPerez-dev/angela/pkg/types"
"golang.org/x/sync/errgroup"
)
const (
batchEndpoint = "https://api.osv.dev/v1/querybatch"
vulnEndpoint = "https://api.osv.dev/v1/vulns/"
userAgent = "angela/0.1.0 (https://github.com/CarterPerez-dev/angela)"
maxHydrate = 15
)
// Client queries the OSV.dev API for known vulnerabilities
type Client struct {
http *http.Client
}
// NewClient creates an OSV client with sensible defaults
func NewClient() *Client {
return &Client{
http: &http.Client{Timeout: 30 * time.Second},
}
}
// PackageQuery identifies a single package+version to check
type PackageQuery struct {
Name string
Version string
}
type batchRequest struct {
Queries []query `json:"queries"`
}
type query struct {
Package pkg `json:"package"`
Version string `json:"version"`
}
type pkg struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
}
type batchResponse struct {
Results []batchResult `json:"results"`
}
type batchResult struct {
Vulns []vulnRef `json:"vulns"`
}
type vulnRef struct {
ID string `json:"id"`
Modified string `json:"modified"`
}
type osvVuln struct {
ID string `json:"id"`
Summary string `json:"summary"`
Aliases []string `json:"aliases"`
Severity []severity `json:"severity"`
Affected []affected `json:"affected"`
References []reference `json:"references"`
DatabaseSpecific map[string]any `json:"database_specific"`
}
type severity struct {
Type string `json:"type"`
Score string `json:"score"`
}
type affected struct {
Package pkg `json:"package"`
Ranges []rng `json:"ranges"`
}
type rng struct {
Type string `json:"type"`
Events []event `json:"events"`
}
type event struct {
Introduced string `json:"introduced,omitempty"`
Fixed string `json:"fixed,omitempty"`
}
type reference struct {
Type string `json:"type"`
URL string `json:"url"`
}
// ScanPackages checks a list of packages for known vulnerabilities and returns
// per-package results with full vulnerability details. Duplicates caused by
// overlapping CVE/GHSA identifiers are removed.
func (c *Client) ScanPackages(
ctx context.Context,
packages []PackageQuery,
) (map[string][]types.Vulnerability, error) {
if len(packages) == 0 {
return nil, nil
}
batch, err := c.queryBatch(ctx, packages)
if err != nil {
return nil, fmt.Errorf("osv batch query: %w", err)
}
allIDs := collectUniqueIDs(batch)
if len(allIDs) == 0 {
return nil, nil
}
vulnMap, err := c.hydrateAll(ctx, allIDs)
if err != nil {
return nil, fmt.Errorf("osv hydrate: %w", err)
}
return buildResults(packages, batch, vulnMap), nil
}
func (c *Client) queryBatch(
ctx context.Context,
packages []PackageQuery,
) (*batchResponse, error) {
queries := make([]query, len(packages))
for i, p := range packages {
queries[i] = query{
Package: pkg{Name: p.Name, Ecosystem: "PyPI"},
Version: p.Version,
}
}
body, err := json.Marshal(batchRequest{Queries: queries})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, batchEndpoint,
bytes.NewReader(body),
)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("osv returned %d", resp.StatusCode)
}
var result batchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode batch response: %w", err)
}
return &result, nil
}
func (c *Client) fetchVuln(
ctx context.Context,
id string,
) (*osvVuln, error) {
req, err := http.NewRequestWithContext(
ctx, http.MethodGet, vulnEndpoint+id, nil,
)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf(
"osv vuln %s returned %d", id, resp.StatusCode,
)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var v osvVuln
if err := json.Unmarshal(data, &v); err != nil {
return nil, fmt.Errorf("decode vuln %s: %w", id, err)
}
return &v, nil
}
func (c *Client) hydrateAll(
ctx context.Context,
ids []string,
) (map[string]*osvVuln, error) {
var mu sync.Mutex
result := make(map[string]*osvVuln, len(ids))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(maxHydrate)
for _, id := range ids {
g.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf(
"panic hydrating %s: %v", id, r,
)
}
}()
v, fetchErr := c.fetchVuln(ctx, id)
if fetchErr != nil {
return fetchErr
}
mu.Lock()
result[id] = v
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return result, nil
}
func collectUniqueIDs(batch *batchResponse) []string {
seen := make(map[string]bool)
var ids []string
for _, r := range batch.Results {
for _, v := range r.Vulns {
if !seen[v.ID] {
seen[v.ID] = true
ids = append(ids, v.ID)
}
}
}
return ids
}
func buildResults(
packages []PackageQuery,
batch *batchResponse,
vulnMap map[string]*osvVuln,
) map[string][]types.Vulnerability {
results := make(map[string][]types.Vulnerability)
for i, br := range batch.Results {
if len(br.Vulns) == 0 {
continue
}
name := packages[i].Name
seen := make(map[string]bool)
for _, vr := range br.Vulns {
ov, ok := vulnMap[vr.ID]
if !ok {
continue
}
if isDuplicate(ov, seen) {
continue
}
seen[ov.ID] = true
for _, alias := range ov.Aliases {
seen[alias] = true
}
results[name] = append(results[name], toVulnerability(ov))
}
}
return results
}
func isDuplicate(v *osvVuln, seen map[string]bool) bool {
if seen[v.ID] {
return true
}
for _, alias := range v.Aliases {
if seen[alias] {
return true
}
}
return false
}
func toVulnerability(v *osvVuln) types.Vulnerability {
return types.Vulnerability{
ID: v.ID,
Aliases: v.Aliases,
Summary: v.Summary,
Severity: extractSeverity(v),
FixedIn: extractFixed(v.Affected),
Link: extractLink(v.References),
}
}
func extractSeverity(v *osvVuln) string {
for _, s := range v.Severity {
if s.Type != "CVSS_V3" && s.Type != "CVSS_V4" {
continue
}
if score, err := strconv.ParseFloat(s.Score, 64); err == nil {
return classifyScore(score)
}
if idx := strings.LastIndex(s.Score, "/"); idx >= 0 {
chunk := s.Score[idx+1:]
if score, err := strconv.ParseFloat(chunk, 64); err == nil {
return classifyScore(score)
}
}
}
if v.DatabaseSpecific != nil {
if sev, ok := v.DatabaseSpecific["severity"].(string); ok {
return strings.ToUpper(sev)
}
}
return "UNKNOWN"
}
func classifyScore(score float64) string {
switch {
case score >= 9.0:
return "CRITICAL"
case score >= 7.0:
return "HIGH"
case score >= 4.0:
return "MODERATE"
case score > 0:
return "LOW"
default:
return "NONE"
}
}
func extractFixed(affected []affected) string {
for _, a := range affected {
if !strings.EqualFold(a.Package.Ecosystem, "PyPI") {
continue
}
for _, r := range a.Ranges {
for _, e := range r.Events {
if e.Fixed != "" {
return e.Fixed
}
}
}
}
return ""
}
func extractLink(refs []reference) string {
for _, r := range refs {
if r.Type == "ADVISORY" {
return r.URL
}
}
for _, r := range refs {
if r.Type == "WEB" || r.Type == "REPORT" {
return r.URL
}
}
if len(refs) > 0 {
return refs[0].URL
}
return ""
}

View File

@ -0,0 +1,254 @@
// ©AngelaMos | 2026
// client_test.go
package osv
import (
"testing"
)
func TestExtractSeverityCVSSV3(t *testing.T) {
t.Parallel()
v := &osvVuln{
Severity: []severity{
{Type: "CVSS_V3", Score: "9.8"},
},
}
got := extractSeverity(v)
if got != "CRITICAL" { //nolint:goconst
t.Errorf("extractSeverity = %q, want CRITICAL", got)
}
}
func TestExtractSeverityCVSSV4(t *testing.T) {
t.Parallel()
v := &osvVuln{
Severity: []severity{
{Type: "CVSS_V4", Score: "7.5"},
},
}
got := extractSeverity(v)
if got != "HIGH" {
t.Errorf("extractSeverity = %q, want HIGH", got)
}
}
func TestExtractSeverityCVSSVector(t *testing.T) {
t.Parallel()
v := &osvVuln{
Severity: []severity{
{
Type: "CVSS_V3",
Score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/9.8",
},
},
}
got := extractSeverity(v)
if got != "CRITICAL" {
t.Errorf("extractSeverity = %q, want CRITICAL", got)
}
}
func TestExtractSeverityDatabaseSpecific(t *testing.T) {
t.Parallel()
v := &osvVuln{
DatabaseSpecific: map[string]any{
"severity": "MODERATE",
},
}
got := extractSeverity(v)
if got != "MODERATE" {
t.Errorf("extractSeverity = %q, want MODERATE", got)
}
}
func TestExtractSeverityUnknown(t *testing.T) {
t.Parallel()
v := &osvVuln{}
got := extractSeverity(v)
if got != "UNKNOWN" {
t.Errorf("extractSeverity = %q, want UNKNOWN", got)
}
}
func TestClassifyScore(t *testing.T) {
t.Parallel()
tests := []struct {
score float64
want string
}{
{9.8, "CRITICAL"},
{9.0, "CRITICAL"},
{8.5, "HIGH"},
{7.0, "HIGH"},
{6.9, "MODERATE"},
{4.0, "MODERATE"},
{3.9, "LOW"},
{0.1, "LOW"},
{0.0, "NONE"},
}
for _, tt := range tests {
got := classifyScore(tt.score)
if got != tt.want {
t.Errorf(
"classifyScore(%v) = %q, want %q",
tt.score, got, tt.want,
)
}
}
}
func TestIsDuplicate(t *testing.T) {
t.Parallel()
seen := map[string]bool{
"CVE-2023-1234": true,
}
v1 := &osvVuln{
ID: "GHSA-xxxx-yyyy",
Aliases: []string{"CVE-2023-1234"},
}
if !isDuplicate(v1, seen) {
t.Error("should detect duplicate via alias")
}
v2 := &osvVuln{
ID: "CVE-2023-1234",
Aliases: []string{},
}
if !isDuplicate(v2, seen) {
t.Error("should detect duplicate via direct ID")
}
v3 := &osvVuln{
ID: "PYSEC-2024-001",
Aliases: []string{"CVE-2024-9999"},
}
if isDuplicate(v3, seen) {
t.Error("should not flag non-duplicate as duplicate")
}
}
func TestExtractFixed(t *testing.T) {
t.Parallel()
aff := []affected{
{
Package: pkg{Name: "django", Ecosystem: "PyPI"},
Ranges: []rng{
{
Type: "ECOSYSTEM",
Events: []event{
{Introduced: "0"},
{Fixed: "4.2.8"},
},
},
},
},
}
got := extractFixed(aff)
if got != "4.2.8" {
t.Errorf("extractFixed = %q, want 4.2.8", got)
}
}
func TestExtractFixedNoFix(t *testing.T) {
t.Parallel()
aff := []affected{
{
Package: pkg{Name: "django", Ecosystem: "PyPI"},
Ranges: []rng{
{
Type: "ECOSYSTEM",
Events: []event{{Introduced: "0"}},
},
},
},
}
got := extractFixed(aff)
if got != "" {
t.Errorf("extractFixed = %q, want empty", got)
}
}
func TestExtractLink(t *testing.T) {
t.Parallel()
refs := []reference{
{Type: "WEB", URL: "https://example.com"},
{
Type: "ADVISORY",
URL: "https://nvd.nist.gov/vuln/detail/CVE-2023-1234",
},
}
got := extractLink(refs)
if got != "https://nvd.nist.gov/vuln/detail/CVE-2023-1234" {
t.Errorf("extractLink preferred ADVISORY, got %q", got)
}
}
func TestExtractLinkFallback(t *testing.T) {
t.Parallel()
refs := []reference{
{Type: "PACKAGE", URL: "https://pypi.org/project/django"},
{Type: "WEB", URL: "https://example.com/info"},
}
got := extractLink(refs)
if got != "https://example.com/info" {
t.Errorf("extractLink should fall back to WEB, got %q", got)
}
}
func TestExtractLinkEmpty(t *testing.T) {
t.Parallel()
got := extractLink(nil)
if got != "" {
t.Errorf("extractLink(nil) = %q, want empty", got)
}
}
func TestCollectUniqueIDs(t *testing.T) {
t.Parallel()
batch := &batchResponse{
Results: []batchResult{
{Vulns: []vulnRef{
{ID: "CVE-A"},
{ID: "CVE-B"},
}},
{Vulns: []vulnRef{
{ID: "CVE-B"},
{ID: "CVE-C"},
}},
{Vulns: nil},
},
}
ids := collectUniqueIDs(batch)
if len(ids) != 3 {
t.Fatalf("len = %d, want 3", len(ids))
}
seen := make(map[string]bool)
for _, id := range ids {
if seen[id] {
t.Errorf("duplicate ID: %s", id)
}
seen[id] = true
}
}

View File

@ -0,0 +1,105 @@
// ©AngelaMos | 2026
// cache.go
package pypi
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// DefaultCacheTTL defines how long cached PyPI responses remain valid
const DefaultCacheTTL = 1 * time.Hour
// Cache provides file-backed storage for PyPI API responses with ETag support
type Cache struct {
dir string
ttl time.Duration
}
// CacheEntry holds a cached PyPI response alongside its freshness metadata
type CacheEntry struct {
ETag string `json:"etag"`
Versions []string `json:"versions"`
CachedAt time.Time `json:"cached_at"`
}
// NewCache creates a file-backed cache at the given directory
func NewCache(dir string, ttl time.Duration) (*Cache, error) {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
return &Cache{dir: dir, ttl: ttl}, nil
}
// Get retrieves a cache entry by package name, returning false on miss
func (c *Cache) Get(key string) (*CacheEntry, bool) {
data, err := os.ReadFile(c.path(key))
if err != nil {
return nil, false
}
var entry CacheEntry
if err := json.Unmarshal(data, &entry); err != nil {
return nil, false
}
return &entry, true
}
// IsFresh reports whether the entry is still within its TTL window
func (c *Cache) IsFresh(entry *CacheEntry) bool {
return time.Since(entry.CachedAt) <= c.ttl
}
// Set writes a cache entry atomically using rename-over-temp
func (c *Cache) Set(key string, entry *CacheEntry) error {
data, err := json.Marshal(entry)
if err != nil {
return err
}
tmp, err := os.CreateTemp(c.dir, "tmp-*.json")
if err != nil {
return err
}
if _, writeErr := tmp.Write(data); writeErr != nil {
_ = tmp.Close() //nolint:errcheck
_ = os.Remove(tmp.Name()) //nolint:errcheck
return writeErr
}
_ = tmp.Close() //nolint:errcheck
return os.Rename(tmp.Name(), c.path(key))
}
// Touch refreshes the CachedAt timestamp without changing stored data
func (c *Cache) Touch(key string) {
entry, ok := c.Get(key)
if !ok {
return
}
entry.CachedAt = time.Now()
_ = c.Set(key, entry) //nolint:errcheck
}
// Clear removes all cached entries from disk
func (c *Cache) Clear() error {
entries, err := os.ReadDir(c.dir)
if err != nil {
return err
}
for _, e := range entries {
if filepath.Ext(e.Name()) == ".json" {
_ = os.Remove(filepath.Join(c.dir, e.Name())) //nolint:errcheck
}
}
return nil
}
func (c *Cache) path(key string) string {
safe := filepath.Base(key)
return filepath.Join(c.dir, safe+".json")
}

View File

@ -0,0 +1,200 @@
// ©AngelaMos | 2026
// cache_test.go
package pypi
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestCacheGetSetRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cache, err := NewCache(dir, time.Hour)
if err != nil {
t.Fatalf("NewCache: %v", err)
}
entry := &CacheEntry{
ETag: `"abc123"`,
Versions: []string{"1.0", "1.1", "2.0"},
CachedAt: time.Now(),
}
if err := cache.Set("requests", entry); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok := cache.Get("requests")
if !ok {
t.Fatal("Get returned false after Set")
}
if got.ETag != entry.ETag {
t.Errorf("ETag = %q, want %q", got.ETag, entry.ETag)
}
if len(got.Versions) != len(entry.Versions) {
t.Fatalf(
"Versions len = %d, want %d",
len(got.Versions), len(entry.Versions),
)
}
for i, v := range got.Versions {
if v != entry.Versions[i] {
t.Errorf(
"Versions[%d] = %q, want %q",
i, v, entry.Versions[i],
)
}
}
}
func TestCacheGetMiss(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cache, err := NewCache(dir, time.Hour)
if err != nil {
t.Fatalf("NewCache: %v", err)
}
_, ok := cache.Get("nonexistent")
if ok {
t.Fatal("Get returned true for missing key")
}
}
func TestCacheIsFresh(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cache, err := NewCache(dir, time.Hour)
if err != nil {
t.Fatalf("NewCache: %v", err)
}
fresh := &CacheEntry{CachedAt: time.Now()}
if !cache.IsFresh(fresh) {
t.Error("entry cached just now should be fresh")
}
stale := &CacheEntry{
CachedAt: time.Now().Add(-2 * time.Hour),
}
if cache.IsFresh(stale) {
t.Error("entry cached 2 hours ago should be stale")
}
}
func TestCacheTouch(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cache, err := NewCache(dir, time.Hour)
if err != nil {
t.Fatalf("NewCache: %v", err)
}
old := time.Now().Add(-30 * time.Minute)
entry := &CacheEntry{
ETag: `"xyz"`,
Versions: []string{"1.0"},
CachedAt: old,
}
if err := cache.Set("flask", entry); err != nil {
t.Fatalf("Set: %v", err)
}
cache.Touch("flask")
got, ok := cache.Get("flask")
if !ok {
t.Fatal("Get returned false after Touch")
}
if !got.CachedAt.After(old) {
t.Error("Touch did not update CachedAt")
}
}
func TestCacheClear(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cache, err := NewCache(dir, time.Hour)
if err != nil {
t.Fatalf("NewCache: %v", err)
}
for _, name := range []string{"a", "b", "c"} {
setErr := cache.Set(name, &CacheEntry{
Versions: []string{"1.0"},
CachedAt: time.Now(),
})
if setErr != nil {
t.Fatalf("Set %s: %v", name, setErr)
}
}
if clearErr := cache.Clear(); clearErr != nil {
t.Fatalf("Clear: %v", clearErr)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
jsonCount := 0
for _, e := range entries {
if filepath.Ext(e.Name()) == ".json" {
jsonCount++
}
}
if jsonCount != 0 {
t.Errorf("Clear left %d .json files", jsonCount)
}
}
func TestCachePathTraversal(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cache, err := NewCache(dir, time.Hour)
if err != nil {
t.Fatalf("NewCache: %v", err)
}
entry := &CacheEntry{
Versions: []string{"1.0"},
CachedAt: time.Now(),
}
if err := cache.Set("../../../etc/passwd", entry); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok := cache.Get("../../../etc/passwd")
if !ok {
t.Fatal("Get returned false for traversal key")
}
if got.Versions[0] != "1.0" {
t.Error("wrong version returned")
}
path := filepath.Join(dir, "passwd.json")
if _, err := os.Stat(path); err != nil {
t.Errorf(
"expected file at safe path %s, got: %v",
path, err,
)
}
unsafePath := filepath.Join(
dir, "../../../etc/passwd.json",
)
if _, err := os.Stat(unsafePath); err == nil {
t.Error("file created at unsafe traversal path")
}
}

View File

@ -0,0 +1,225 @@
// ©AngelaMos | 2026
// client.go
package pypi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
const (
DefaultMaxWorkers = 10
DefaultTimeout = 30 * time.Second
DefaultUserAgent = "angela/0.1.0 (https://github.com/CarterPerez-dev/angela)"
simpleAPIBase = "https://pypi.org/simple/"
simpleAPIAccept = "application/vnd.pypi.simple.v1+json"
maxRetries = 3
baseRetryMs = 500
)
var nameNormalizeRe = regexp.MustCompile(`[-_.]+`)
// Client queries the PyPI Simple API for package version information
type Client struct {
http *http.Client
cache *Cache
maxWorkers int
userAgent string
}
type simpleAPIResponse struct {
Name string `json:"name"`
Versions []string `json:"versions"`
}
// FetchResult holds the outcome of querying a single package
type FetchResult struct {
Name string
Versions []string
Err error
}
// NewClient creates a PyPI client backed by a file cache at cacheDir
func NewClient(cacheDir string) (*Client, error) {
cache, err := NewCache(cacheDir, DefaultCacheTTL)
if err != nil {
return nil, fmt.Errorf("init cache: %w", err)
}
return &Client{
http: &http.Client{
Timeout: DefaultTimeout,
Transport: &http.Transport{
MaxIdleConnsPerHost: DefaultMaxWorkers,
MaxConnsPerHost: DefaultMaxWorkers,
IdleConnTimeout: 90 * time.Second,
},
},
cache: cache,
maxWorkers: DefaultMaxWorkers,
userAgent: DefaultUserAgent,
}, nil
}
// FetchVersions returns all known versions for a single PyPI package
func (c *Client) FetchVersions(
ctx context.Context,
name string,
) ([]string, error) {
normalized := NormalizeName(name)
entry, hit := c.cache.Get(normalized)
if hit && c.cache.IsFresh(entry) {
return entry.Versions, nil
}
url := simpleAPIBase + normalized + "/"
req, err := http.NewRequestWithContext(
ctx, http.MethodGet, url, nil,
)
if err != nil {
return nil, fmt.Errorf("build request for %s: %w", name, err)
}
req.Header.Set("Accept", simpleAPIAccept)
req.Header.Set("User-Agent", c.userAgent)
if entry != nil && entry.ETag != "" {
req.Header.Set("If-None-Match", entry.ETag)
}
resp, err := c.doWithRetry(ctx, req)
if err != nil {
if entry != nil {
return entry.Versions, nil
}
return nil, fmt.Errorf("fetch %s: %w", name, err)
}
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
switch resp.StatusCode {
case http.StatusNotModified:
c.cache.Touch(normalized)
return entry.Versions, nil
case http.StatusNotFound:
return nil, fmt.Errorf(
"package %q not found on PyPI", name,
)
case http.StatusOK:
var result simpleAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode %s: %w", name, err)
}
_ = c.cache.Set(normalized, &CacheEntry{ //nolint:errcheck
ETag: resp.Header.Get("ETag"),
Versions: result.Versions,
CachedAt: time.Now(),
})
return result.Versions, nil
default:
return nil, fmt.Errorf(
"PyPI returned %d for %s", resp.StatusCode, name,
)
}
}
// FetchAllVersions queries multiple packages concurrently and returns
// per-package results. Failures for individual packages do not prevent
// the remaining packages from being fetched.
func (c *Client) FetchAllVersions(
ctx context.Context,
names []string,
) []FetchResult {
var (
mu sync.Mutex
results = make([]FetchResult, 0, len(names))
)
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(c.maxWorkers)
for _, name := range names {
g.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic fetching %s: %v", name, r)
}
}()
versions, fetchErr := c.FetchVersions(ctx, name)
mu.Lock()
results = append(results, FetchResult{
Name: name,
Versions: versions,
Err: fetchErr,
})
mu.Unlock()
return nil
})
}
_ = g.Wait() //nolint:errcheck
return results
}
// ClearCache removes all cached PyPI responses
func (c *Client) ClearCache() error {
return c.cache.Clear()
}
// NormalizeName converts a PyPI package name to its canonical form per PEP 503
func NormalizeName(name string) string {
return nameNormalizeRe.ReplaceAllString(strings.ToLower(name), "-")
}
func (c *Client) doWithRetry(
ctx context.Context,
req *http.Request,
) (*http.Response, error) {
var lastErr error
for attempt := range maxRetries {
if attempt > 0 {
shift := uint(attempt - 1) //nolint:gosec
delay := time.Duration(1<<shift) * baseRetryMs * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
resp, err := c.http.Do(req)
if err != nil {
lastErr = err
continue
}
if resp.StatusCode >= http.StatusInternalServerError {
_ = resp.Body.Close() //nolint:errcheck
lastErr = fmt.Errorf(
"server error: %d", resp.StatusCode,
)
continue
}
return resp, nil
}
return nil, fmt.Errorf(
"after %d attempts: %w", maxRetries, lastErr,
)
}

View File

@ -0,0 +1,42 @@
// ©AngelaMos | 2026
// client_test.go
package pypi
import (
"testing"
)
func TestNormalizeName(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want string
}{
{"requests", "requests"},
{"Requests", "requests"},
{"REQUESTS", "requests"},
{"some-package", "some-package"},
{"some_package", "some-package"},
{"some.package", "some-package"},
{"Some_Package", "some-package"},
{"my---package", "my-package"},
{"my__.package", "my-package"},
{"Flask", "flask"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
got := NormalizeName(tt.input)
if got != tt.want {
t.Errorf(
"NormalizeName(%q) = %q, want %q",
tt.input, got, tt.want,
)
}
})
}
}

View File

@ -0,0 +1,313 @@
// ©AngelaMos | 2026
// version.go
package pypi
import (
"cmp"
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
)
// ErrInvalidVersion indicates a string that does not conform to PEP 440
var ErrInvalidVersion = errors.New("invalid PEP 440 version")
// ChangeKind classifies a version bump by semver magnitude
type ChangeKind int
const (
Patch ChangeKind = iota + 1
Minor
Major
)
// String returns the lowercase name of the change kind
func (c ChangeKind) String() string {
switch c {
case Patch:
return "patch"
case Minor:
return "minor"
case Major:
return "major"
default:
return "unknown"
}
}
// Version represents a parsed PEP 440 version with all optional components
type Version struct {
Raw string
Epoch int
Release []int
PreKind string
PreNum int
Post int
Dev int
Local string
}
var versionRe = regexp.MustCompile(
`(?i)^v?` +
`(?:(\d+)!)?` +
`(\d+(?:\.\d+)*)` +
`(?:[-_.]?(alpha|a|beta|b|preview|pre|c|rc)[-_.]?(\d*))?` +
`(?:[-_.]?(post|rev|r)[-_.]?(\d*)|-(\d+))?` +
`(?:[-_.]?(dev)[-_.]?(\d*))?` +
`(?:\+([a-z0-9](?:[a-z0-9._-]*[a-z0-9])?))?$`,
)
// ParseVersion parses a PEP 440 version string into its structured components
func ParseVersion(s string) (Version, error) {
normalized := strings.ToLower(strings.TrimSpace(s))
m := versionRe.FindStringSubmatch(normalized)
if m == nil {
return Version{}, fmt.Errorf("%w: %q", ErrInvalidVersion, s)
}
v := Version{
Raw: s,
Post: -1,
Dev: -1,
}
if m[1] != "" {
v.Epoch = mustAtoi(m[1])
}
for _, seg := range strings.Split(m[2], ".") {
v.Release = append(v.Release, mustAtoi(seg))
}
if m[3] != "" {
v.PreKind = normalizePreKind(m[3])
v.PreNum = optionalAtoi(m[4])
}
switch {
case m[5] != "":
v.Post = optionalAtoi(m[6])
case m[7] != "":
v.Post = mustAtoi(m[7])
}
if m[8] != "" {
v.Dev = optionalAtoi(m[9])
}
v.Local = m[10]
return v, nil
}
// String returns the canonical PEP 440 representation
func (v Version) String() string {
var b strings.Builder
if v.Epoch != 0 {
fmt.Fprintf(&b, "%d!", v.Epoch)
}
for i, n := range v.Release {
if i > 0 {
b.WriteByte('.')
}
fmt.Fprintf(&b, "%d", n)
}
if v.PreKind != "" {
fmt.Fprintf(&b, "%s%d", v.PreKind, v.PreNum)
}
if v.Post >= 0 {
fmt.Fprintf(&b, ".post%d", v.Post)
}
if v.Dev >= 0 {
fmt.Fprintf(&b, ".dev%d", v.Dev)
}
if v.Local != "" {
fmt.Fprintf(&b, "+%s", v.Local)
}
return b.String()
}
// IsStable reports whether the version is a stable (non-pre, non-dev) release
func (v Version) IsStable() bool {
return v.PreKind == "" && v.Dev < 0
}
// Compare returns -1, 0, or 1 following PEP 440 ordering rules.
//
// The ordering within a given release segment is:
//
// 1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1
func (v Version) Compare(other Version) int {
if c := cmp.Compare(v.Epoch, other.Epoch); c != 0 {
return c
}
if c := compareRelease(v.Release, other.Release); c != 0 {
return c
}
vpt, vpn := preKey(v)
opt, opn := preKey(other)
if c := cmp.Compare(vpt, opt); c != 0 {
return c
}
if c := cmp.Compare(vpn, opn); c != 0 {
return c
}
if c := cmp.Compare(postKey(v), postKey(other)); c != 0 {
return c
}
return cmp.Compare(devKey(v), devKey(other))
}
// ClassifyChange determines whether moving from v to other is a major, minor,
// or patch bump based on their release segments
func ClassifyChange(from, to Version) ChangeKind {
fromR := padRelease(from.Release, 3)
toR := padRelease(to.Release, 3)
if fromR[0] != toR[0] {
return Major
}
if fromR[1] != toR[1] {
return Minor
}
return Patch
}
// LatestStable finds the highest stable version from a list of version strings
func LatestStable(versions []string) (Version, error) {
var latest Version
var found bool
for _, raw := range versions {
v, err := ParseVersion(raw)
if err != nil {
continue
}
if !v.IsStable() {
continue
}
if !found || v.Compare(latest) > 0 {
latest = v
found = true
}
}
if !found {
return Version{}, errors.New("no stable version found")
}
return latest, nil
}
func compareRelease(a, b []int) int {
maxLen := max(len(a), len(b))
for i := range maxLen {
av, bv := 0, 0
if i < len(a) {
av = a[i]
}
if i < len(b) {
bv = b[i]
}
if c := cmp.Compare(av, bv); c != 0 {
return c
}
}
return 0
}
// preKey produces a sortable tuple for the pre-release component.
//
// Versions with only a dev suffix (no pre, no post) sort before all
// pre-releases. Pre-releases sort by kind (a < b < rc) then number.
// Final and post-releases sort after all pre-releases.
func preKey(v Version) (int, int) {
hasPre := v.PreKind != ""
hasDev := v.Dev >= 0
hasPost := v.Post >= 0
switch {
case !hasPre && !hasPost && hasDev:
return math.MinInt, math.MinInt
case hasPre:
return preKindRank(v.PreKind), v.PreNum
default:
return math.MaxInt, math.MaxInt
}
}
func postKey(v Version) int {
if v.Post < 0 {
return math.MinInt
}
return v.Post
}
func devKey(v Version) int {
if v.Dev < 0 {
return math.MaxInt
}
return v.Dev
}
func preKindRank(kind string) int {
switch kind {
case "a":
return 0
case "b":
return 1
case "rc":
return 2
default:
return -1
}
}
func normalizePreKind(s string) string {
switch strings.ToLower(s) {
case "a", "alpha":
return "a"
case "b", "beta":
return "b"
case "rc", "c", "pre", "preview":
return "rc"
default:
return s
}
}
func padRelease(r []int, n int) []int {
if len(r) >= n {
return r[:n]
}
padded := make([]int, n)
copy(padded, r)
return padded
}
func mustAtoi(s string) int {
n, _ := strconv.Atoi(s) //nolint:errcheck
return n
}
func optionalAtoi(s string) int {
if s == "" {
return 0
}
n, _ := strconv.Atoi(s) //nolint:errcheck
return n
}

View File

@ -0,0 +1,623 @@
// ©AngelaMos | 2026
// version_test.go
package pypi
import (
"errors"
"testing"
)
//nolint:gocognit,funlen
func TestParseVersion(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want Version
wantErr bool
}{
{
name: "simple three-segment",
input: "1.2.3",
want: Version{
Raw: "1.2.3", Release: []int{1, 2, 3},
Post: -1, Dev: -1,
},
},
{
name: "two segments",
input: "2.0",
want: Version{
Raw: "2.0", Release: []int{2, 0},
Post: -1, Dev: -1,
},
},
{
name: "single segment",
input: "42",
want: Version{
Raw: "42", Release: []int{42},
Post: -1, Dev: -1,
},
},
{
name: "leading v prefix",
input: "v1.0.0",
want: Version{
Raw: "v1.0.0", Release: []int{1, 0, 0},
Post: -1, Dev: -1,
},
},
{
name: "epoch",
input: "2!1.0",
want: Version{
Raw: "2!1.0", Epoch: 2, Release: []int{1, 0},
Post: -1, Dev: -1,
},
},
{
name: "alpha pre-release",
input: "1.0a1",
want: Version{
Raw: "1.0a1", Release: []int{1, 0},
PreKind: "a", PreNum: 1, Post: -1, Dev: -1,
},
},
{
name: "beta pre-release",
input: "1.0b2",
want: Version{
Raw: "1.0b2", Release: []int{1, 0},
PreKind: "b", PreNum: 2, Post: -1, Dev: -1,
},
},
{
name: "release candidate",
input: "1.0rc1",
want: Version{
Raw: "1.0rc1", Release: []int{1, 0},
PreKind: "rc", PreNum: 1, Post: -1, Dev: -1,
},
},
{
name: "alpha spelled out",
input: "1.0alpha1",
want: Version{
Raw: "1.0alpha1", Release: []int{1, 0},
PreKind: "a", PreNum: 1, Post: -1, Dev: -1,
},
},
{
name: "beta spelled out",
input: "1.0beta3",
want: Version{
Raw: "1.0beta3", Release: []int{1, 0},
PreKind: "b", PreNum: 3, Post: -1, Dev: -1,
},
},
{
name: "preview normalizes to rc",
input: "1.0preview2",
want: Version{
Raw: "1.0preview2", Release: []int{1, 0},
PreKind: "rc", PreNum: 2, Post: -1, Dev: -1,
},
},
{
name: "c normalizes to rc",
input: "1.0c1",
want: Version{
Raw: "1.0c1", Release: []int{1, 0},
PreKind: "rc", PreNum: 1, Post: -1, Dev: -1,
},
},
{
name: "pre normalizes to rc",
input: "1.0pre4",
want: Version{
Raw: "1.0pre4", Release: []int{1, 0},
PreKind: "rc", PreNum: 4, Post: -1, Dev: -1,
},
},
{
name: "pre-release with dot separator",
input: "1.0.a1",
want: Version{
Raw: "1.0.a1", Release: []int{1, 0},
PreKind: "a", PreNum: 1, Post: -1, Dev: -1,
},
},
{
name: "pre-release with dash separator",
input: "1.0-alpha1",
want: Version{
Raw: "1.0-alpha1", Release: []int{1, 0},
PreKind: "a", PreNum: 1, Post: -1, Dev: -1,
},
},
{
name: "pre-release with underscore separator",
input: "1.0_b2",
want: Version{
Raw: "1.0_b2", Release: []int{1, 0},
PreKind: "b", PreNum: 2, Post: -1, Dev: -1,
},
},
{
name: "implicit pre-release zero",
input: "1.0a",
want: Version{
Raw: "1.0a", Release: []int{1, 0},
PreKind: "a", PreNum: 0, Post: -1, Dev: -1,
},
},
{
name: "post-release",
input: "1.0.post1",
want: Version{
Raw: "1.0.post1", Release: []int{1, 0},
Post: 1, Dev: -1,
},
},
{
name: "post-release implicit zero",
input: "1.0.post",
want: Version{
Raw: "1.0.post", Release: []int{1, 0},
Post: 0, Dev: -1,
},
},
{
name: "implicit post via dash-number",
input: "1.0-1",
want: Version{
Raw: "1.0-1", Release: []int{1, 0},
Post: 1, Dev: -1,
},
},
{
name: "rev normalizes to post",
input: "1.0.rev2",
want: Version{
Raw: "1.0.rev2", Release: []int{1, 0},
Post: 2, Dev: -1,
},
},
{
name: "r normalizes to post",
input: "1.0.r3",
want: Version{
Raw: "1.0.r3", Release: []int{1, 0},
Post: 3, Dev: -1,
},
},
{
name: "dev release",
input: "1.0.dev1",
want: Version{
Raw: "1.0.dev1", Release: []int{1, 0},
Post: -1, Dev: 1,
},
},
{
name: "dev release implicit zero",
input: "1.0.dev",
want: Version{
Raw: "1.0.dev", Release: []int{1, 0},
Post: -1, Dev: 0,
},
},
{
name: "dev release with dash separator",
input: "1.0-dev3",
want: Version{
Raw: "1.0-dev3", Release: []int{1, 0},
Post: -1, Dev: 3,
},
},
{
name: "local version",
input: "1.0+ubuntu1",
want: Version{
Raw: "1.0+ubuntu1", Release: []int{1, 0},
Post: -1, Dev: -1, Local: "ubuntu1",
},
},
{
name: "full complex version",
input: "2!1.2.3a4.post5.dev6+local7",
want: Version{
Raw: "2!1.2.3a4.post5.dev6+local7",
Epoch: 2, Release: []int{1, 2, 3},
PreKind: "a", PreNum: 4,
Post: 5, Dev: 6, Local: "local7",
},
},
{
name: "post and dev combined",
input: "1.0.post1.dev2",
want: Version{
Raw: "1.0.post1.dev2", Release: []int{1, 0},
Post: 1, Dev: 2,
},
},
{
name: "pre and dev combined",
input: "1.0a1.dev1",
want: Version{
Raw: "1.0a1.dev1", Release: []int{1, 0},
PreKind: "a", PreNum: 1, Post: -1, Dev: 1,
},
},
{
name: "leading zeros normalized",
input: "1.01.010",
want: Version{
Raw: "1.01.010", Release: []int{1, 1, 10},
Post: -1, Dev: -1,
},
},
{
name: "many release segments",
input: "1.2.3.4.5",
want: Version{
Raw: "1.2.3.4.5", Release: []int{1, 2, 3, 4, 5},
Post: -1, Dev: -1,
},
},
{
name: "empty string",
input: "",
wantErr: true,
},
{
name: "garbage",
input: "not-a-version",
wantErr: true,
},
{
name: "just text",
input: "latest",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseVersion(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf(
"ParseVersion(%q) succeeded, want error",
tt.input,
)
}
if !errors.Is(err, ErrInvalidVersion) {
t.Fatalf(
"ParseVersion(%q) err = %v, want ErrInvalidVersion",
tt.input, err,
)
}
return
}
if err != nil {
t.Fatalf("ParseVersion(%q) error: %v", tt.input, err)
}
if got.Epoch != tt.want.Epoch {
t.Errorf("Epoch = %d, want %d", got.Epoch, tt.want.Epoch)
}
if !intSliceEqual(got.Release, tt.want.Release) {
t.Errorf(
"Release = %v, want %v",
got.Release, tt.want.Release,
)
}
if got.PreKind != tt.want.PreKind {
t.Errorf(
"PreKind = %q, want %q",
got.PreKind, tt.want.PreKind,
)
}
if got.PreNum != tt.want.PreNum {
t.Errorf("PreNum = %d, want %d", got.PreNum, tt.want.PreNum)
}
if got.Post != tt.want.Post {
t.Errorf("Post = %d, want %d", got.Post, tt.want.Post)
}
if got.Dev != tt.want.Dev {
t.Errorf("Dev = %d, want %d", got.Dev, tt.want.Dev)
}
if got.Local != tt.want.Local {
t.Errorf(
"Local = %q, want %q",
got.Local, tt.want.Local,
)
}
})
}
}
func TestVersionString(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want string
}{
{"1.0", "1.0"},
{"1.2.3", "1.2.3"},
{"2!1.0", "2!1.0"},
{"1.0a1", "1.0a1"},
{"1.0b2", "1.0b2"},
{"1.0rc1", "1.0rc1"},
{"1.0.post1", "1.0.post1"},
{"1.0.dev1", "1.0.dev1"},
{"1.0+local", "1.0+local"},
{"1.0alpha1", "1.0a1"},
{"1.0beta2", "1.0b2"},
{"1.0preview1", "1.0rc1"},
{"1.0c3", "1.0rc3"},
{"1.0pre1", "1.0rc1"},
{"v1.0.0", "1.0.0"},
{"1.0a", "1.0a0"},
{"1.0.post", "1.0.post0"},
{"1.0.dev", "1.0.dev0"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
v, err := ParseVersion(tt.input)
if err != nil {
t.Fatalf("ParseVersion(%q) error: %v", tt.input, err)
}
got := v.String()
if got != tt.want {
t.Errorf("String() = %q, want %q", got, tt.want)
}
})
}
}
func TestVersionIsStable(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want bool
}{
{"1.0", true},
{"1.2.3", true},
{"1.0.post1", true},
{"2!1.0", true},
{"1.0+local", true},
{"1.0a1", false},
{"1.0b1", false},
{"1.0rc1", false},
{"1.0.dev1", false},
{"1.0a1.dev1", false},
{"1.0.post1.dev1", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
v, err := ParseVersion(tt.input)
if err != nil {
t.Fatalf("ParseVersion(%q) error: %v", tt.input, err)
}
if got := v.IsStable(); got != tt.want {
t.Errorf("IsStable() = %v, want %v", got, tt.want)
}
})
}
}
func TestVersionCompare(t *testing.T) {
t.Parallel()
ordered := []string{
"1.0.dev0",
"1.0.dev1",
"1.0a1.dev1",
"1.0a1",
"1.0a2",
"1.0b1",
"1.0b2",
"1.0rc1",
"1.0rc2",
"1.0",
"1.0.post0",
"1.0.post1",
"1.0.post2",
"1.1",
"2.0",
}
versions := make([]Version, len(ordered))
for i, s := range ordered {
v, err := ParseVersion(s)
if err != nil {
t.Fatalf("ParseVersion(%q) error: %v", s, err)
}
versions[i] = v
}
for i := range len(versions) {
for j := range len(versions) {
got := versions[i].Compare(versions[j])
var want int
switch {
case i < j:
want = -1
case i > j:
want = 1
default:
want = 0
}
if (want < 0 && got >= 0) ||
(want > 0 && got <= 0) ||
(want == 0 && got != 0) {
t.Errorf(
"Compare(%s, %s) = %d, want sign %d",
ordered[i], ordered[j], got, want,
)
}
}
}
}
func TestVersionCompareEpoch(t *testing.T) {
t.Parallel()
low, err := ParseVersion("99.0")
if err != nil {
t.Fatal(err)
}
high, err := ParseVersion("2!1.0")
if err != nil {
t.Fatal(err)
}
if got := low.Compare(high); got >= 0 {
t.Errorf("99.0 should be less than 2!1.0, got %d", got)
}
if got := high.Compare(low); got <= 0 {
t.Errorf("2!1.0 should be greater than 99.0, got %d", got)
}
}
func TestVersionCompareImplicitZeros(t *testing.T) {
t.Parallel()
a, err := ParseVersion("1.0")
if err != nil {
t.Fatal(err)
}
b, err := ParseVersion("1.0.0")
if err != nil {
t.Fatal(err)
}
c, err := ParseVersion("1.0.0.0")
if err != nil {
t.Fatal(err)
}
if got := a.Compare(b); got != 0 {
t.Errorf("1.0 vs 1.0.0 = %d, want 0", got)
}
if got := b.Compare(c); got != 0 {
t.Errorf("1.0.0 vs 1.0.0.0 = %d, want 0", got)
}
if got := a.Compare(c); got != 0 {
t.Errorf("1.0 vs 1.0.0.0 = %d, want 0", got)
}
}
func TestClassifyChange(t *testing.T) {
t.Parallel()
tests := []struct {
from string
to string
want ChangeKind
}{
{"1.0.0", "1.0.1", Patch},
{"1.0.0", "1.1.0", Minor},
{"1.0.0", "2.0.0", Major},
{"1.0", "1.1", Minor},
{"1.0", "2.0", Major},
{"3.2.0", "4.2.8", Major},
{"2.28.1", "2.31.0", Minor},
{"7.4.0", "7.4.3", Patch},
}
for _, tt := range tests {
t.Run(tt.from+"->"+tt.to, func(t *testing.T) {
t.Parallel()
from, err := ParseVersion(tt.from)
if err != nil {
t.Fatal(err)
}
to, err := ParseVersion(tt.to)
if err != nil {
t.Fatal(err)
}
if got := ClassifyChange(from, to); got != tt.want {
t.Errorf(
"ClassifyChange(%s, %s) = %s, want %s",
tt.from, tt.to, got, tt.want,
)
}
})
}
}
func TestLatestStable(t *testing.T) {
t.Parallel()
versions := []string{
"1.0",
"1.1",
"1.2a1",
"1.2b1",
"1.2rc1",
"1.2.dev1",
"1.1.1",
"1.1.2",
"0.9",
}
got, err := LatestStable(versions)
if err != nil {
t.Fatalf("LatestStable() error: %v", err)
}
if got.String() != "1.1.2" {
t.Errorf("LatestStable() = %s, want 1.1.2", got.String())
}
}
func TestLatestStableNoStable(t *testing.T) {
t.Parallel()
versions := []string{"1.0a1", "1.0b1", "1.0.dev1"}
_, err := LatestStable(versions)
if err == nil {
t.Fatal("LatestStable() should fail when no stable versions exist")
}
}
func TestLatestStableSkipsInvalid(t *testing.T) {
t.Parallel()
versions := []string{"not-a-version", "also-bad", "1.0", "???"}
got, err := LatestStable(versions)
if err != nil {
t.Fatalf("LatestStable() error: %v", err)
}
if got.String() != "1.0" {
t.Errorf("LatestStable() = %s, want 1.0", got.String())
}
}
func intSliceEqual(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@ -0,0 +1,117 @@
// ©AngelaMos | 2026
// parser.go
package pyproject
import (
"fmt"
"os"
"strings"
"github.com/CarterPerez-dev/angela/pkg/types"
toml "github.com/pelletier/go-toml/v2"
)
type pyProjectFile struct {
Project struct {
Name string `toml:"name"`
Version string `toml:"version"`
Dependencies []string `toml:"dependencies"`
OptionalDependencies map[string][]string `toml:"optional-dependencies"`
} `toml:"project"`
}
// ParseFile reads a pyproject.toml and extracts all dependency declarations
func ParseFile(path string) ([]types.Dependency, error) {
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf(
"open %s: file not found (run from your project root)",
path,
)
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
var proj pyProjectFile
if err := toml.Unmarshal(data, &proj); err != nil {
return nil, fmt.Errorf(
"parse %s: invalid TOML syntax: %w", path, err,
)
}
var deps []types.Dependency
for _, raw := range proj.Project.Dependencies {
deps = append(deps, ParseDependency(raw, ""))
}
for group, entries := range proj.Project.OptionalDependencies {
for _, raw := range entries {
deps = append(deps, ParseDependency(raw, group))
}
}
if len(deps) == 0 {
return nil, fmt.Errorf(
"%s: no dependencies found in [project.dependencies] or [project.optional-dependencies]",
path,
)
}
return deps, nil
}
// ParseDependency splits a PEP 508 dependency string into its components
func ParseDependency(raw, group string) types.Dependency {
dep := types.Dependency{Group: group}
s := strings.TrimSpace(raw)
if idx := strings.Index(s, ";"); idx >= 0 {
dep.Markers = strings.TrimSpace(s[idx+1:])
s = s[:idx]
}
s = strings.TrimSpace(s)
if start := strings.Index(s, "["); start >= 0 {
end := strings.Index(s, "]")
if end > start {
for _, e := range strings.Split(s[start+1:end], ",") {
dep.Extras = append(
dep.Extras,
strings.TrimSpace(e),
)
}
s = s[:start] + s[end+1:]
}
}
if idx := strings.IndexAny(s, "><=!~"); idx >= 0 {
dep.Name = strings.TrimSpace(s[:idx])
dep.Spec = strings.TrimSpace(s[idx:])
} else {
dep.Name = strings.TrimSpace(s)
}
return dep
}
// ExtractMinVersion returns the first pinned or lower-bound version from a
// PEP 440 version specifier string. Returns empty if no version is specified.
func ExtractMinVersion(spec string) string {
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
switch {
case strings.HasPrefix(part, ">="):
return strings.TrimSpace(part[2:])
case strings.HasPrefix(part, "=="):
return strings.TrimSuffix(
strings.TrimSpace(part[2:]),
".*",
)
case strings.HasPrefix(part, "~="):
return strings.TrimSpace(part[2:])
}
}
return ""
}

View File

@ -0,0 +1,159 @@
// ©AngelaMos | 2026
// parser_test.go
package pyproject
import (
"testing"
)
//nolint:gocognit,funlen
func TestParseDependency(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantPkg string
wantSpc string
extras []string
markers string
}{
{
name: "simple with lower bound",
input: "requests>=2.28.0",
wantPkg: "requests",
wantSpc: ">=2.28.0",
},
{
name: "range constraint",
input: "django>=3.2,<4.0",
wantPkg: "django",
wantSpc: ">=3.2,<4.0",
},
{
name: "extras",
input: "flask[async]>=2.0",
wantPkg: "flask",
wantSpc: ">=2.0",
extras: []string{"async"},
},
{
name: "multiple extras",
input: "requests[security, socks]>=2.28.0",
wantPkg: "requests",
wantSpc: ">=2.28.0",
extras: []string{"security", "socks"},
},
{
name: "markers",
input: `pandas>=1.5; python_version>='3.8'`,
wantPkg: "pandas",
wantSpc: ">=1.5",
markers: "python_version>='3.8'",
},
{
name: "bare name no version",
input: "numpy",
wantPkg: "numpy",
wantSpc: "",
},
{
name: "exact pin",
input: "black==23.7.0",
wantPkg: "black",
wantSpc: "==23.7.0",
},
{
name: "compatible release",
input: "setuptools~=68.0",
wantPkg: "setuptools",
wantSpc: "~=68.0",
},
{
name: "not equal",
input: "urllib3!=2.0.0",
wantPkg: "urllib3",
wantSpc: "!=2.0.0",
},
{
name: "whitespace around",
input: " click >= 8.0.0 ",
wantPkg: "click",
wantSpc: ">= 8.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dep := ParseDependency(tt.input, "")
if dep.Name != tt.wantPkg {
t.Errorf(
"Name = %q, want %q", dep.Name, tt.wantPkg,
)
}
if dep.Spec != tt.wantSpc {
t.Errorf(
"Spec = %q, want %q", dep.Spec, tt.wantSpc,
)
}
if tt.markers != "" && dep.Markers != tt.markers {
t.Errorf(
"Markers = %q, want %q",
dep.Markers, tt.markers,
)
}
if len(tt.extras) > 0 {
if len(dep.Extras) != len(tt.extras) {
t.Fatalf(
"Extras = %v, want %v",
dep.Extras, tt.extras,
)
}
for i, e := range tt.extras {
if dep.Extras[i] != e {
t.Errorf(
"Extras[%d] = %q, want %q",
i, dep.Extras[i], e,
)
}
}
}
})
}
}
func TestExtractMinVersion(t *testing.T) {
t.Parallel()
tests := []struct {
spec string
want string
}{
{">=2.28.0", "2.28.0"},
{">=3.2,<4.0", "3.2"},
{"==23.7.0", "23.7.0"},
{"==3.2.*", "3.2"},
{"~=68.0", "68.0"},
{"!=2.0", ""},
{">1.0", ""},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.spec, func(t *testing.T) {
t.Parallel()
got := ExtractMinVersion(tt.spec)
if got != tt.want {
t.Errorf(
"ExtractMinVersion(%q) = %q, want %q",
tt.spec, got, tt.want,
)
}
})
}
}

View File

@ -0,0 +1,141 @@
// ©AngelaMos | 2026
// writer.go
package pyproject
import (
"fmt"
"os"
"regexp"
"strings"
"github.com/CarterPerez-dev/angela/internal/pypi"
toml "github.com/pelletier/go-toml/v2"
)
// Updater performs surgical edits on raw pyproject.toml bytes, preserving
// all comments, whitespace, and key ordering
type Updater struct {
content []byte
}
// NewUpdater wraps raw file content after validating it as syntactically
// correct TOML
func NewUpdater(content []byte) (*Updater, error) {
var probe map[string]any
if err := toml.Unmarshal(content, &probe); err != nil {
return nil, fmt.Errorf("invalid TOML: %w", err)
}
return &Updater{content: content}, nil
}
// UpdateDependency replaces the version specifier for a single dependency
// while preserving quotes, extras, markers, and surrounding formatting.
// Tries double-quote patterns first, then single-quote.
func (u *Updater) UpdateDependency(pkg, newSpec string) error {
for _, q := range []byte{'"', '\''} {
pattern := buildDepPattern(pkg, q)
found := false
u.content = pattern.ReplaceAllFunc(
u.content,
func(match []byte) []byte {
found = true
return replaceSpec(pattern, match, newSpec, q)
},
)
if found {
var probe map[string]any
if err := toml.Unmarshal(u.content, &probe); err != nil {
return fmt.Errorf(
"update produced invalid TOML: %w", err,
)
}
return nil
}
}
return fmt.Errorf("dependency %q not found", pkg)
}
// Bytes returns the current file content
func (u *Updater) Bytes() []byte {
return u.content
}
// WriteFile atomically writes the updated content to disk
func (u *Updater) WriteFile(path string) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, u.content, 0o600); err != nil {
return fmt.Errorf("write temp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp) //nolint:errcheck
return fmt.Errorf("rename: %w", err)
}
return nil
}
func buildDepPattern(name string, quote byte) *regexp.Regexp {
normalized := pypi.NormalizeName(name)
parts := strings.Split(normalized, "-")
for i, p := range parts {
parts[i] = regexp.QuoteMeta(p)
}
namePattern := strings.Join(parts, `[-_.]?`)
q := string(quote)
notQ := `[^` + q + `]`
return regexp.MustCompile(
`(?i)` +
q +
`(` + namePattern + `)` +
`(\[[^\]]*\])?` +
`(\s*[><=!~]` + notQ + `*?)` +
`(;` + notQ + `*)?` +
q,
)
}
func replaceSpec(
re *regexp.Regexp, match []byte, newSpec string, quote byte,
) []byte {
groups := re.FindSubmatch(match)
if len(groups) < 5 {
return match
}
name := groups[1]
extras := groups[2]
markers := groups[4]
var b []byte
b = append(b, quote)
b = append(b, name...)
b = append(b, extras...)
b = append(b, []byte(newSpec)...)
b = append(b, markers...)
b = append(b, quote)
return b
}
// UpdateFile is a convenience that reads, updates all given dependencies,
// and writes back atomically
func UpdateFile(path string, updates map[string]string) error {
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
u, err := NewUpdater(data)
if err != nil {
return err
}
for pkg, spec := range updates {
if err := u.UpdateDependency(pkg, spec); err != nil {
return fmt.Errorf("update %s: %w", pkg, err)
}
}
return u.WriteFile(path)
}

View File

@ -0,0 +1,177 @@
// ©AngelaMos | 2026
// writer_test.go
package pyproject
import (
"strings"
"testing"
)
const sampleTOML = `# Project configuration
[project]
name = "myapp"
version = "1.0.0"
# Core dependencies
dependencies = [
"requests>=2.28.0", # HTTP library
"django>=3.2,<4.0",
"flask[async]>=2.0",
"numpy",
]
[project.optional-dependencies]
# Development tools
dev = [
"pytest>=7.0.0",
"black==23.7.0", # Code formatter
]
`
func TestUpdaterPreservesComments(t *testing.T) {
t.Parallel()
u, err := NewUpdater([]byte(sampleTOML))
if err != nil {
t.Fatalf("NewUpdater error: %v", err)
}
if err := u.UpdateDependency("requests", ">=2.31.0"); err != nil {
t.Fatalf("UpdateDependency error: %v", err)
}
result := string(u.Bytes())
if !strings.Contains(result, `"requests>=2.31.0"`) {
t.Error("version was not updated")
}
if !strings.Contains(result, "# HTTP library") {
t.Error("inline comment was lost")
}
if !strings.Contains(result, "# Project configuration") {
t.Error("header comment was lost")
}
if !strings.Contains(result, "# Core dependencies") {
t.Error("section comment was lost")
}
if !strings.Contains(result, "# Development tools") {
t.Error("optional-deps comment was lost")
}
}
func TestUpdaterPreservesExtras(t *testing.T) {
t.Parallel()
u, err := NewUpdater([]byte(sampleTOML))
if err != nil {
t.Fatal(err)
}
if err := u.UpdateDependency("flask", ">=3.0"); err != nil {
t.Fatal(err)
}
result := string(u.Bytes())
if !strings.Contains(result, `"flask[async]>=3.0"`) {
t.Errorf("extras lost or version not updated: %s", result)
}
}
func TestUpdaterHandlesExactPin(t *testing.T) {
t.Parallel()
u, err := NewUpdater([]byte(sampleTOML))
if err != nil {
t.Fatal(err)
}
if err := u.UpdateDependency("black", ">=24.0.0"); err != nil {
t.Fatal(err)
}
result := string(u.Bytes())
if !strings.Contains(result, `"black>=24.0.0"`) {
t.Errorf("exact pin not updated: %s", result)
}
if !strings.Contains(result, "# Code formatter") {
t.Error("inline comment was lost")
}
}
func TestUpdaterNotFound(t *testing.T) {
t.Parallel()
u, err := NewUpdater([]byte(sampleTOML))
if err != nil {
t.Fatal(err)
}
err = u.UpdateDependency("nonexistent", ">=1.0")
if err == nil {
t.Fatal("expected error for missing dependency")
}
}
func TestUpdaterSkipsBareNames(t *testing.T) {
t.Parallel()
u, err := NewUpdater([]byte(sampleTOML))
if err != nil {
t.Fatal(err)
}
err = u.UpdateDependency("numpy", ">=1.26.0")
if err == nil {
t.Fatal("expected error: bare name has no version spec to match")
}
}
func TestUpdaterMultipleUpdates(t *testing.T) {
t.Parallel()
u, err := NewUpdater([]byte(sampleTOML))
if err != nil {
t.Fatal(err)
}
updates := map[string]string{
"requests": ">=2.31.0",
"django": ">=4.2.8",
"pytest": ">=8.0.0",
}
for pkg, spec := range updates {
if err := u.UpdateDependency(pkg, spec); err != nil {
t.Fatalf("update %s: %v", pkg, err)
}
}
result := string(u.Bytes())
if !strings.Contains(result, `"requests>=2.31.0"`) {
t.Error("requests not updated")
}
if !strings.Contains(result, `"django>=4.2.8"`) {
t.Error("django not updated")
}
if !strings.Contains(result, `"pytest>=8.0.0"`) {
t.Error("pytest not updated")
}
if !strings.Contains(result, "# HTTP library") {
t.Error("comment lost after multiple updates")
}
if !strings.Contains(result, "# Code formatter") {
t.Error("comment lost after multiple updates")
}
}
func TestUpdaterInvalidTOML(t *testing.T) {
t.Parallel()
_, err := NewUpdater([]byte(`[invalid`))
if err == nil {
t.Fatal("expected error for invalid TOML")
}
}

View File

@ -0,0 +1,91 @@
// ©AngelaMos | 2026
// parser.go
package requirements
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/CarterPerez-dev/angela/pkg/types"
)
// ParseFile reads a requirements.txt and extracts all dependency declarations
func ParseFile(path string) ([]types.Dependency, error) {
f, err := os.Open(path) //nolint:gosec
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf(
"open %s: file not found", path,
)
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
defer func() { _ = f.Close() }() //nolint:errcheck
var deps []types.Dependency
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" ||
strings.HasPrefix(line, "#") ||
strings.HasPrefix(line, "-") {
continue
}
if idx := strings.Index(line, " #"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
dep := parseLine(line)
if dep.Name != "" {
deps = append(deps, dep)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(deps) == 0 {
return nil, fmt.Errorf(
"%s: no dependencies found", path,
)
}
return deps, nil
}
func parseLine(s string) types.Dependency {
var dep types.Dependency
if idx := strings.Index(s, ";"); idx >= 0 {
dep.Markers = strings.TrimSpace(s[idx+1:])
s = strings.TrimSpace(s[:idx])
}
if start := strings.Index(s, "["); start >= 0 {
end := strings.Index(s, "]")
if end > start {
for _, e := range strings.Split(s[start+1:end], ",") {
dep.Extras = append(
dep.Extras,
strings.TrimSpace(e),
)
}
s = s[:start] + s[end+1:]
}
}
if idx := strings.IndexAny(s, "><=!~"); idx >= 0 {
dep.Name = strings.TrimSpace(s[:idx])
dep.Spec = strings.TrimSpace(s[idx:])
} else {
dep.Name = strings.TrimSpace(s)
}
return dep
}

View File

@ -0,0 +1,122 @@
// ©AngelaMos | 2026
// parser_test.go
package requirements
import (
"os"
"path/filepath"
"testing"
)
func TestParseFile(t *testing.T) {
t.Parallel()
content := `# Core deps
django>=3.2.0
requests==2.28.1
flask>=2.0,<3.0
numpy # pinned
click[extra]>=8.0.0; python_version>="3.8"
# Options to skip
-r other.txt
-e ./local-pkg
--index-url https://pypi.org/simple/
`
path := filepath.Join(t.TempDir(), "requirements.txt")
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
deps, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if len(deps) != 5 {
t.Fatalf("len = %d, want 5", len(deps))
}
tests := []struct {
name string
spec string
}{
{"django", ">=3.2.0"},
{"requests", "==2.28.1"},
{"flask", ">=2.0,<3.0"},
{"numpy", ""},
{"click", ">=8.0.0"},
}
for i, tt := range tests {
if deps[i].Name != tt.name {
t.Errorf("deps[%d].Name = %q, want %q", i, deps[i].Name, tt.name)
}
if deps[i].Spec != tt.spec {
t.Errorf("deps[%d].Spec = %q, want %q", i, deps[i].Spec, tt.spec)
}
}
}
func TestParseFileExtras(t *testing.T) {
t.Parallel()
content := "requests[security,socks]>=2.28.0\n"
path := filepath.Join(t.TempDir(), "requirements.txt")
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
deps, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if len(deps) != 1 {
t.Fatalf("len = %d, want 1", len(deps))
}
if len(deps[0].Extras) != 2 {
t.Fatalf("extras len = %d, want 2", len(deps[0].Extras))
}
if deps[0].Extras[0] != "security" || deps[0].Extras[1] != "socks" {
t.Errorf("extras = %v, want [security socks]", deps[0].Extras)
}
}
func TestParseFileMarkers(t *testing.T) {
t.Parallel()
content := "pywin32>=300; sys_platform==\"win32\"\n"
path := filepath.Join(t.TempDir(), "requirements.txt")
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
deps, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if deps[0].Markers != `sys_platform=="win32"` {
t.Errorf("markers = %q, want sys_platform==\"win32\"", deps[0].Markers)
}
}
func TestParseFileEmpty(t *testing.T) {
t.Parallel()
content := "# just comments\n\n-r other.txt\n"
path := filepath.Join(t.TempDir(), "requirements.txt")
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
_, err := ParseFile(path)
if err == nil {
t.Error("expected error for empty requirements")
}
}
func TestParseFileNotFound(t *testing.T) {
t.Parallel()
_, err := ParseFile("/nonexistent/requirements.txt")
if err == nil {
t.Error("expected error for missing file")
}
}

View File

@ -0,0 +1,80 @@
// ©AngelaMos | 2026
// writer.go
package requirements
import (
"fmt"
"os"
"regexp"
"strings"
"github.com/CarterPerez-dev/angela/internal/pypi"
)
// UpdateFile replaces version specifiers for the given packages in a
// requirements.txt file, preserving all comments and formatting
func UpdateFile(path string, updates map[string]string) error {
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
content := data
for pkg, spec := range updates {
pattern := buildPattern(pkg)
found := false
content = pattern.ReplaceAllFunc(
content,
func(match []byte) []byte {
found = true
return replaceSpec(pattern, match, spec)
},
)
if !found {
return fmt.Errorf(
"dependency %q not found in %s", pkg, path,
)
}
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, content, 0o600); err != nil {
return fmt.Errorf("write temp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp) //nolint:errcheck
return fmt.Errorf("rename: %w", err)
}
return nil
}
func buildPattern(name string) *regexp.Regexp {
normalized := pypi.NormalizeName(name)
parts := strings.Split(normalized, "-")
for i, p := range parts {
parts[i] = regexp.QuoteMeta(p)
}
namePattern := strings.Join(parts, `[-_.]?`)
return regexp.MustCompile(
`(?im)^(` + namePattern + `)` +
`(\[[^\]]*\])?` +
`(\s*[><=!~][^\n;#]*)`,
)
}
func replaceSpec(
re *regexp.Regexp, match []byte, newSpec string,
) []byte {
groups := re.FindSubmatch(match)
if len(groups) < 4 {
return match
}
var b []byte
b = append(b, groups[1]...)
b = append(b, groups[2]...)
b = append(b, []byte(newSpec)...)
return b
}

View File

@ -0,0 +1,92 @@
// ©AngelaMos | 2026
// writer_test.go
package requirements
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestUpdateFile(t *testing.T) {
t.Parallel()
content := `# Core deps
django>=3.2.0
requests==2.28.1 # HTTP client
flask>=2.0.0
`
path := filepath.Join(t.TempDir(), "requirements.txt")
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
updates := map[string]string{
"django": ">=6.0.1",
"requests": ">=2.32.5",
}
if err := UpdateFile(path, updates); err != nil {
t.Fatalf("UpdateFile: %v", err)
}
data, _ := os.ReadFile(path) //nolint:errcheck,gosec
result := string(data)
if !strings.Contains(result, "django>=6.0.1") {
t.Error("django not updated")
}
if !strings.Contains(result, "requests>=2.32.5") {
t.Error("requests not updated")
}
if !strings.Contains(result, "# Core deps") {
t.Error("comment lost")
}
if !strings.Contains(result, "# HTTP client") {
t.Error("inline comment lost")
}
if !strings.Contains(result, "flask>=2.0.0") {
t.Error("flask was incorrectly modified")
}
}
func TestUpdateFilePreservesFormatting(t *testing.T) {
t.Parallel()
content := "Django>=3.2.0\nnumpy==1.24.0\n"
path := filepath.Join(t.TempDir(), "requirements.txt")
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
updates := map[string]string{
"django": ">=6.0.1",
}
if err := UpdateFile(path, updates); err != nil {
t.Fatalf("UpdateFile: %v", err)
}
data, _ := os.ReadFile(path) //nolint:errcheck,gosec
result := string(data)
if !strings.Contains(result, "Django>=6.0.1") {
t.Error("original casing not preserved")
}
if !strings.Contains(result, "numpy==1.24.0") {
t.Error("numpy was incorrectly modified")
}
}
func TestUpdateFileNotFound(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "requirements.txt")
os.WriteFile(path, []byte("flask>=2.0.0\n"), 0o600) //nolint:errcheck
err := UpdateFile(path, map[string]string{
"nonexistent": ">=1.0.0",
})
if err == nil {
t.Error("expected error for missing dependency")
}
}

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

@ -0,0 +1,180 @@
# Go Concurrency Patterns Used in angela
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` 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 answer is **bounded concurrency** — run up to N requests in parallel, queue the rest.
---
## errgroup.SetLimit
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)
g.SetLimit(c.maxWorkers) // max 10 concurrent goroutines
for _, name := range names {
g.Go(func() error {
versions, err := c.FetchVersions(ctx, name)
mu.Lock()
results = append(results, FetchResult{
Name: name, Versions: versions, Err: err,
})
mu.Unlock()
return nil
})
}
_ = g.Wait()
```
A few things to notice:
- **`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?
Channels would work, but errgroup gets you the same thing with half the code:
```go
// Channel-based worker pool: ~30 lines
jobs := make(chan string, len(names))
results := make(chan FetchResult, len(names))
for range maxWorkers {
go func() {
for name := range jobs {
// ... fetch ...
results <- result
}
}()
}
// ... send jobs, collect results, close channels ...
// errgroup: ~15 lines
g.SetLimit(maxWorkers)
for _, name := range names {
g.Go(func() error { /* ... */ })
}
g.Wait()
```
errgroup handles context cancellation for free and there's no channel lifecycle to think about.
---
## Panic recovery
Every goroutine angela launches wraps itself in a recover:
```go
g.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic fetching %s: %v", name, r)
}
}()
versions, fetchErr := c.FetchVersions(ctx, name)
// ...
return nil
})
```
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.
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.
---
## Context cancellation
Every HTTP request uses context:
```go
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
```
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()``runUpdate()``FetchAllVersions()``FetchVersions()` → the HTTP call itself. Standard Go pattern — context as the first parameter, threaded through everything.
---
## Retry with exponential backoff
`internal/pypi/client.go` retries transient failures:
```go
for attempt := range maxRetries {
if attempt > 0 {
delay := time.Duration(1<<shift) * baseRetryMs * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
resp, err := c.http.Do(req)
if err != nil {
lastErr = err
continue
}
if resp.StatusCode >= 500 {
lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
continue
}
return resp, nil
}
```
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.
---
## Mutex vs channel
angela uses `sync.Mutex` to protect the results slice:
```go
var mu sync.Mutex
results := make([]FetchResult, 0, len(names))
// In each goroutine:
mu.Lock()
results = append(results, result)
mu.Unlock()
```
Quick rule of thumb:
| Use Mutex | Use Channel |
|-----------|-------------|
| Protecting shared state (maps, slices) | Passing data between goroutines |
| Simple append/read operations | Producer-consumer pipelines |
| Order doesn't matter | Sequential processing needed |
For appending results where order doesn't matter, a mutex is simpler and cheaper.
---
## The bigger picture
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.
`go test -race` catches most of what code review misses. Run it in CI, always.

View File

@ -0,0 +1,141 @@
# PEP 440: Python Version Parsing from Scratch
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 has components semver doesn't:
```
[epoch!] release [pre-release] [.postN] [.devN] [+local]
```
Real examples from PyPI:
| Version | What it means |
|---------|---------------|
| `1.2.3` | Normal release |
| `2!1.0` | Epoch 2 — beats any version with epoch 0 or 1 |
| `1.0a1` | Alpha pre-release |
| `1.0b2` | Beta pre-release |
| `1.0rc1` | Release candidate |
| `1.0.dev3` | Development snapshot |
| `1.0.post1` | Post-release (stable — just a docs or metadata fix) |
| `1.0+ubuntu1` | Local version label (never shows up on PyPI) |
---
## Why you can't just string-compare
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 gets this wrong will upgrade users to unstable pre-releases. That's why angela implements the full spec.
---
## How the parser works
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?
(?:(\d+)!)? # epoch
(\d+(?:\.\d+)*) # release segments
(?:[-_.]?(alpha|a|beta|b|...|rc)[-_.]?(\d*))? # pre-release
(?:[-_.]?(post|rev|r)[-_.]?(\d*)|-(\d+))? # post-release
(?:[-_.]?(dev)[-_.]?(\d*))? # dev release
(?:\+([a-z0-9]...))?$ # local version
```
Each section maps to a field in the `Version` struct:
```go
type Version struct {
Raw string
Epoch int
Release []int // [1, 2, 3] for "1.2.3"
PreKind string // "a", "b", or "rc"
PreNum int
Post int // -1 means absent
Dev int // -1 means absent
Local string
}
```
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
PEP 440 allows a bunch of different spellings that all mean the same thing:
| Input | Normalized |
|-------|-----------|
| `alpha` | `a` |
| `beta` | `b` |
| `c`, `pre`, `preview` | `rc` |
| `rev`, `r` | `post` |
| `1.0-1` | `1.0.post1` (implicit post via dash-number) |
| `1.0a` | `1.0a0` (implicit zero) |
| `v1.0` | `1.0` (strip leading v) |
The parser normalizes all of these during extraction, so the rest of the codebase never has to think about variant spellings.
---
## Version comparison
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 {
// 1. Compare epochs
// 2. Compare release segments (with implicit zero extension)
// 3. Compare pre-release (using sentinel values)
// 4. Compare post-release
// 5. Compare dev-release
}
```
The trick is in the sentinel values:
| Component state | Sort key |
|----------------|----------|
| Dev-only (no pre, no post) | `MinInt, MinInt` — sorts before everything |
| Pre-release `a1` | `0, 1` — alpha rank 0 |
| Pre-release `b2` | `1, 2` — beta rank 1 |
| Pre-release `rc1` | `2, 1` — rc rank 2 |
| Final release (no pre) | `MaxInt, MaxInt` — sorts after all pre-releases |
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:
```go
func (v Version) IsStable() bool {
return v.PreKind == "" && v.Dev < 0
}
```
Post-releases count as stable — `1.0.post1` is a docs fix or metadata correction, not an unstable build.
---
## Takeaways
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.
Normalizing at parse time (alpha→a, preview→rc) keeps things clean — nothing downstream ever has to handle variant spellings.
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

@ -0,0 +1,175 @@
# Preserving Comments in Dependency Files
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`.
---
## The constraint
A developer's `pyproject.toml` looks like this:
```toml
[project]
name = "myapp"
version = "1.0.0"
# Core runtime dependencies
dependencies = [
"requests>=2.28.0", # HTTP library - pin for security
"django>=3.2,<4.0",
"flask[async]>=2.0",
]
```
After running `angela update`, the file should look like:
```toml
[project]
name = "myapp"
version = "1.0.0"
# Core runtime dependencies
dependencies = [
"requests>=2.32.3", # HTTP library - pin for security
"django>=5.1.5",
"flask[async]>=3.1.0",
]
```
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 into Go structs, then marshaling back:
```go
var proj PyProject
toml.Unmarshal(data, &proj) // comments stripped
proj.Dependencies[0] = "requests>=2.32.3"
output, _ := toml.Marshal(proj) // comments gone, formatting changed
```
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 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.
---
## Regex surgery
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 for pyproject.toml lives in `internal/pyproject/writer.go`:
1. Build a regex that matches the full dependency string inside quotes:
```
"requests>=2.28.0"
```
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, keep everything else.
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 {
for _, q := range []byte{'"', '\''} {
pattern := buildDepPattern(pkg, q)
found := false
u.content = pattern.ReplaceAllFunc(u.content,
func(match []byte) []byte {
found = true
return replaceSpec(pattern, match, newSpec, q)
},
)
if found {
var probe map[string]any
if err := toml.Unmarshal(u.content, &probe); err != nil {
return fmt.Errorf("update produced invalid TOML: %w", err)
}
return nil
}
}
return fmt.Errorf("dependency %q not found", pkg)
}
```
---
## 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
Package names on PyPI are case-insensitive and treat `-`, `_`, and `.` as equivalent:
```
Some_Package == some-package == some.package
```
The regex has to match all variants. angela splits the normalized name on `-` and joins with `[-_.]?`:
```go
parts := strings.Split(normalized, "-")
for i, p := range parts {
parts[i] = regexp.QuoteMeta(p)
}
namePattern := strings.Join(parts, `[-_.]?`)
```
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 closing quotes with the opening quote. Go's `regexp` package uses RE2, which doesn't do backreferences.
The workaround: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character.
### Atomic file writes
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 {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, u.content, 0o600); err != nil {
return fmt.Errorf("write temp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
return nil
}
```
It's two syscalls, but the file is never in a half-written state.
---
## This is how production tools do it
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.
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

@ -0,0 +1,48 @@
// ©AngelaMos | 2026
// types.go
package types
import "time"
// Dependency represents a single parsed dependency from a pyproject.toml file
type Dependency struct {
Name string
Spec string
Extras []string
Markers string
Group string
}
// UpdateResult holds the outcome of checking one dependency for updates
type UpdateResult struct {
Name string
OldSpec string
NewSpec string
OldVer string
NewVer string
Change string
Skipped bool
Reason string
}
// Vulnerability represents a single security advisory for a package
type Vulnerability struct {
ID string
Aliases []string
Summary string
Severity string
FixedIn string
Link string
}
// ScanResult holds the aggregated output of a full dependency scan
type ScanResult struct {
Updates []UpdateResult
Vulnerabilities map[string][]Vulnerability
TotalPackages int
TotalUpdated int
TotalVulns int
VulnsScanned bool
Duration time.Duration
}

View File

@ -0,0 +1,23 @@
# Sample pyproject.toml for testing angela
[project]
name = "sample-app"
version = "0.1.0"
requires-python = ">=3.10"
# Core runtime dependencies (outdated)
dependencies = [
"requests>=2.28.0",
"django>=3.2,<4.0",
"click>=8.0.0",
"pydantic>=2.0",
"flask[async]>=2.0",
]
[project.optional-dependencies]
# Development and testing tools (outdated)
dev = [
"pytest>=7.0.0",
"black>=23.0.0", # Code formatter
"ruff>=0.1.0",
"mypy>=1.0.0",
]

View File

@ -0,0 +1,13 @@
# Core dependencies
django>=3.2.0
requests>=2.28.0
flask>=2.0.0
# Dev tools
black>=23.0.0
pydantic>=1.10.0
# Pinned
numpy==1.24.0
pytest>=7.0.0
click>=8.0.0