feat: complete phase 1 intial build

This commit is contained in:
CarterPerez-dev 2026-01-27 23:17:50 -05:00
parent f201800f73
commit d9445e06b5
26 changed files with 4507 additions and 0 deletions

View File

@ -0,0 +1,36 @@
# AngelaMos | 2026
# .gitignore
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
.meep
# Test
*.test
# Build
tmp/
# 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 --scan-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,207 @@
# angela
A fast CLI tool that updates Python dependencies in `pyproject.toml` and scans for known vulnerabilities using [OSV.dev](https://osv.dev).
Built in Go for speed — parallel HTTP requests, local caching with ETag support, and a single binary with zero runtime dependencies.
---
## Why
The Python ecosystem lacks a single tool that updates all dependencies in `pyproject.toml` the way `pnpm update` works for JavaScript. Existing options either show outdated packages without updating them (`pip list --outdated`), upgrade one at a time (`pip install --upgrade`), or require CI infrastructure (Dependabot, Renovate).
**angela** fills this gap: one command updates everything, with built-in CVE scanning.
---
## Install
```bash
go install github.com/CarterPerez-dev/angela@latest
```
Or build from source:
```bash
git clone https://github.com/CarterPerez-dev/angela.git
cd angela
go build -o bin/angela ./cmd/angela
```
---
## Usage
### Update all dependencies
```bash
angela update
```
Reads `pyproject.toml` in the current directory, queries PyPI for the latest stable versions, and writes the updates back to the file.
### Dry run (show what would change)
```bash
angela check
```
Shows available updates and vulnerabilities without modifying any files.
### Scan for vulnerabilities only
```bash
angela scan
```
Checks all pinned dependencies against the OSV.dev vulnerability database.
### Update with vulnerability scan
```bash
angela update --scan-vulns
```
Updates dependencies and reports any known CVEs in your current versions.
### Skip major version bumps
```bash
angela update --safe
```
Only applies minor and patch updates, skipping anything that crosses a major version boundary.
### Include pre-release versions
```bash
angela update --include-prerelease
```
Considers alpha, beta, release candidate, and dev versions when resolving the latest.
### Specify a different file
```bash
angela update --file path/to/pyproject.toml
```
### Clear the local cache
```bash
angela cache clear
```
Removes all cached PyPI responses from `~/.angela/cache/`.
---
## Example output
```
Scanning 9 dependencies...
Updates available:
django 3.2.0 -> 5.1.5 (major)
requests 2.28.0 -> 2.32.3 (minor)
click 8.0.0 -> 8.1.8 (patch)
pydantic 2.0.0 -> 2.10.6 (minor)
flask 2.0.0 -> 3.1.0 (major)
pytest 7.0.0 -> 8.3.4 (major)
black 23.0.0 -> 25.1.0 (major)
ruff 0.1.0 -> 0.9.4 (minor)
mypy 1.0.0 -> 1.14.1 (minor)
Vulnerabilities found:
django
GHSA-2hrw-hx67-34x6 [CRITICAL] Potential denial-of-service in django.utils.text.Truncator
Fixed in: 4.2.16
requests
GHSA-9wx4-h78v-vm56 [MEDIUM] Requests `Session` object does not verify requests after making first request with verify=False
Fixed in: 2.32.0
Updated pyproject.toml
9 packages checked
9 updated
36 vulnerabilities found
Done in 2.4s
```
---
## How it works
```
1. Parse pyproject.toml
Extract all [project.dependencies] and [project.optional-dependencies]
2. Query PyPI Simple API (parallel)
Fetch version lists using the lightweight JSON format
ETag-based caching avoids redundant downloads
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
Hydrate results with full advisory details
Deduplicate overlapping CVE/GHSA identifiers
5. Write updates
Regex-based surgery preserves comments and formatting
Atomic write via temp file + rename
```
---
## Architecture
```
cmd/angela/main.go CLI entry point
internal/
cli/
update.go Command definitions and orchestration
output.go Terminal formatting with color
pypi/
version.go PEP 440 parser and comparator
client.go PyPI Simple API client with retry
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
pkg/types/
types.go Shared domain types
```
---
## 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 HTML coverage report
just check # Lint + test in one step
just run check # Run angela check via go run
```
---
## Part of Cybersecurity-Projects
This tool is project #1 in the [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) repository — a collection of 60 security-focused projects built for learning and reference. The code is written to be educational: clear structure, proper error handling, and thorough testing.
See the [`learn/`](learn/) directory for deep dives into the techniques used here.
---
## 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.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.25.0 // indirect
)

View File

@ -0,0 +1,25 @@
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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -0,0 +1,218 @@
// ©AngelaMos | 2026
// output.go
package cli
import (
"fmt"
"strings"
"github.com/CarterPerez-dev/angela/internal/pypi"
"github.com/CarterPerez-dev/angela/pkg/types"
"github.com/fatih/color"
)
var (
bold = color.New(color.Bold).SprintFunc()
dim = color.New(color.Faint).SprintFunc()
cyan = color.New(color.FgCyan, color.Bold).SprintFunc()
green = color.New(color.FgGreen).SprintFunc()
yellow = color.New(color.FgYellow).SprintFunc()
red = color.New(color.FgRed).SprintFunc()
redBold = color.New(color.FgRed, color.Bold).SprintFunc()
greenBold = color.New(color.FgGreen, color.Bold).SprintFunc()
white = color.New(color.FgWhite, color.Bold).SprintFunc()
)
// PrintScanning announces the start of a scan
func PrintScanning(count int) {
fmt.Printf(
"\n %s %d dependencies...\n\n",
cyan("Scanning"), count,
)
}
// PrintUpdates displays a formatted table of available dependency updates
func PrintUpdates(updates []types.UpdateResult) {
var actionable []types.UpdateResult
for _, u := range updates {
if !u.Skipped {
actionable = append(actionable, u)
}
}
if len(actionable) == 0 {
fmt.Printf(" %s\n\n", dim("All dependencies are up to date."))
return
}
fmt.Printf(" %s\n", cyan("Updates available:"))
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\n",
nameWidth, white(u.Name),
dim(padRight(u.OldVer, oldWidth)),
dim("->"),
changeColor(u.NewVer),
dim("("+u.Change+")"),
)
}
fmt.Println()
}
// PrintSkipped displays dependencies that were not updated with reasons
func PrintSkipped(updates []types.UpdateResult) {
var skipped []types.UpdateResult
for _, u := range updates {
if u.Skipped {
skipped = append(skipped, u)
}
}
if len(skipped) == 0 {
return
}
fmt.Printf(" %s\n", dim("Skipped:"))
for _, u := range skipped {
fmt.Printf(" %s %s\n", dim(u.Name), dim(u.Reason))
}
fmt.Println()
}
// PrintVulnerabilities displays a formatted list of security advisories
func PrintVulnerabilities(
vulns map[string][]types.Vulnerability,
) {
if len(vulns) == 0 {
return
}
fmt.Printf(" %s\n", redBold("Vulnerabilities found:"))
for pkg, vlist := range vulns {
fmt.Printf(" %s\n", white(pkg))
for _, v := range vlist {
sevColor := severityColorFn(v.Severity)
fmt.Printf(
" %s %s %s\n",
bold(v.ID),
sevColor("["+v.Severity+"]"),
v.Summary,
)
if v.FixedIn != "" {
fmt.Printf(
" %s %s\n",
dim("Fixed in:"),
green(v.FixedIn),
)
}
if v.Link != "" {
fmt.Printf(
" %s\n",
dim(v.Link),
)
}
}
}
fmt.Println()
}
// PrintSummary displays final counts after an update or scan operation
func PrintSummary(result types.ScanResult, updated bool) {
if updated {
fmt.Printf(" %s\n", greenBold("Updated pyproject.toml"))
}
fmt.Printf(
" %s packages checked\n",
bold(fmt.Sprintf("%d", result.TotalPackages)),
)
if result.TotalUpdated > 0 {
fmt.Printf(
" %s updated\n",
bold(fmt.Sprintf("%d", result.TotalUpdated)),
)
}
if result.VulnsScanned {
if result.TotalVulns > 0 {
fmt.Printf(
" %s\n",
red(fmt.Sprintf(
"%d %s found",
result.TotalVulns,
pluralize("vulnerability", result.TotalVulns),
)),
)
} else {
fmt.Printf(
" %s\n",
green("No vulnerabilities found"),
)
}
}
fmt.Printf(
" %s %s\n\n",
dim("Done in"),
dim(result.Duration.Round(1e6).String()),
)
}
// PrintError displays a user-friendly error message
func PrintError(msg string) {
fmt.Printf("\n %s %s\n\n", red("error:"), msg)
}
func changeColorFn(kind string) func(a ...any) string {
switch kind {
case pypi.Major.String():
return red
case pypi.Minor.String():
return yellow
default:
return green
}
}
func severityColorFn(sev string) func(a ...any) string {
switch strings.ToUpper(sev) {
case "CRITICAL":
return redBold
case "HIGH":
return red
case "MEDIUM":
return yellow
case "LOW":
return cyan
default:
return dim
}
}
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,416 @@
// ©AngelaMos | 2026
// update.go
package cli
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"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/pkg/types"
"github.com/spf13/cobra"
)
type updateFlags struct {
file string
safe bool
scanVulns 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,
}
root.AddCommand(
newUpdateCmd(),
newCheckCmd(),
newScanCmd(),
newCacheCmd(),
)
if err := root.Execute(); err != nil {
PrintError(err.Error())
os.Exit(1)
}
}
func newUpdateCmd() *cobra.Command {
f := &updateFlags{}
cmd := &cobra.Command{
Use: "update",
Short: "Update dependencies to latest stable versions",
RunE: func(cmd *cobra.Command, _ []string) error {
return runUpdate(cmd.Context(), f, false)
},
}
cmd.Flags().StringVarP(
&f.file, "file", "f", "pyproject.toml",
"path to pyproject.toml",
)
cmd.Flags().BoolVar(
&f.safe, "safe", false,
"skip major version bumps",
)
cmd.Flags().BoolVar(
&f.scanVulns, "scan-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",
Short: "Show available updates without modifying files",
RunE: func(cmd *cobra.Command, _ []string) error {
f.scanVulns = true
return runUpdate(cmd.Context(), f, true)
},
}
cmd.Flags().StringVarP(
&f.file, "file", "f", "pyproject.toml",
"path to pyproject.toml",
)
cmd.Flags().BoolVar(
&f.safe, "safe", false,
"skip major version bumps",
)
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",
Short: "Scan dependencies for known vulnerabilities",
RunE: func(cmd *cobra.Command, _ []string) error {
return runScan(cmd.Context(), file)
},
}
cmd.Flags().StringVarP(
&file, "file", "f", "pyproject.toml",
"path to pyproject.toml",
)
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.Println(" Cache cleared.")
return nil
},
})
return cmd
}
func runUpdate(
ctx context.Context,
f *updateFlags,
dryRun bool,
) error {
start := time.Now()
deps, err := pyproject.ParseFile(f.file)
if err != nil {
return err
}
PrintScanning(len(deps))
client, err := pypi.NewClient(defaultCacheDir())
if err != nil {
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,
)
sortUpdates(updates)
var vulns map[string][]types.Vulnerability
if f.scanVulns {
vulns = scanForVulns(ctx, deps)
}
if !dryRun && len(updateSpecs) > 0 {
if err := pyproject.UpdateFile(f.file, updateSpecs); err != nil {
return fmt.Errorf("write updates: %w", err)
}
}
PrintUpdates(updates)
PrintSkipped(updates)
if vulns != nil {
PrintVulnerabilities(vulns)
}
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.scanVulns,
Duration: time.Since(start),
}, !dryRun && len(updateSpecs) > 0)
return nil
}
func runScan(ctx context.Context, file string) error {
start := time.Now()
deps, err := pyproject.ParseFile(file)
if err != nil {
return err
}
PrintScanning(len(deps))
vulns := scanForVulns(ctx, deps)
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,
) ([]types.UpdateResult, map[string]string) {
var results []types.UpdateResult
specs := make(map[string]string)
for _, dep := range deps {
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 {
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
}
client := osv.NewClient()
vulns, err := client.ScanPackages(ctx, queries)
if err != nil {
PrintError(fmt.Sprintf("vulnerability scan: %v", err))
return nil
}
return vulns
}
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 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,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 "MEDIUM"
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, "MEDIUM"},
{4.0, "MEDIUM"},
{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,190 @@
# Go Concurrency Patterns Used in angela
This document explains the concurrency patterns angela uses to fetch data from PyPI and OSV.dev in parallel. These are production patterns used at Uber, Google, and Cloudflare — not textbook exercises.
---
## The problem
angela needs to query PyPI for every dependency in your `pyproject.toml`. A typical project has 20-50 dependencies. Making those requests sequentially would take 10-25 seconds. Making them all at once would hammer PyPI with 50 simultaneous connections.
The solution: **bounded concurrency** — run up to N requests in parallel, queuing the rest.
---
## Pattern 1: errgroup.SetLimit
The `golang.org/x/sync/errgroup` package provides the cleanest API for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`:
```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()
```
Key decisions:
1. **`SetLimit(10)`** — caps concurrent HTTP requests. PyPI recommends 5-10.
2. **Always returns nil** — individual package failures are collected in results, not propagated. One failed package shouldn't cancel the others.
3. **Mutex protects shared slice**`results` is appended to from multiple goroutines.
### Why not channels?
A channel-based worker pool would work, but errgroup.SetLimit does the same thing with less code:
```go
// Channel approach: ~30 lines of setup
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 ...
// errgroup approach: ~15 lines
g.SetLimit(maxWorkers)
for _, name := range names {
g.Go(func() error { /* ... */ })
}
g.Wait()
```
The errgroup version is shorter, handles context cancellation automatically, and has no channel lifecycle to manage.
---
## Pattern 2: Panic recovery in goroutines
Every goroutine angela launches includes panic recovery:
```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
})
```
**Why this matters**: an unrecovered panic in a goroutine kills the entire process. In a CLI tool, that means the user sees a stack trace instead of a helpful error message. The `defer recover()` converts panics into errors that flow through the normal error path.
The named return `(err error)` is essential — it lets the deferred function assign the recovered panic as the return value.
---
## Pattern 3: Context cancellation
Every HTTP request in angela uses context:
```go
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
```
This means:
- If the user presses Ctrl+C, pending requests are cancelled
- If `errgroup.WithContext` detects an error, remaining work stops
- HTTP timeouts are enforced at the transport level
The context flows from `cobra.Command.Context()` through `runUpdate()` through `FetchAllVersions()` through `FetchVersions()` to the actual HTTP call. This is the standard Go pattern: context as the first parameter, threaded through the entire call chain.
---
## Pattern 4: Retry with exponential backoff
`internal/pypi/client.go` implements retry for 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
}
```
Design choices:
1. **Exponential backoff**`1<<shift` doubles the delay each attempt (500ms, 1s, 2s)
2. **Context-aware wait** — the `select` respects cancellation even during the delay
3. **Only retry server errors** — 5xx is retryable, 4xx is not
4. **Max 3 attempts** — enough to survive a transient blip, not enough to block the user
---
## Pattern 5: Mutex vs channel for result collection
angela uses `sync.Mutex` to protect the results slice, not a channel:
```go
var mu sync.Mutex
results := make([]FetchResult, 0, len(names))
// In each goroutine:
mu.Lock()
results = append(results, result)
mu.Unlock()
```
**When to use which:**
| 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 angela's case — appending results from concurrent workers — a mutex is simpler and allocates less.
---
## What to learn from this
1. **errgroup.SetLimit is the standard** — it replaced raw `go func()` + `sync.WaitGroup` + semaphore channels. Use it for any bounded concurrent work.
2. **Always recover panics in goroutines** — an unrecovered panic kills the process. Production code wraps every `g.Go` callback with `defer recover()`.
3. **Thread context through everything** — from CLI command to HTTP request, context enables cancellation and timeouts at every level.
4. **Choose the right concurrency primitive** — mutex for shared state, channels for data flow, errgroup for work coordination. Don't use channels when a mutex is clearer.
5. **`go test -race` is mandatory** — Go's race detector catches data races that code review misses. Run it in CI, always.

View File

@ -0,0 +1,143 @@
# PEP 440: Python Version Parsing from Scratch
This document explains how angela parses Python version strings — one of the hardest parts of the project. If you're building tools that interact with the Python ecosystem, understanding PEP 440 is non-negotiable.
---
## The problem
Python versions are **not** semantic versioning. They follow [PEP 440](https://peps.python.org/pep-0440/), which allows components that semver doesn't:
```
[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 — fixes to docs or metadata) |
| `1.0+ubuntu1` | Local version label (never on PyPI) |
---
## Why this matters for dependency tools
If you naively compare version strings, `1.0a1` looks "newer" than `1.0` because `a` > `` in ASCII. But in Python's world:
```
1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1
```
A tool that doesn't understand this will upgrade users to **unstable pre-releases**. That's why angela implements the full PEP 440 parser.
---
## How angela's parser works
The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to extract all 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 named 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 sentinel value `-1` for Post and Dev is critical — it distinguishes "not present" from "present with value 0". Both `1.0.post0` and `1.0.post` are valid (implicit zero), but they're different from `1.0` (no post-release at all).
---
## Normalization edge cases
PEP 440 allows multiple 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 handles all of these by normalizing during extraction.
---
## Version comparison: the tuple trick
Python's `packaging` library compares versions by converting them into tuples that sort naturally. angela uses the same idea:
```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 key insight is the sentinel values for sorting:
| 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 |
This produces the correct PEP 440 ordering without any special-case branching in the comparison loop.
---
## 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 are stable — `1.0.post1` is a documentation fix or metadata correction, not an unstable build.
---
## What to learn from this
1. **Regex can be the right tool** — a single compiled regex handles all PEP 440 forms in one pass. The alternative (hand-written state machine) would be 3x the code for no real benefit.
2. **Sentinel values simplify comparison** — using `math.MinInt` and `math.MaxInt` lets the comparison function be a clean sequence of `cmp.Compare` calls without branches.
3. **Test-driven development** — the version parser has 67 test cases across 10 functions. When implementing something spec-driven like PEP 440, write the test cases first from the spec, then make them pass.
4. **Normalization at parse time** — by normalizing spellings during parsing (alpha→a, preview→rc), the rest of the codebase never has to think about variant forms.

View File

@ -0,0 +1,169 @@
# Preserving TOML Comments: Why Regex Surgery is the Right Call
When angela updates version specifiers in `pyproject.toml`, it must preserve every comment, blank line, and formatting choice the developer made. This is harder than it sounds. This document explains the approach and why alternatives don't work.
---
## 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 — preserved.
---
## Why TOML libraries can't do this
Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling TOML into Go structs, then marshaling back:
```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 mechanism to store comment metadata on struct fields. The unmarshal/marshal round-trip fundamentally **destroys comments**. This isn't a bug — it's an architectural limitation of how Go TOML libraries work.
pelletier/go-toml v2 has an `unstable` package with AST access that preserves comments in the parse tree, but it provides no serialization — you'd have to write your own TOML emitter from the AST.
---
## The regex surgery approach
angela's solution: don't parse and re-serialize. Instead, treat the file as a byte buffer and surgically replace only the version specifier substring.
The implementation in `internal/pyproject/writer.go`:
1. **Build a regex** that matches the full dependency string including quotes:
```
"requests>=2.28.0"
```
2. **Capture groups** isolate the parts we need:
- Group 1: package name (`requests`)
- Group 2: extras (`[async]` or empty)
- Group 3: version specifier (`>=2.28.0`)
- Group 4: markers (`;python_version>='3.8'` or empty)
3. **Replace only group 3** (the version spec) while preserving everything else.
4. **Validate before and after** — feed the result through go-toml v2's unmarshaler to catch syntax errors.
```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 {
// Re-validate TOML syntax
var probe map[string]any
if err := toml.Unmarshal(u.content, &probe); err != nil {
return fmt.Errorf("update produced invalid TOML: %w", err)
}
return nil
}
}
return fmt.Errorf("dependency %q not found", pkg)
}
```
---
## 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 must 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, `[-_.]?`)
```
This means the pattern for `some-package` becomes `some[-_.]?package`, matching `some_package`, `some.package`, and `somepackage`.
### Go RE2 doesn't support backreferences
The TOML research suggested using `\1` to match the closing quote with the opening quote. Go's `regexp` package uses RE2, which doesn't support backreferences.
The fix: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character.
### Atomic file writes
Even the write is careful — angela writes to a `.tmp` file first, then renames over the original. If the process is killed mid-write, the original file is untouched:
```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
}
```
---
## This is how production tools do it
Renovate (GitHub's automated dependency updater) and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize — they surgically modify. The reason is the same: no TOML/YAML/JSON library in any language perfectly round-trips formatting and comments.
---
## What to learn from this
1. **Sometimes the "crude" approach is correct** — regex surgery sounds hacky, but it's the only way to preserve comments in TOML. The fancier approaches (AST manipulation, custom serializer) are more complex and still lose whitespace.
2. **Validate at boundaries** — angela validates TOML syntax before and after every edit. The regex does the surgery; go-toml v2 confirms the patient survived.
3. **Atomic writes prevent corruption** — write to temp + rename is the standard pattern for file updates in Unix. It's two syscalls, but it guarantees the file is never half-written.
4. **Know your regex engine** — Go uses RE2, which guarantees linear-time matching but doesn't support backreferences or lookaheads. Design patterns accordingly.

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
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
dev = [
"pytest>=7.0.0",
"black>=23.0.0", # Code formatter
"ruff>=0.1.0",
"mypy>=1.0.0",
]