feat: complete phase 1 intial build
This commit is contained in:
parent
f201800f73
commit
d9445e06b5
|
|
@ -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*
|
||||
|
|
@ -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
|
||||
|
|
@ -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."
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// ©AngelaMos | 2026
|
||||
// main.go
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/CarterPerez-dev/angela/internal/cli"
|
||||
|
||||
func main() {
|
||||
cli.Execute()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
]
|
||||
Loading…
Reference in New Issue