Merge pull request #49 from CarterPerez-dev/project/simple-vulnerability-scanner
Project/simple vulnerability scanner
This commit is contained in:
commit
a6921c9672
|
|
@ -0,0 +1,44 @@
|
|||
# AngelaMos | 2026
|
||||
# .gitignore
|
||||
|
||||
# Binaries
|
||||
bin/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
.meep
|
||||
|
||||
# Test
|
||||
*.test
|
||||
coverage.out
|
||||
*.prof
|
||||
trace.out
|
||||
|
||||
# Build
|
||||
tmp/
|
||||
|
||||
# Cache
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Debug
|
||||
__debug_bin*
|
||||
|
|
@ -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 --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,298 @@
|
|||
# angela
|
||||
|
||||
A fast Python dependency updater and vulnerability scanner, written in Go.
|
||||
|
||||
angela scans your `pyproject.toml` or `requirements.txt`, updates all dependencies to their latest stable versions, and checks for known CVEs using [OSV.dev](https://osv.dev) — in a single command.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **One command updates everything** in `pyproject.toml` or `requirements.txt`, like `pnpm update` for Python.
|
||||
- Fast parallel queries against the [PyPI Simple API](https://peps.python.org/pep-0691/) with local ETag caching.
|
||||
- Built-in vulnerability scanning via [OSV.dev](https://osv.dev) batch queries — no API key required.
|
||||
- Full [PEP 440](https://peps.python.org/pep-0440/) version parsing with pre-release filtering and epoch support.
|
||||
- Comment-preserving file updates — inline comments, section headers, and formatting stay intact.
|
||||
- Configurable via `.angela.toml` or `[tool.angela]` in `pyproject.toml`.
|
||||
- Single binary with zero runtime dependencies.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go install github.com/CarterPerez-dev/angela/cmd/angela@latest
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/CarterPerez-dev/angela.git
|
||||
cd angela
|
||||
go build -o angela ./cmd/angela
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Initialize a new pyproject.toml
|
||||
angela init
|
||||
|
||||
# Update all dependencies to latest stable versions
|
||||
angela update
|
||||
|
||||
# Preview updates without writing changes
|
||||
angela check
|
||||
|
||||
# Scan for known vulnerabilities
|
||||
angela scan
|
||||
|
||||
# Update + scan for vulnerabilities in one pass
|
||||
angela update --vulns
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `angela init`
|
||||
|
||||
Create a new `pyproject.toml` with angela configuration in the current directory.
|
||||
|
||||
```console
|
||||
$ angela init
|
||||
|
||||
Created pyproject.toml
|
||||
```
|
||||
|
||||
### `angela update`
|
||||
|
||||
Update all dependencies in `pyproject.toml` to their latest stable versions.
|
||||
|
||||
```console
|
||||
$ angela update
|
||||
|
||||
Scanning 9 dependencies...
|
||||
|
||||
Updates available:
|
||||
django 3.2.0 -> 6.0.1 (major)
|
||||
requests 2.28.0 -> 2.32.5 (minor)
|
||||
flask 2.0.0 -> 3.1.0 (major)
|
||||
click 8.0.0 -> 8.1.8 (patch)
|
||||
|
||||
Updated pyproject.toml
|
||||
9 packages checked
|
||||
4 updated
|
||||
Done in 1.8s
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
```
|
||||
-f, --file string Path to dependency file (default "pyproject.toml")
|
||||
--safe Skip major version bumps
|
||||
--vulns Also scan for vulnerabilities
|
||||
--include-prerelease Include pre-release versions (alpha, beta, rc, dev)
|
||||
```
|
||||
|
||||
### `angela check`
|
||||
|
||||
Show available updates without modifying any files.
|
||||
|
||||
```console
|
||||
$ angela check
|
||||
```
|
||||
|
||||
Accepts the same flags as `update`. Useful for CI or previewing changes before applying them.
|
||||
|
||||
### `angela scan`
|
||||
|
||||
Scan all dependencies for known vulnerabilities via OSV.dev.
|
||||
|
||||
```console
|
||||
$ angela scan
|
||||
|
||||
Scanning 9 dependencies...
|
||||
|
||||
Vulnerabilities: 36 across 4 packages
|
||||
|
||||
django (30 vulns: 5 critical, 15 high, 10 moderate)
|
||||
CRITICAL CVE-2022-28346 SQL Injection in Django Fixed: 2.2.28
|
||||
CRITICAL CVE-2022-34265 Trunc()/Extract() SQL Injection Fixed: 3.2.14
|
||||
HIGH CVE-2023-24580 Resource exhaustion Fixed: 3.2.18
|
||||
HIGH CVE-2022-36359 Reflected File Download attack Fixed: 3.2.15
|
||||
HIGH CVE-2023-46695 Potential denial-of-service Fixed: 4.2.8
|
||||
...and 25 more
|
||||
|
||||
requests (3 vulns: 3 moderate)
|
||||
MODERATE CVE-2024-35195 Session verify=False bypass Fixed: 2.32.0
|
||||
MODERATE CVE-2023-32681 Proxy-Authorization header leak Fixed: 2.31.0
|
||||
MODERATE CVE-2024-47081 .netrc credentials leak via URLs Fixed: 2.32.4
|
||||
|
||||
Run angela scan -v for full details.
|
||||
|
||||
9 packages checked
|
||||
36 vulnerabilities found
|
||||
Done in 2.4s
|
||||
```
|
||||
|
||||
Use `-v` for full vulnerability details including advisory links:
|
||||
|
||||
```console
|
||||
$ angela scan -v
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
```
|
||||
-f, --file string Path to dependency file (default "pyproject.toml")
|
||||
```
|
||||
|
||||
### `angela cache clear`
|
||||
|
||||
Remove all cached PyPI responses from `~/.angela/cache/`.
|
||||
|
||||
### Global flags
|
||||
|
||||
```
|
||||
-v, --verbose Show full vulnerability details
|
||||
--min-severity Minimum severity to report (critical, high, moderate, low)
|
||||
```
|
||||
|
||||
### Subcommand aliases
|
||||
|
||||
| Command | Alias |
|
||||
|---------|-------|
|
||||
| `angela update` | `angela u` |
|
||||
| `angela check` | `angela c` |
|
||||
| `angela scan` | `angela s` |
|
||||
|
||||
## File support
|
||||
|
||||
angela works with both `pyproject.toml` and `requirements.txt`. The file type is detected automatically by extension:
|
||||
|
||||
```bash
|
||||
# pyproject.toml (default)
|
||||
angela update
|
||||
|
||||
# requirements.txt
|
||||
angela update -f requirements.txt
|
||||
```
|
||||
|
||||
For `pyproject.toml`, angela reads `[project.dependencies]` and `[project.optional-dependencies]`. For `requirements.txt`, it parses standard pip-format lines, skipping comments, blank lines, and pip options (`-r`, `-e`, `--index-url`).
|
||||
|
||||
Both parsers handle PEP 508 dependency strings including extras (`[security,socks]`) and environment markers (`; python_version >= "3.8"`).
|
||||
|
||||
## Configuration
|
||||
|
||||
angela supports project-level configuration through either a standalone `.angela.toml` file or a `[tool.angela]` section in `pyproject.toml`.
|
||||
|
||||
### `.angela.toml`
|
||||
|
||||
```toml
|
||||
min-severity = "moderate"
|
||||
|
||||
ignore = [
|
||||
"django",
|
||||
"numpy",
|
||||
]
|
||||
|
||||
ignore-vulns = [
|
||||
"CVE-2024-3772",
|
||||
]
|
||||
```
|
||||
|
||||
### `[tool.angela]` in pyproject.toml
|
||||
|
||||
```toml
|
||||
[tool.angela]
|
||||
min-severity = "moderate"
|
||||
ignore = ["django", "numpy"]
|
||||
ignore-vulns = ["CVE-2024-3772"]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `min-severity` | string | Minimum severity to report: `critical`, `high`, `moderate`, `low` |
|
||||
| `ignore` | string array | Package names to skip during updates |
|
||||
| `ignore-vulns` | string array | Vulnerability IDs to suppress (CVE or GHSA) |
|
||||
|
||||
### Resolution order
|
||||
|
||||
1. CLI flags (`--min-severity`)
|
||||
2. `.angela.toml`
|
||||
3. `[tool.angela]` in `pyproject.toml`
|
||||
4. Defaults
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
1. Parse dependency file
|
||||
Read pyproject.toml or requirements.txt
|
||||
Extract package names and version specifiers
|
||||
|
||||
2. Query PyPI (parallel)
|
||||
Fetch version lists from the Simple API with JSON format
|
||||
Cache responses locally with ETag support (1-hour TTL)
|
||||
|
||||
3. Resolve updates
|
||||
Parse versions per PEP 440 (epochs, pre-releases, post-releases)
|
||||
Filter to stable releases by default
|
||||
Classify changes as major, minor, or patch
|
||||
|
||||
4. Scan for vulnerabilities (optional)
|
||||
Batch query OSV.dev for all dependencies in a single request
|
||||
Hydrate results with full advisory details
|
||||
Deduplicate overlapping CVE/GHSA identifiers
|
||||
|
||||
5. Write updates
|
||||
Regex-based surgery preserves all comments and formatting
|
||||
Atomic write via temp file + rename
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
cmd/angela/main.go Entry point
|
||||
internal/
|
||||
cli/
|
||||
update.go Commands and orchestration
|
||||
output.go Terminal formatting
|
||||
config/
|
||||
config.go Configuration loading
|
||||
pypi/
|
||||
client.go PyPI Simple API client with retry
|
||||
version.go PEP 440 parser and comparator
|
||||
cache.go File-backed ETag cache
|
||||
osv/
|
||||
client.go OSV.dev batch vulnerability scanner
|
||||
pyproject/
|
||||
parser.go TOML parsing and PEP 508 splitting
|
||||
writer.go Comment-preserving regex updater
|
||||
requirements/
|
||||
parser.go requirements.txt parsing
|
||||
writer.go requirements.txt updater
|
||||
pkg/types/
|
||||
types.go Shared domain types
|
||||
testdata/ Test fixtures
|
||||
learn/ Educational deep dives
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Requires Go 1.24+ and [just](https://github.com/casey/just).
|
||||
|
||||
```bash
|
||||
just test # Run all tests with race detector
|
||||
just lint # Run golangci-lint
|
||||
just build # Build binary to bin/angela
|
||||
just cover # Generate coverage report
|
||||
just ci # Lint + test in one step
|
||||
just run check # Run angela check via go run
|
||||
```
|
||||
|
||||
## Part of Cybersecurity-Projects
|
||||
|
||||
This tool is project #1 in [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) — a collection of 60 security-focused projects built for learning and reference.
|
||||
|
||||
The code is written to be educational: clear structure, proper error handling, and thorough testing. See the [`learn/`](learn/) directory for deep dives into the techniques used.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
@ -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.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
@ -0,0 +1,529 @@
|
|||
// ©AngelaMos | 2026
|
||||
// output.go
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/CarterPerez-dev/angela/internal/pypi"
|
||||
"github.com/CarterPerez-dev/angela/internal/ui"
|
||||
"github.com/CarterPerez-dev/angela/pkg/types"
|
||||
)
|
||||
|
||||
func printDivider() {
|
||||
fmt.Printf("\n %s\n", ui.HiBlack(ui.HRule(44)))
|
||||
}
|
||||
|
||||
func printSectionHeader(
|
||||
symbol, title string,
|
||||
symbolColor, titleColor func(a ...any) string,
|
||||
) {
|
||||
fmt.Printf(
|
||||
"\n %s %s\n",
|
||||
symbolColor(symbol),
|
||||
titleColor(title),
|
||||
)
|
||||
}
|
||||
|
||||
func PrintUpdates(updates []types.UpdateResult) {
|
||||
var actionable []types.UpdateResult
|
||||
for _, u := range updates {
|
||||
if !u.Skipped {
|
||||
actionable = append(actionable, u)
|
||||
}
|
||||
}
|
||||
|
||||
if len(actionable) == 0 {
|
||||
printDivider()
|
||||
fmt.Printf(
|
||||
"\n %s %s\n",
|
||||
ui.HiGreen(ui.Check),
|
||||
ui.HiBlackItalic(
|
||||
"All dependencies are up to date.",
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
printDivider()
|
||||
printSectionHeader(
|
||||
ui.Diamond, "Updates available",
|
||||
ui.Cyan, ui.Red,
|
||||
)
|
||||
|
||||
nameWidth := 0
|
||||
oldWidth := 0
|
||||
for _, u := range actionable {
|
||||
if len(u.Name) > nameWidth {
|
||||
nameWidth = len(u.Name)
|
||||
}
|
||||
if len(u.OldVer) > oldWidth {
|
||||
oldWidth = len(u.OldVer)
|
||||
}
|
||||
}
|
||||
|
||||
for _, u := range actionable {
|
||||
changeColor := changeColorFn(u.Change)
|
||||
fmt.Printf(
|
||||
" %-*s %s %s %s %s %s\n",
|
||||
nameWidth, ui.HiBlue(u.Name),
|
||||
ui.HiBlackCrossed(
|
||||
padRight(u.OldVer, oldWidth),
|
||||
),
|
||||
ui.Green(ui.Arrow),
|
||||
changeColor(u.NewVer),
|
||||
ui.HiBlack(ui.ArrowRight),
|
||||
changeColor(u.Change),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func PrintSkipped(updates []types.UpdateResult) {
|
||||
var skipped []types.UpdateResult
|
||||
for _, u := range updates {
|
||||
if u.Skipped {
|
||||
skipped = append(skipped, u)
|
||||
}
|
||||
}
|
||||
if len(skipped) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
printDivider()
|
||||
printSectionHeader(
|
||||
ui.ArrowRight, "Skipped",
|
||||
ui.Yellow, ui.Red,
|
||||
)
|
||||
|
||||
for _, u := range skipped {
|
||||
fmt.Printf(
|
||||
" %s %s\n",
|
||||
ui.HiBlue(u.Name),
|
||||
ui.HiBlackItalic(u.Reason),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func PrintVulnerabilities(
|
||||
vulns map[string][]types.Vulnerability,
|
||||
) {
|
||||
if len(vulns) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, vl := range vulns {
|
||||
total += len(vl)
|
||||
}
|
||||
|
||||
printDivider()
|
||||
fmt.Printf(
|
||||
"\n %s %s %s %s %s %s\n",
|
||||
ui.Red(ui.TriangleUp),
|
||||
ui.Red("Vulnerabilities:"),
|
||||
ui.RedBold(fmt.Sprintf("%d", total)),
|
||||
ui.HiBlack("across"),
|
||||
ui.RedBold(fmt.Sprintf("%d", len(vulns))),
|
||||
ui.HiBlack(pluralize("package", len(vulns))),
|
||||
)
|
||||
|
||||
pkgs := sortedVulnPackages(vulns)
|
||||
|
||||
if verbose {
|
||||
printVulnsVerbose(pkgs, vulns)
|
||||
} else {
|
||||
printVulnsCompact(pkgs, vulns)
|
||||
}
|
||||
}
|
||||
|
||||
const maxVulnsPerPackage = 5
|
||||
|
||||
func printVulnsCompact(
|
||||
pkgs []string,
|
||||
vulns map[string][]types.Vulnerability,
|
||||
) {
|
||||
for _, pkg := range pkgs {
|
||||
vlist := vulns[pkg]
|
||||
sortVulnsBySeverity(vlist)
|
||||
|
||||
fmt.Printf(
|
||||
"\n %s %s %s\n",
|
||||
ui.Blue(ui.Gem),
|
||||
ui.HiCyan(pkg),
|
||||
ui.HiBlack(fmt.Sprintf(
|
||||
"(%d %s: %s)",
|
||||
len(vlist),
|
||||
pluralize("vuln", len(vlist)),
|
||||
severityBreakdown(vlist),
|
||||
)),
|
||||
)
|
||||
|
||||
limit := min(maxVulnsPerPackage, len(vlist))
|
||||
for i := range limit {
|
||||
printVulnLine(vlist[i])
|
||||
}
|
||||
|
||||
if len(vlist) > maxVulnsPerPackage {
|
||||
fmt.Printf(
|
||||
" %s\n",
|
||||
ui.HiBlackItalic(fmt.Sprintf(
|
||||
"...and %d more",
|
||||
len(vlist)-maxVulnsPerPackage,
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"\n %s\n",
|
||||
ui.HiBlackItalic(
|
||||
"Run angela scan -v for full details.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func printVulnsVerbose(
|
||||
pkgs []string,
|
||||
vulns map[string][]types.Vulnerability,
|
||||
) {
|
||||
for _, pkg := range pkgs {
|
||||
vlist := vulns[pkg]
|
||||
sortVulnsBySeverity(vlist)
|
||||
|
||||
fmt.Printf(
|
||||
"\n %s %s %s\n",
|
||||
ui.Magenta(ui.Gem),
|
||||
ui.HiCyan(pkg),
|
||||
ui.HiBlack(fmt.Sprintf(
|
||||
"(%d %s: %s)",
|
||||
len(vlist),
|
||||
pluralize("vuln", len(vlist)),
|
||||
severityBreakdown(vlist),
|
||||
)),
|
||||
)
|
||||
|
||||
for _, v := range vlist {
|
||||
id := preferredID(v)
|
||||
sevColor := severityColorFn(v.Severity)
|
||||
|
||||
fmt.Printf(
|
||||
"\n %s %s\n",
|
||||
sevColor(
|
||||
padRight(strings.ToUpper(v.Severity), 8),
|
||||
),
|
||||
ui.Magenta(id),
|
||||
)
|
||||
fmt.Printf(
|
||||
" %s\n",
|
||||
ui.HiBlack(truncate(v.Summary, 72)),
|
||||
)
|
||||
if v.FixedIn != "" {
|
||||
fmt.Printf(
|
||||
" %s %s\n",
|
||||
ui.HiBlack("Fixed in:"),
|
||||
ui.Green(v.FixedIn),
|
||||
)
|
||||
}
|
||||
if v.Link != "" {
|
||||
fmt.Printf(
|
||||
" %s\n",
|
||||
ui.BlueItalic(v.Link),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func printVulnLine(v types.Vulnerability) {
|
||||
id := preferredID(v)
|
||||
sevColor := severityColorFn(v.Severity)
|
||||
|
||||
fixedStr := ""
|
||||
if v.FixedIn != "" {
|
||||
fixedStr = ui.HiBlack("Fixed: ") +
|
||||
ui.Green(v.FixedIn)
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
" %s %s %s %s\n",
|
||||
sevColor(
|
||||
padRight(strings.ToUpper(v.Severity), 8),
|
||||
),
|
||||
ui.Blue(padRight(id, 16)),
|
||||
ui.HiBlack(truncate(v.Summary, 38)),
|
||||
fixedStr,
|
||||
)
|
||||
}
|
||||
|
||||
func sortedVulnPackages(
|
||||
vulns map[string][]types.Vulnerability,
|
||||
) []string {
|
||||
pkgs := make([]string, 0, len(vulns))
|
||||
for k := range vulns {
|
||||
pkgs = append(pkgs, k)
|
||||
}
|
||||
sort.Slice(pkgs, func(i, j int) bool {
|
||||
li := len(vulns[pkgs[i]])
|
||||
lj := len(vulns[pkgs[j]])
|
||||
if li != lj {
|
||||
return li > lj
|
||||
}
|
||||
return pkgs[i] < pkgs[j]
|
||||
})
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func sortVulnsBySeverity(
|
||||
vulns []types.Vulnerability,
|
||||
) {
|
||||
sort.Slice(vulns, func(i, j int) bool {
|
||||
ri := severityRank(vulns[i].Severity)
|
||||
rj := severityRank(vulns[j].Severity)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return vulns[i].ID < vulns[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
func severityRank(sev string) int {
|
||||
switch strings.ToUpper(sev) {
|
||||
case "CRITICAL":
|
||||
return 0
|
||||
case "HIGH":
|
||||
return 1
|
||||
case "MODERATE":
|
||||
return 2
|
||||
case "LOW":
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
func severityBreakdown(
|
||||
vulns []types.Vulnerability,
|
||||
) string {
|
||||
counts := make(map[string]int)
|
||||
for _, v := range vulns {
|
||||
counts[strings.ToUpper(v.Severity)]++
|
||||
}
|
||||
|
||||
order := []string{
|
||||
"CRITICAL", "HIGH", "MODERATE", "LOW", "UNKNOWN",
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, sev := range order {
|
||||
if n := counts[sev]; n > 0 {
|
||||
sevColor := severityColorFn(sev)
|
||||
parts = append(parts, sevColor(fmt.Sprintf(
|
||||
"%d %s", n, strings.ToLower(sev),
|
||||
)))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ui.HiBlack(", "))
|
||||
}
|
||||
|
||||
func preferredID(v types.Vulnerability) string {
|
||||
for _, alias := range v.Aliases {
|
||||
if strings.HasPrefix(alias, "CVE-") {
|
||||
return alias
|
||||
}
|
||||
}
|
||||
return v.ID
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
func filterIgnoredVulns(
|
||||
vulns map[string][]types.Vulnerability,
|
||||
ignoreIDs []string,
|
||||
) map[string][]types.Vulnerability {
|
||||
if len(ignoreIDs) == 0 {
|
||||
return vulns
|
||||
}
|
||||
|
||||
ignored := make(map[string]bool, len(ignoreIDs))
|
||||
for _, id := range ignoreIDs {
|
||||
ignored[id] = true
|
||||
}
|
||||
|
||||
filtered := make(map[string][]types.Vulnerability)
|
||||
for pkg, vlist := range vulns {
|
||||
var kept []types.Vulnerability
|
||||
for _, v := range vlist {
|
||||
if isVulnIgnored(v, ignored) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, v)
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
filtered[pkg] = kept
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isVulnIgnored(
|
||||
v types.Vulnerability, ignored map[string]bool,
|
||||
) bool {
|
||||
if ignored[v.ID] {
|
||||
return true
|
||||
}
|
||||
for _, alias := range v.Aliases {
|
||||
if ignored[alias] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterVulnsBySeverity(
|
||||
vulns map[string][]types.Vulnerability,
|
||||
minSev string,
|
||||
) map[string][]types.Vulnerability {
|
||||
threshold := severityRank(minSev)
|
||||
filtered := make(map[string][]types.Vulnerability)
|
||||
for pkg, vlist := range vulns {
|
||||
var kept []types.Vulnerability
|
||||
for _, v := range vlist {
|
||||
if severityRank(v.Severity) <= threshold {
|
||||
kept = append(kept, v)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
filtered[pkg] = kept
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func PrintSummary(
|
||||
result types.ScanResult, updated bool,
|
||||
) {
|
||||
printDivider()
|
||||
printSectionHeader(
|
||||
ui.Star, "Summary",
|
||||
ui.Green, ui.RedItalic,
|
||||
)
|
||||
|
||||
if updated {
|
||||
fmt.Printf(
|
||||
" %s %s\n",
|
||||
ui.HiGreen(ui.Check),
|
||||
ui.HiGreen("Updated pyproject.toml"),
|
||||
)
|
||||
}
|
||||
fmt.Printf(
|
||||
" %s %s %s\n",
|
||||
ui.Green(ui.Check),
|
||||
ui.Green(fmt.Sprintf("%d", result.TotalPackages)),
|
||||
ui.GreenItalic("packages checked"),
|
||||
)
|
||||
if result.TotalUpdated > 0 {
|
||||
fmt.Printf(
|
||||
" %s %s %s\n",
|
||||
ui.Green(ui.ArrowUp),
|
||||
ui.Green(
|
||||
fmt.Sprintf("%d", result.TotalUpdated),
|
||||
),
|
||||
ui.GreenItalic("updated"),
|
||||
)
|
||||
}
|
||||
if result.VulnsScanned {
|
||||
if result.TotalVulns > 0 {
|
||||
fmt.Printf(
|
||||
" %s %s %s\n",
|
||||
ui.HiRed(ui.Cross),
|
||||
ui.RedBold(
|
||||
fmt.Sprintf("%d", result.TotalVulns),
|
||||
),
|
||||
ui.HiRed(
|
||||
pluralize(
|
||||
"vulnerability",
|
||||
result.TotalVulns,
|
||||
)+" found",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
fmt.Printf(
|
||||
" %s %s\n",
|
||||
ui.HiGreen(ui.Check),
|
||||
ui.HiGreen("No vulnerabilities found"),
|
||||
)
|
||||
}
|
||||
}
|
||||
fmt.Printf(
|
||||
" %s %s\n\n",
|
||||
ui.HiCyan(ui.Timer),
|
||||
ui.HiBlueItalic(
|
||||
result.Duration.Round(1e6).String(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func PrintError(msg string) {
|
||||
fmt.Printf(
|
||||
"\n %s %s %s\n\n",
|
||||
ui.RedBold(ui.Cross),
|
||||
ui.RedBold("error:"),
|
||||
ui.HiRed(msg),
|
||||
)
|
||||
}
|
||||
|
||||
func changeColorFn(
|
||||
kind string,
|
||||
) func(a ...any) string {
|
||||
switch kind {
|
||||
case pypi.Major.String():
|
||||
return ui.Red
|
||||
case pypi.Minor.String():
|
||||
return ui.HiYellow
|
||||
default:
|
||||
return ui.Green
|
||||
}
|
||||
}
|
||||
|
||||
func severityColorFn(
|
||||
sev string,
|
||||
) func(a ...any) string {
|
||||
switch strings.ToUpper(sev) {
|
||||
case "CRITICAL":
|
||||
return ui.Red
|
||||
case "HIGH":
|
||||
return ui.RedBold
|
||||
case "MODERATE":
|
||||
return ui.HiYellow
|
||||
case "LOW":
|
||||
return ui.Cyan
|
||||
default:
|
||||
return ui.HiBlack
|
||||
}
|
||||
}
|
||||
|
||||
func padRight(s string, width int) string {
|
||||
if len(s) >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-len(s))
|
||||
}
|
||||
|
||||
func pluralize(word string, n int) string {
|
||||
if n == 1 {
|
||||
return word
|
||||
}
|
||||
if strings.HasSuffix(word, "y") {
|
||||
return word[:len(word)-1] + "ies"
|
||||
}
|
||||
return word + "s"
|
||||
}
|
||||
|
|
@ -0,0 +1,614 @@
|
|||
// ©AngelaMos | 2026
|
||||
// update.go
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/CarterPerez-dev/angela/internal/config"
|
||||
"github.com/CarterPerez-dev/angela/internal/osv"
|
||||
"github.com/CarterPerez-dev/angela/internal/pypi"
|
||||
"github.com/CarterPerez-dev/angela/internal/pyproject"
|
||||
"github.com/CarterPerez-dev/angela/internal/requirements"
|
||||
"github.com/CarterPerez-dev/angela/internal/ui"
|
||||
"github.com/CarterPerez-dev/angela/pkg/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
verbose bool
|
||||
minSeverity string
|
||||
)
|
||||
|
||||
type updateFlags struct {
|
||||
file string
|
||||
safe bool
|
||||
vulns bool
|
||||
includePrerelease bool
|
||||
}
|
||||
|
||||
func defaultCacheDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return filepath.Join(home, ".angela", "cache")
|
||||
}
|
||||
|
||||
// Execute sets up the CLI and runs the root command
|
||||
func Execute() {
|
||||
root := &cobra.Command{
|
||||
Use: "angela",
|
||||
Short: "Python dependency updater and vulnerability scanner",
|
||||
Long: `angela scans your pyproject.toml, updates dependencies to their
|
||||
latest stable versions, and checks for known CVEs using OSV.dev.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRun: func(
|
||||
_ *cobra.Command, _ []string,
|
||||
) {
|
||||
ui.PrintBanner()
|
||||
},
|
||||
}
|
||||
|
||||
defaultHelp := root.HelpFunc()
|
||||
root.SetHelpFunc(
|
||||
func(cmd *cobra.Command, args []string) {
|
||||
if cmd.Root() == cmd {
|
||||
ui.PrintBannerWithArt()
|
||||
} else {
|
||||
ui.PrintBanner()
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
},
|
||||
)
|
||||
|
||||
root.PersistentFlags().BoolVarP(
|
||||
&verbose, "verbose", "v", false,
|
||||
"show full vulnerability details",
|
||||
)
|
||||
root.PersistentFlags().StringVar(
|
||||
&minSeverity, "min-severity", "",
|
||||
"minimum severity to report (critical, high, moderate, low)",
|
||||
)
|
||||
|
||||
root.AddCommand(
|
||||
newInitCmd(),
|
||||
newUpdateCmd(),
|
||||
newCheckCmd(),
|
||||
newScanCmd(),
|
||||
newCacheCmd(),
|
||||
)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
PrintError(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const pyprojectTemplate = `[project]
|
||||
name = "%s"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
requires-python = ">=3.13"
|
||||
dependencies = []
|
||||
|
||||
[tool.angela]
|
||||
# Minimum severity to report (critical, high, moderate, low)
|
||||
# min-severity = "moderate"
|
||||
|
||||
# Dependencies to skip during updates
|
||||
# ignore = []
|
||||
|
||||
# Vulnerability IDs to suppress (accepted risk)
|
||||
# ignore-vulns = []
|
||||
`
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Create a new pyproject.toml with angela configuration",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return runInit()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runInit() error {
|
||||
if _, err := os.Stat("pyproject.toml"); err == nil {
|
||||
return fmt.Errorf("pyproject.toml already exists")
|
||||
}
|
||||
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
|
||||
name := filepath.Base(dir)
|
||||
content := fmt.Sprintf(pyprojectTemplate, name)
|
||||
|
||||
if err := os.WriteFile(
|
||||
"pyproject.toml",
|
||||
[]byte(content),
|
||||
0o644,
|
||||
); err != nil { //nolint:gosec
|
||||
return fmt.Errorf("write pyproject.toml: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"\n %s %s\n\n",
|
||||
ui.HiGreen(ui.Check),
|
||||
ui.HiGreen("Created pyproject.toml"),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newUpdateCmd() *cobra.Command {
|
||||
f := &updateFlags{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "update [path]",
|
||||
Aliases: []string{"u"},
|
||||
Short: "Update dependencies to latest stable versions",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
f.file = resolveFile(f.file, args)
|
||||
return runUpdate(cmd.Context(), f, false)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&f.file, "file", "f", "pyproject.toml",
|
||||
"path to dependency file",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&f.safe, "safe", false,
|
||||
"skip major version bumps",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&f.vulns, "vulns", false,
|
||||
"also scan for vulnerabilities",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&f.includePrerelease, "include-prerelease", false,
|
||||
"include pre-release versions",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCheckCmd() *cobra.Command {
|
||||
f := &updateFlags{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "check [path]",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Show available updates without modifying files",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
f.file = resolveFile(f.file, args)
|
||||
return runUpdate(cmd.Context(), f, true)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&f.file, "file", "f", "pyproject.toml",
|
||||
"path to dependency file",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&f.safe, "safe", false,
|
||||
"skip major version bumps",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&f.vulns, "vulns", false,
|
||||
"also scan for vulnerabilities",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&f.includePrerelease, "include-prerelease", false,
|
||||
"include pre-release versions",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newScanCmd() *cobra.Command {
|
||||
var file string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "scan [path]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Scan dependencies for known vulnerabilities",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
file = resolveFile(file, args)
|
||||
return runScan(cmd.Context(), file)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&file, "file", "f", "pyproject.toml",
|
||||
"path to dependency file",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCacheCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cache",
|
||||
Short: "Manage the local response cache",
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "clear",
|
||||
Short: "Remove all cached PyPI responses",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
client, err := pypi.NewClient(defaultCacheDir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.ClearCache(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(
|
||||
" %s %s\n",
|
||||
ui.HiGreen(ui.Check),
|
||||
ui.HiGreen("Cache cleared."),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runUpdate(
|
||||
ctx context.Context,
|
||||
f *updateFlags,
|
||||
dryRun bool,
|
||||
) error {
|
||||
start := time.Now()
|
||||
cfg := config.Load(f.file)
|
||||
|
||||
deps, err := parseDeps(f.file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spin := ui.NewSpinner(fmt.Sprintf(
|
||||
"Scanning %d dependencies...", len(deps),
|
||||
))
|
||||
spin.Start()
|
||||
|
||||
client, err := pypi.NewClient(defaultCacheDir())
|
||||
if err != nil {
|
||||
spin.Stop()
|
||||
return err
|
||||
}
|
||||
|
||||
names := make([]string, len(deps))
|
||||
for i, d := range deps {
|
||||
names[i] = d.Name
|
||||
}
|
||||
fetched := client.FetchAllVersions(ctx, names)
|
||||
|
||||
versionMap := make(map[string][]string, len(fetched))
|
||||
for _, r := range fetched {
|
||||
if r.Err == nil {
|
||||
versionMap[r.Name] = r.Versions
|
||||
}
|
||||
}
|
||||
|
||||
updates, updateSpecs := resolveUpdates(
|
||||
deps, versionMap, f.safe,
|
||||
f.includePrerelease, cfg.Ignore,
|
||||
)
|
||||
|
||||
sortUpdates(updates)
|
||||
|
||||
var vulns map[string][]types.Vulnerability
|
||||
var scanErr error
|
||||
if f.vulns {
|
||||
vulns, scanErr = scanForVulns(ctx, deps)
|
||||
}
|
||||
|
||||
spin.Stop()
|
||||
|
||||
if scanErr != nil {
|
||||
PrintError(scanErr.Error())
|
||||
}
|
||||
|
||||
if !dryRun && len(updateSpecs) > 0 {
|
||||
if err := updateDepsFile(
|
||||
f.file, updateSpecs,
|
||||
); err != nil {
|
||||
return fmt.Errorf("write updates: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
minSev := resolveMinSeverity(cfg.MinSeverity)
|
||||
if vulns != nil {
|
||||
vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns)
|
||||
vulns = filterVulnsBySeverity(vulns, minSev)
|
||||
PrintVulnerabilities(vulns)
|
||||
}
|
||||
|
||||
PrintUpdates(updates)
|
||||
PrintSkipped(updates)
|
||||
|
||||
totalVulns := 0
|
||||
for _, vl := range vulns {
|
||||
totalVulns += len(vl)
|
||||
}
|
||||
|
||||
PrintSummary(types.ScanResult{
|
||||
Updates: updates,
|
||||
Vulnerabilities: vulns,
|
||||
TotalPackages: len(deps),
|
||||
TotalUpdated: len(updateSpecs),
|
||||
TotalVulns: totalVulns,
|
||||
VulnsScanned: f.vulns,
|
||||
Duration: time.Since(start),
|
||||
}, !dryRun && len(updateSpecs) > 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runScan(ctx context.Context, file string) error {
|
||||
start := time.Now()
|
||||
cfg := config.Load(file)
|
||||
|
||||
deps, err := parseDeps(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spin := ui.NewSpinner(fmt.Sprintf(
|
||||
"Scanning %d dependencies for vulnerabilities...",
|
||||
len(deps),
|
||||
))
|
||||
spin.Start()
|
||||
|
||||
minSev := resolveMinSeverity(cfg.MinSeverity)
|
||||
vulns, scanErr := scanForVulns(ctx, deps)
|
||||
|
||||
spin.Stop()
|
||||
|
||||
if scanErr != nil {
|
||||
PrintError(scanErr.Error())
|
||||
}
|
||||
|
||||
vulns = filterIgnoredVulns(vulns, cfg.IgnoreVulns)
|
||||
vulns = filterVulnsBySeverity(vulns, minSev)
|
||||
PrintVulnerabilities(vulns)
|
||||
|
||||
totalVulns := 0
|
||||
for _, vl := range vulns {
|
||||
totalVulns += len(vl)
|
||||
}
|
||||
|
||||
PrintSummary(types.ScanResult{
|
||||
TotalPackages: len(deps),
|
||||
TotalVulns: totalVulns,
|
||||
VulnsScanned: true,
|
||||
Duration: time.Since(start),
|
||||
}, false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveUpdates(
|
||||
deps []types.Dependency,
|
||||
versionMap map[string][]string,
|
||||
safe bool,
|
||||
includePrerelease bool,
|
||||
ignoreDeps []string,
|
||||
) ([]types.UpdateResult, map[string]string) {
|
||||
var results []types.UpdateResult
|
||||
specs := make(map[string]string)
|
||||
|
||||
ignoreSet := make(map[string]bool, len(ignoreDeps))
|
||||
for _, name := range ignoreDeps {
|
||||
ignoreSet[pypi.NormalizeName(name)] = true
|
||||
}
|
||||
|
||||
for _, dep := range deps {
|
||||
if ignoreSet[pypi.NormalizeName(dep.Name)] {
|
||||
results = append(results, types.UpdateResult{
|
||||
Name: dep.Name,
|
||||
Skipped: true,
|
||||
Reason: "ignored in config",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
versions, ok := versionMap[dep.Name]
|
||||
if !ok {
|
||||
results = append(results, types.UpdateResult{
|
||||
Name: dep.Name,
|
||||
Skipped: true,
|
||||
Reason: "not found on PyPI",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
currentStr := pyproject.ExtractMinVersion(dep.Spec)
|
||||
if currentStr == "" {
|
||||
results = append(results, types.UpdateResult{
|
||||
Name: dep.Name,
|
||||
Skipped: true,
|
||||
Reason: "no version specifier",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
current, err := pypi.ParseVersion(currentStr)
|
||||
if err != nil {
|
||||
results = append(results, types.UpdateResult{
|
||||
Name: dep.Name,
|
||||
Skipped: true,
|
||||
Reason: "unparseable version",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
var latest pypi.Version
|
||||
if includePrerelease {
|
||||
latest, err = latestAny(versions)
|
||||
} else {
|
||||
latest, err = pypi.LatestStable(versions)
|
||||
}
|
||||
if err != nil {
|
||||
results = append(results, types.UpdateResult{
|
||||
Name: dep.Name,
|
||||
Skipped: true,
|
||||
Reason: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if latest.Compare(current) <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
change := pypi.ClassifyChange(current, latest)
|
||||
if safe && change == pypi.Major {
|
||||
results = append(results, types.UpdateResult{
|
||||
Name: dep.Name,
|
||||
OldVer: current.String(),
|
||||
NewVer: latest.String(),
|
||||
Change: change.String(),
|
||||
Skipped: true,
|
||||
Reason: "major bump (use --all to include)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
newSpec := ">=" + latest.String()
|
||||
results = append(results, types.UpdateResult{
|
||||
Name: dep.Name,
|
||||
OldVer: current.String(),
|
||||
NewVer: latest.String(),
|
||||
OldSpec: dep.Spec,
|
||||
NewSpec: newSpec,
|
||||
Change: change.String(),
|
||||
})
|
||||
specs[dep.Name] = newSpec
|
||||
}
|
||||
|
||||
return results, specs
|
||||
}
|
||||
|
||||
func scanForVulns(
|
||||
ctx context.Context,
|
||||
deps []types.Dependency,
|
||||
) (map[string][]types.Vulnerability, error) {
|
||||
var queries []osv.PackageQuery
|
||||
for _, dep := range deps {
|
||||
ver := pyproject.ExtractMinVersion(dep.Spec)
|
||||
if ver == "" {
|
||||
continue
|
||||
}
|
||||
queries = append(queries, osv.PackageQuery{
|
||||
Name: dep.Name,
|
||||
Version: ver,
|
||||
})
|
||||
}
|
||||
|
||||
if len(queries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
client := osv.NewClient()
|
||||
vulns, err := client.ScanPackages(ctx, queries)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"vulnerability scan: %w", err,
|
||||
)
|
||||
}
|
||||
return vulns, nil
|
||||
}
|
||||
|
||||
func latestAny(versions []string) (pypi.Version, error) {
|
||||
var latest pypi.Version
|
||||
var found bool
|
||||
|
||||
for _, raw := range versions {
|
||||
v, err := pypi.ParseVersion(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !found || v.Compare(latest) > 0 {
|
||||
latest = v
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return pypi.Version{}, fmt.Errorf("no versions found")
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func resolveFile(flagVal string, args []string) string {
|
||||
if len(args) > 0 {
|
||||
path := args[0]
|
||||
info, err := os.Stat(path)
|
||||
if err == nil && info.IsDir() {
|
||||
return filepath.Join(path, "pyproject.toml")
|
||||
}
|
||||
return path
|
||||
}
|
||||
return flagVal
|
||||
}
|
||||
|
||||
func isRequirementsTxt(path string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(path), ".txt")
|
||||
}
|
||||
|
||||
func parseDeps(file string) ([]types.Dependency, error) {
|
||||
if isRequirementsTxt(file) {
|
||||
return requirements.ParseFile(file)
|
||||
}
|
||||
return pyproject.ParseFile(file)
|
||||
}
|
||||
|
||||
func updateDepsFile(file string, specs map[string]string) error {
|
||||
if isRequirementsTxt(file) {
|
||||
return requirements.UpdateFile(file, specs)
|
||||
}
|
||||
return pyproject.UpdateFile(file, specs)
|
||||
}
|
||||
|
||||
func resolveMinSeverity(configVal string) string {
|
||||
if minSeverity != "" {
|
||||
return minSeverity
|
||||
}
|
||||
if configVal != "" {
|
||||
return configVal
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
func sortUpdates(updates []types.UpdateResult) {
|
||||
order := map[string]int{
|
||||
pypi.Major.String(): 0,
|
||||
pypi.Minor.String(): 1,
|
||||
pypi.Patch.String(): 2,
|
||||
}
|
||||
sort.Slice(updates, func(i, j int) bool {
|
||||
if updates[i].Skipped != updates[j].Skipped {
|
||||
return !updates[i].Skipped
|
||||
}
|
||||
oi := order[updates[i].Change]
|
||||
oj := order[updates[j].Change]
|
||||
if oi != oj {
|
||||
return oi < oj
|
||||
}
|
||||
return updates[i].Name < updates[j].Name
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
// ©AngelaMos | 2026
|
||||
// config.go
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
toml "github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// Config holds project-level angela settings loaded from .angela.toml
|
||||
// or [tool.angela] in pyproject.toml
|
||||
type Config struct {
|
||||
MinSeverity string `toml:"min-severity"`
|
||||
Ignore []string `toml:"ignore"`
|
||||
IgnoreVulns []string `toml:"ignore-vulns"`
|
||||
}
|
||||
|
||||
type pyprojectWrapper struct {
|
||||
Tool struct {
|
||||
Angela Config `toml:"angela"`
|
||||
} `toml:"tool"`
|
||||
}
|
||||
|
||||
// Load reads angela configuration using a cascading resolution order:
|
||||
// .angela.toml in current directory, then [tool.angela] in pyproject.toml
|
||||
func Load(pyprojectPath string) Config {
|
||||
if cfg, err := loadFile(".angela.toml"); err == nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
if cfg, ok := loadFromPyproject(pyprojectPath); ok {
|
||||
return cfg
|
||||
}
|
||||
|
||||
return Config{}
|
||||
}
|
||||
|
||||
func loadFile(path string) (Config, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.MinSeverity = strings.ToLower(
|
||||
strings.TrimSpace(cfg.MinSeverity),
|
||||
)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadFromPyproject(path string) (Config, bool) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec
|
||||
if err != nil {
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
var wrapper pyprojectWrapper
|
||||
if err := toml.Unmarshal(data, &wrapper); err != nil {
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
cfg := wrapper.Tool.Angela
|
||||
if cfg.MinSeverity == "" &&
|
||||
len(cfg.Ignore) == 0 &&
|
||||
len(cfg.IgnoreVulns) == 0 {
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
cfg.MinSeverity = strings.ToLower(
|
||||
strings.TrimSpace(cfg.MinSeverity),
|
||||
)
|
||||
return cfg, true
|
||||
}
|
||||
|
|
@ -0,0 +1,401 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client.go
|
||||
|
||||
package osv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/CarterPerez-dev/angela/pkg/types"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
batchEndpoint = "https://api.osv.dev/v1/querybatch"
|
||||
vulnEndpoint = "https://api.osv.dev/v1/vulns/"
|
||||
userAgent = "angela/0.1.0 (https://github.com/CarterPerez-dev/angela)"
|
||||
maxHydrate = 15
|
||||
)
|
||||
|
||||
// Client queries the OSV.dev API for known vulnerabilities
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates an OSV client with sensible defaults
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// PackageQuery identifies a single package+version to check
|
||||
type PackageQuery struct {
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
type batchRequest struct {
|
||||
Queries []query `json:"queries"`
|
||||
}
|
||||
|
||||
type query struct {
|
||||
Package pkg `json:"package"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type pkg struct {
|
||||
Name string `json:"name"`
|
||||
Ecosystem string `json:"ecosystem"`
|
||||
}
|
||||
|
||||
type batchResponse struct {
|
||||
Results []batchResult `json:"results"`
|
||||
}
|
||||
|
||||
type batchResult struct {
|
||||
Vulns []vulnRef `json:"vulns"`
|
||||
}
|
||||
|
||||
type vulnRef struct {
|
||||
ID string `json:"id"`
|
||||
Modified string `json:"modified"`
|
||||
}
|
||||
|
||||
type osvVuln struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Severity []severity `json:"severity"`
|
||||
Affected []affected `json:"affected"`
|
||||
References []reference `json:"references"`
|
||||
DatabaseSpecific map[string]any `json:"database_specific"`
|
||||
}
|
||||
|
||||
type severity struct {
|
||||
Type string `json:"type"`
|
||||
Score string `json:"score"`
|
||||
}
|
||||
|
||||
type affected struct {
|
||||
Package pkg `json:"package"`
|
||||
Ranges []rng `json:"ranges"`
|
||||
}
|
||||
|
||||
type rng struct {
|
||||
Type string `json:"type"`
|
||||
Events []event `json:"events"`
|
||||
}
|
||||
|
||||
type event struct {
|
||||
Introduced string `json:"introduced,omitempty"`
|
||||
Fixed string `json:"fixed,omitempty"`
|
||||
}
|
||||
|
||||
type reference struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ScanPackages checks a list of packages for known vulnerabilities and returns
|
||||
// per-package results with full vulnerability details. Duplicates caused by
|
||||
// overlapping CVE/GHSA identifiers are removed.
|
||||
func (c *Client) ScanPackages(
|
||||
ctx context.Context,
|
||||
packages []PackageQuery,
|
||||
) (map[string][]types.Vulnerability, error) {
|
||||
if len(packages) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
batch, err := c.queryBatch(ctx, packages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("osv batch query: %w", err)
|
||||
}
|
||||
|
||||
allIDs := collectUniqueIDs(batch)
|
||||
if len(allIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
vulnMap, err := c.hydrateAll(ctx, allIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("osv hydrate: %w", err)
|
||||
}
|
||||
|
||||
return buildResults(packages, batch, vulnMap), nil
|
||||
}
|
||||
|
||||
func (c *Client) queryBatch(
|
||||
ctx context.Context,
|
||||
packages []PackageQuery,
|
||||
) (*batchResponse, error) {
|
||||
queries := make([]query, len(packages))
|
||||
for i, p := range packages {
|
||||
queries[i] = query{
|
||||
Package: pkg{Name: p.Name, Ecosystem: "PyPI"},
|
||||
Version: p.Version,
|
||||
}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(batchRequest{Queries: queries})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx, http.MethodPost, batchEndpoint,
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("osv returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result batchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode batch response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchVuln(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
) (*osvVuln, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx, http.MethodGet, vulnEndpoint+id, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf(
|
||||
"osv vuln %s returned %d", id, resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var v osvVuln
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, fmt.Errorf("decode vuln %s: %w", id, err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (c *Client) hydrateAll(
|
||||
ctx context.Context,
|
||||
ids []string,
|
||||
) (map[string]*osvVuln, error) {
|
||||
var mu sync.Mutex
|
||||
result := make(map[string]*osvVuln, len(ids))
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(maxHydrate)
|
||||
|
||||
for _, id := range ids {
|
||||
g.Go(func() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf(
|
||||
"panic hydrating %s: %v", id, r,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
v, fetchErr := c.fetchVuln(ctx, id)
|
||||
if fetchErr != nil {
|
||||
return fetchErr
|
||||
}
|
||||
mu.Lock()
|
||||
result[id] = v
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func collectUniqueIDs(batch *batchResponse) []string {
|
||||
seen := make(map[string]bool)
|
||||
var ids []string
|
||||
for _, r := range batch.Results {
|
||||
for _, v := range r.Vulns {
|
||||
if !seen[v.ID] {
|
||||
seen[v.ID] = true
|
||||
ids = append(ids, v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func buildResults(
|
||||
packages []PackageQuery,
|
||||
batch *batchResponse,
|
||||
vulnMap map[string]*osvVuln,
|
||||
) map[string][]types.Vulnerability {
|
||||
results := make(map[string][]types.Vulnerability)
|
||||
|
||||
for i, br := range batch.Results {
|
||||
if len(br.Vulns) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := packages[i].Name
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, vr := range br.Vulns {
|
||||
ov, ok := vulnMap[vr.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if isDuplicate(ov, seen) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[ov.ID] = true
|
||||
for _, alias := range ov.Aliases {
|
||||
seen[alias] = true
|
||||
}
|
||||
|
||||
results[name] = append(results[name], toVulnerability(ov))
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func isDuplicate(v *osvVuln, seen map[string]bool) bool {
|
||||
if seen[v.ID] {
|
||||
return true
|
||||
}
|
||||
for _, alias := range v.Aliases {
|
||||
if seen[alias] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toVulnerability(v *osvVuln) types.Vulnerability {
|
||||
return types.Vulnerability{
|
||||
ID: v.ID,
|
||||
Aliases: v.Aliases,
|
||||
Summary: v.Summary,
|
||||
Severity: extractSeverity(v),
|
||||
FixedIn: extractFixed(v.Affected),
|
||||
Link: extractLink(v.References),
|
||||
}
|
||||
}
|
||||
|
||||
func extractSeverity(v *osvVuln) string {
|
||||
for _, s := range v.Severity {
|
||||
if s.Type != "CVSS_V3" && s.Type != "CVSS_V4" {
|
||||
continue
|
||||
}
|
||||
|
||||
if score, err := strconv.ParseFloat(s.Score, 64); err == nil {
|
||||
return classifyScore(score)
|
||||
}
|
||||
|
||||
if idx := strings.LastIndex(s.Score, "/"); idx >= 0 {
|
||||
chunk := s.Score[idx+1:]
|
||||
if score, err := strconv.ParseFloat(chunk, 64); err == nil {
|
||||
return classifyScore(score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v.DatabaseSpecific != nil {
|
||||
if sev, ok := v.DatabaseSpecific["severity"].(string); ok {
|
||||
return strings.ToUpper(sev)
|
||||
}
|
||||
}
|
||||
|
||||
return "UNKNOWN"
|
||||
}
|
||||
|
||||
func classifyScore(score float64) string {
|
||||
switch {
|
||||
case score >= 9.0:
|
||||
return "CRITICAL"
|
||||
case score >= 7.0:
|
||||
return "HIGH"
|
||||
case score >= 4.0:
|
||||
return "MODERATE"
|
||||
case score > 0:
|
||||
return "LOW"
|
||||
default:
|
||||
return "NONE"
|
||||
}
|
||||
}
|
||||
|
||||
func extractFixed(affected []affected) string {
|
||||
for _, a := range affected {
|
||||
if !strings.EqualFold(a.Package.Ecosystem, "PyPI") {
|
||||
continue
|
||||
}
|
||||
for _, r := range a.Ranges {
|
||||
for _, e := range r.Events {
|
||||
if e.Fixed != "" {
|
||||
return e.Fixed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractLink(refs []reference) string {
|
||||
for _, r := range refs {
|
||||
if r.Type == "ADVISORY" {
|
||||
return r.URL
|
||||
}
|
||||
}
|
||||
for _, r := range refs {
|
||||
if r.Type == "WEB" || r.Type == "REPORT" {
|
||||
return r.URL
|
||||
}
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
return refs[0].URL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client_test.go
|
||||
|
||||
package osv
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractSeverityCVSSV3(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := &osvVuln{
|
||||
Severity: []severity{
|
||||
{Type: "CVSS_V3", Score: "9.8"},
|
||||
},
|
||||
}
|
||||
got := extractSeverity(v)
|
||||
if got != "CRITICAL" { //nolint:goconst
|
||||
t.Errorf("extractSeverity = %q, want CRITICAL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSeverityCVSSV4(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := &osvVuln{
|
||||
Severity: []severity{
|
||||
{Type: "CVSS_V4", Score: "7.5"},
|
||||
},
|
||||
}
|
||||
got := extractSeverity(v)
|
||||
if got != "HIGH" {
|
||||
t.Errorf("extractSeverity = %q, want HIGH", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSeverityCVSSVector(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := &osvVuln{
|
||||
Severity: []severity{
|
||||
{
|
||||
Type: "CVSS_V3",
|
||||
Score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/9.8",
|
||||
},
|
||||
},
|
||||
}
|
||||
got := extractSeverity(v)
|
||||
if got != "CRITICAL" {
|
||||
t.Errorf("extractSeverity = %q, want CRITICAL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSeverityDatabaseSpecific(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := &osvVuln{
|
||||
DatabaseSpecific: map[string]any{
|
||||
"severity": "MODERATE",
|
||||
},
|
||||
}
|
||||
got := extractSeverity(v)
|
||||
if got != "MODERATE" {
|
||||
t.Errorf("extractSeverity = %q, want MODERATE", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSeverityUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := &osvVuln{}
|
||||
got := extractSeverity(v)
|
||||
if got != "UNKNOWN" {
|
||||
t.Errorf("extractSeverity = %q, want UNKNOWN", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyScore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
score float64
|
||||
want string
|
||||
}{
|
||||
{9.8, "CRITICAL"},
|
||||
{9.0, "CRITICAL"},
|
||||
{8.5, "HIGH"},
|
||||
{7.0, "HIGH"},
|
||||
{6.9, "MODERATE"},
|
||||
{4.0, "MODERATE"},
|
||||
{3.9, "LOW"},
|
||||
{0.1, "LOW"},
|
||||
{0.0, "NONE"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := classifyScore(tt.score)
|
||||
if got != tt.want {
|
||||
t.Errorf(
|
||||
"classifyScore(%v) = %q, want %q",
|
||||
tt.score, got, tt.want,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDuplicate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
seen := map[string]bool{
|
||||
"CVE-2023-1234": true,
|
||||
}
|
||||
|
||||
v1 := &osvVuln{
|
||||
ID: "GHSA-xxxx-yyyy",
|
||||
Aliases: []string{"CVE-2023-1234"},
|
||||
}
|
||||
if !isDuplicate(v1, seen) {
|
||||
t.Error("should detect duplicate via alias")
|
||||
}
|
||||
|
||||
v2 := &osvVuln{
|
||||
ID: "CVE-2023-1234",
|
||||
Aliases: []string{},
|
||||
}
|
||||
if !isDuplicate(v2, seen) {
|
||||
t.Error("should detect duplicate via direct ID")
|
||||
}
|
||||
|
||||
v3 := &osvVuln{
|
||||
ID: "PYSEC-2024-001",
|
||||
Aliases: []string{"CVE-2024-9999"},
|
||||
}
|
||||
if isDuplicate(v3, seen) {
|
||||
t.Error("should not flag non-duplicate as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFixed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aff := []affected{
|
||||
{
|
||||
Package: pkg{Name: "django", Ecosystem: "PyPI"},
|
||||
Ranges: []rng{
|
||||
{
|
||||
Type: "ECOSYSTEM",
|
||||
Events: []event{
|
||||
{Introduced: "0"},
|
||||
{Fixed: "4.2.8"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := extractFixed(aff)
|
||||
if got != "4.2.8" {
|
||||
t.Errorf("extractFixed = %q, want 4.2.8", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFixedNoFix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aff := []affected{
|
||||
{
|
||||
Package: pkg{Name: "django", Ecosystem: "PyPI"},
|
||||
Ranges: []rng{
|
||||
{
|
||||
Type: "ECOSYSTEM",
|
||||
Events: []event{{Introduced: "0"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := extractFixed(aff)
|
||||
if got != "" {
|
||||
t.Errorf("extractFixed = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
refs := []reference{
|
||||
{Type: "WEB", URL: "https://example.com"},
|
||||
{
|
||||
Type: "ADVISORY",
|
||||
URL: "https://nvd.nist.gov/vuln/detail/CVE-2023-1234",
|
||||
},
|
||||
}
|
||||
|
||||
got := extractLink(refs)
|
||||
if got != "https://nvd.nist.gov/vuln/detail/CVE-2023-1234" {
|
||||
t.Errorf("extractLink preferred ADVISORY, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLinkFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
refs := []reference{
|
||||
{Type: "PACKAGE", URL: "https://pypi.org/project/django"},
|
||||
{Type: "WEB", URL: "https://example.com/info"},
|
||||
}
|
||||
|
||||
got := extractLink(refs)
|
||||
if got != "https://example.com/info" {
|
||||
t.Errorf("extractLink should fall back to WEB, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLinkEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := extractLink(nil)
|
||||
if got != "" {
|
||||
t.Errorf("extractLink(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUniqueIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
batch := &batchResponse{
|
||||
Results: []batchResult{
|
||||
{Vulns: []vulnRef{
|
||||
{ID: "CVE-A"},
|
||||
{ID: "CVE-B"},
|
||||
}},
|
||||
{Vulns: []vulnRef{
|
||||
{ID: "CVE-B"},
|
||||
{ID: "CVE-C"},
|
||||
}},
|
||||
{Vulns: nil},
|
||||
},
|
||||
}
|
||||
|
||||
ids := collectUniqueIDs(batch)
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(ids))
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
t.Errorf("duplicate ID: %s", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
|
|
@ -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,91 @@
|
|||
// ©AngelaMos | 2026
|
||||
// parser.go
|
||||
|
||||
package requirements
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/CarterPerez-dev/angela/pkg/types"
|
||||
)
|
||||
|
||||
// ParseFile reads a requirements.txt and extracts all dependency declarations
|
||||
func ParseFile(path string) ([]types.Dependency, error) {
|
||||
f, err := os.Open(path) //nolint:gosec
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf(
|
||||
"open %s: file not found", path,
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }() //nolint:errcheck
|
||||
|
||||
var deps []types.Dependency
|
||||
scanner := bufio.NewScanner(f)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if line == "" ||
|
||||
strings.HasPrefix(line, "#") ||
|
||||
strings.HasPrefix(line, "-") {
|
||||
continue
|
||||
}
|
||||
|
||||
if idx := strings.Index(line, " #"); idx >= 0 {
|
||||
line = strings.TrimSpace(line[:idx])
|
||||
}
|
||||
|
||||
dep := parseLine(line)
|
||||
if dep.Name != "" {
|
||||
deps = append(deps, dep)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
if len(deps) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"%s: no dependencies found", path,
|
||||
)
|
||||
}
|
||||
return deps, nil
|
||||
}
|
||||
|
||||
func parseLine(s string) types.Dependency {
|
||||
var dep types.Dependency
|
||||
|
||||
if idx := strings.Index(s, ";"); idx >= 0 {
|
||||
dep.Markers = strings.TrimSpace(s[idx+1:])
|
||||
s = strings.TrimSpace(s[:idx])
|
||||
}
|
||||
|
||||
if start := strings.Index(s, "["); start >= 0 {
|
||||
end := strings.Index(s, "]")
|
||||
if end > start {
|
||||
for _, e := range strings.Split(s[start+1:end], ",") {
|
||||
dep.Extras = append(
|
||||
dep.Extras,
|
||||
strings.TrimSpace(e),
|
||||
)
|
||||
}
|
||||
s = s[:start] + s[end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(s, "><=!~"); idx >= 0 {
|
||||
dep.Name = strings.TrimSpace(s[:idx])
|
||||
dep.Spec = strings.TrimSpace(s[idx:])
|
||||
} else {
|
||||
dep.Name = strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
return dep
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
// ©AngelaMos | 2026
|
||||
// parser_test.go
|
||||
|
||||
package requirements
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := `# Core deps
|
||||
django>=3.2.0
|
||||
requests==2.28.1
|
||||
flask>=2.0,<3.0
|
||||
numpy # pinned
|
||||
click[extra]>=8.0.0; python_version>="3.8"
|
||||
|
||||
# Options to skip
|
||||
-r other.txt
|
||||
-e ./local-pkg
|
||||
--index-url https://pypi.org/simple/
|
||||
`
|
||||
|
||||
path := filepath.Join(t.TempDir(), "requirements.txt")
|
||||
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
|
||||
|
||||
deps, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
if len(deps) != 5 {
|
||||
t.Fatalf("len = %d, want 5", len(deps))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec string
|
||||
}{
|
||||
{"django", ">=3.2.0"},
|
||||
{"requests", "==2.28.1"},
|
||||
{"flask", ">=2.0,<3.0"},
|
||||
{"numpy", ""},
|
||||
{"click", ">=8.0.0"},
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
if deps[i].Name != tt.name {
|
||||
t.Errorf("deps[%d].Name = %q, want %q", i, deps[i].Name, tt.name)
|
||||
}
|
||||
if deps[i].Spec != tt.spec {
|
||||
t.Errorf("deps[%d].Spec = %q, want %q", i, deps[i].Spec, tt.spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileExtras(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := "requests[security,socks]>=2.28.0\n"
|
||||
path := filepath.Join(t.TempDir(), "requirements.txt")
|
||||
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
|
||||
|
||||
deps, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
if len(deps) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(deps))
|
||||
}
|
||||
|
||||
if len(deps[0].Extras) != 2 {
|
||||
t.Fatalf("extras len = %d, want 2", len(deps[0].Extras))
|
||||
}
|
||||
if deps[0].Extras[0] != "security" || deps[0].Extras[1] != "socks" {
|
||||
t.Errorf("extras = %v, want [security socks]", deps[0].Extras)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileMarkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := "pywin32>=300; sys_platform==\"win32\"\n"
|
||||
path := filepath.Join(t.TempDir(), "requirements.txt")
|
||||
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
|
||||
|
||||
deps, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
if deps[0].Markers != `sys_platform=="win32"` {
|
||||
t.Errorf("markers = %q, want sys_platform==\"win32\"", deps[0].Markers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := "# just comments\n\n-r other.txt\n"
|
||||
path := filepath.Join(t.TempDir(), "requirements.txt")
|
||||
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
|
||||
|
||||
_, err := ParseFile(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty requirements")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseFile("/nonexistent/requirements.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
// ©AngelaMos | 2026
|
||||
// writer.go
|
||||
|
||||
package requirements
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/CarterPerez-dev/angela/internal/pypi"
|
||||
)
|
||||
|
||||
// UpdateFile replaces version specifiers for the given packages in a
|
||||
// requirements.txt file, preserving all comments and formatting
|
||||
func UpdateFile(path string, updates map[string]string) error {
|
||||
data, err := os.ReadFile(path) //nolint:gosec
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
content := data
|
||||
for pkg, spec := range updates {
|
||||
pattern := buildPattern(pkg)
|
||||
found := false
|
||||
content = pattern.ReplaceAllFunc(
|
||||
content,
|
||||
func(match []byte) []byte {
|
||||
found = true
|
||||
return replaceSpec(pattern, match, spec)
|
||||
},
|
||||
)
|
||||
if !found {
|
||||
return fmt.Errorf(
|
||||
"dependency %q not found in %s", pkg, path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, content, 0o600); err != nil {
|
||||
return fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp) //nolint:errcheck
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildPattern(name string) *regexp.Regexp {
|
||||
normalized := pypi.NormalizeName(name)
|
||||
parts := strings.Split(normalized, "-")
|
||||
for i, p := range parts {
|
||||
parts[i] = regexp.QuoteMeta(p)
|
||||
}
|
||||
namePattern := strings.Join(parts, `[-_.]?`)
|
||||
|
||||
return regexp.MustCompile(
|
||||
`(?im)^(` + namePattern + `)` +
|
||||
`(\[[^\]]*\])?` +
|
||||
`(\s*[><=!~][^\n;#]*)`,
|
||||
)
|
||||
}
|
||||
|
||||
func replaceSpec(
|
||||
re *regexp.Regexp, match []byte, newSpec string,
|
||||
) []byte {
|
||||
groups := re.FindSubmatch(match)
|
||||
if len(groups) < 4 {
|
||||
return match
|
||||
}
|
||||
|
||||
var b []byte
|
||||
b = append(b, groups[1]...)
|
||||
b = append(b, groups[2]...)
|
||||
b = append(b, []byte(newSpec)...)
|
||||
return b
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
// ©AngelaMos | 2026
|
||||
// writer_test.go
|
||||
|
||||
package requirements
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := `# Core deps
|
||||
django>=3.2.0
|
||||
requests==2.28.1 # HTTP client
|
||||
flask>=2.0.0
|
||||
`
|
||||
|
||||
path := filepath.Join(t.TempDir(), "requirements.txt")
|
||||
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
|
||||
|
||||
updates := map[string]string{
|
||||
"django": ">=6.0.1",
|
||||
"requests": ">=2.32.5",
|
||||
}
|
||||
|
||||
if err := UpdateFile(path, updates); err != nil {
|
||||
t.Fatalf("UpdateFile: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path) //nolint:errcheck,gosec
|
||||
result := string(data)
|
||||
|
||||
if !strings.Contains(result, "django>=6.0.1") {
|
||||
t.Error("django not updated")
|
||||
}
|
||||
if !strings.Contains(result, "requests>=2.32.5") {
|
||||
t.Error("requests not updated")
|
||||
}
|
||||
if !strings.Contains(result, "# Core deps") {
|
||||
t.Error("comment lost")
|
||||
}
|
||||
if !strings.Contains(result, "# HTTP client") {
|
||||
t.Error("inline comment lost")
|
||||
}
|
||||
if !strings.Contains(result, "flask>=2.0.0") {
|
||||
t.Error("flask was incorrectly modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilePreservesFormatting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := "Django>=3.2.0\nnumpy==1.24.0\n"
|
||||
path := filepath.Join(t.TempDir(), "requirements.txt")
|
||||
os.WriteFile(path, []byte(content), 0o600) //nolint:errcheck
|
||||
|
||||
updates := map[string]string{
|
||||
"django": ">=6.0.1",
|
||||
}
|
||||
|
||||
if err := UpdateFile(path, updates); err != nil {
|
||||
t.Fatalf("UpdateFile: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path) //nolint:errcheck,gosec
|
||||
result := string(data)
|
||||
|
||||
if !strings.Contains(result, "Django>=6.0.1") {
|
||||
t.Error("original casing not preserved")
|
||||
}
|
||||
if !strings.Contains(result, "numpy==1.24.0") {
|
||||
t.Error("numpy was incorrectly modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFileNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "requirements.txt")
|
||||
os.WriteFile(path, []byte("flask>=2.0.0\n"), 0o600) //nolint:errcheck
|
||||
|
||||
err := UpdateFile(path, map[string]string{
|
||||
"nonexistent": ">=1.0.0",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for missing dependency")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
// ©AngelaMos | 2026
|
||||
// banner.go
|
||||
|
||||
package ui
|
||||
|
||||
import "fmt"
|
||||
|
||||
var angelaBanner = []string{
|
||||
" ▄▀▄ █▄ █ ▄▀ ██▀ █ ▄▀▄ ",
|
||||
" █▀█ █ ▀█ ▀▄█ █▄▄ █▄▄ █▀█ ",
|
||||
}
|
||||
|
||||
var bannerColors = []func(a ...any) string{
|
||||
Red,
|
||||
Blue,
|
||||
}
|
||||
|
||||
var animeArt = []string{
|
||||
"⣿⣿⣿⡷⠊⡢⡹⣦⡑⢂⢕⢂⢕⢂⢕⢂⠕⠔⠌⠝⠛⠶⠶⢶⣦⣄⢂⢕⢂⢕",
|
||||
"⣿⣿⠏⣠⣾⣦⡐⢌⢿⣷⣦⣅⡑⠕⠡⠐⢿⠿⣛⠟⠛⠛⠛⠛⠡⢷⡈⢂⢕⢂",
|
||||
"⠟⣡⣾⣿⣿⣿⣿⣦⣑⠝⢿⣿⣿⣿⣿⣿⡵⢁⣤⣶⣶⣿⢿⢿⢿⡟⢻⣤⢑⢂",
|
||||
"⣾⣿⣿⡿⢟⣛⣻⣿⣿⣿⣦⣬⣙⣻⣿⣿⣷⣿⣿⢟⢝⢕⢕⢕⢕⢽⣿⣿⣷⣔",
|
||||
"⣿⣿⠵⠚⠉⢀⣀⣀⣈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣗⢕⢕⢕⢕⢕⢕⣽⣿⣿⣿⣿",
|
||||
"⢷⣂⣠⣴⣾⡿⡿⡻⡻⣿⣿⣴⣿⣿⣿⣿⣿⣿⣷⣵⣵⣵⣷⣿⣿⣿⣿⣿⣿⡿",
|
||||
"⢌⠻⣿⡿⡫⡪⡪⡪⡪⣺⣿⣿⣿⣿⣿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃",
|
||||
"⠣⡁⠹⡪⡪⡪⡪⣪⣾⣿⣿⣿⣿⠋⠐⢉⢍⢄⢌⠻⣿⣿⣿⣿⣿⣿⣿⣿⠏⠈",
|
||||
"⡣⡘⢄⠙⣾⣾⣾⣿⣿⣿⣿⣿⣿⡀⢐⢕⢕⢕⢕⢕⡘⣿⣿⣿⣿⣿⣿⠏⠠⠈",
|
||||
"⠌⢊⢂⢣⠹⣿⣿⣿⣿⣿⣿⣿⣿⣧⢐⢕⢕⢕⢕⢕⢅⣿⣿⣿⣿⡿⢋⢜⠠⠈",
|
||||
}
|
||||
|
||||
var artColors = []func(a ...any) string{
|
||||
Red,
|
||||
Red,
|
||||
Blue,
|
||||
Blue,
|
||||
Red,
|
||||
Red,
|
||||
Blue,
|
||||
Blue,
|
||||
Blue,
|
||||
Red,
|
||||
}
|
||||
|
||||
func PrintBanner() {
|
||||
fmt.Println()
|
||||
fmt.Println()
|
||||
for i, line := range angelaBanner {
|
||||
c := bannerColors[i%len(bannerColors)]
|
||||
fmt.Printf(" %s\n", c(line))
|
||||
}
|
||||
fmt.Printf(" %s\n", White(HRule(52)))
|
||||
fmt.Printf(
|
||||
" %s\n\n",
|
||||
HiBlackItalic(
|
||||
"Python dependency updater & vulnerability scanner",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func PrintBannerWithArt() {
|
||||
fmt.Println()
|
||||
for i, line := range angelaBanner {
|
||||
c := bannerColors[i%len(bannerColors)]
|
||||
fmt.Printf(" %s\n", c(line))
|
||||
}
|
||||
fmt.Printf(" %s\n", White(HRule(52)))
|
||||
fmt.Printf(
|
||||
" %s\n",
|
||||
HiBlackItalic(
|
||||
"Python dependency updater & vulnerability scanner",
|
||||
),
|
||||
)
|
||||
fmt.Println()
|
||||
for i, line := range animeArt {
|
||||
c := artColors[i%len(artColors)]
|
||||
fmt.Printf(" %s\n", c(line))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
// ©AngelaMos | 2026
|
||||
// color.go
|
||||
|
||||
package ui
|
||||
|
||||
import "github.com/fatih/color"
|
||||
|
||||
var (
|
||||
Black = color.New(color.FgBlack).SprintFunc()
|
||||
Red = color.New(color.FgRed).SprintFunc()
|
||||
Green = color.New(color.FgGreen).SprintFunc()
|
||||
Yellow = color.New(color.FgYellow).SprintFunc()
|
||||
Blue = color.New(color.FgBlue).SprintFunc()
|
||||
Magenta = color.New(color.FgMagenta).SprintFunc()
|
||||
Cyan = color.New(color.FgCyan).SprintFunc()
|
||||
White = color.New(color.FgWhite).SprintFunc()
|
||||
|
||||
HiBlack = color.New(color.FgHiBlack).SprintFunc()
|
||||
HiRed = color.New(color.FgHiRed).SprintFunc()
|
||||
HiGreen = color.New(color.FgHiGreen).SprintFunc()
|
||||
HiYellow = color.New(color.FgHiYellow).SprintFunc()
|
||||
HiBlue = color.New(color.FgHiBlue).SprintFunc()
|
||||
HiMagenta = color.New(color.FgHiMagenta).SprintFunc()
|
||||
HiCyan = color.New(color.FgHiCyan).SprintFunc()
|
||||
HiWhite = color.New(color.FgHiWhite).SprintFunc()
|
||||
|
||||
RedBold = color.New(color.FgRed, color.Bold).SprintFunc()
|
||||
GreenBold = color.New(color.FgGreen, color.Bold).SprintFunc()
|
||||
YellowBold = color.New(color.FgYellow, color.Bold).SprintFunc()
|
||||
BlueBold = color.New(color.FgBlue, color.Bold).SprintFunc()
|
||||
MagentaBold = color.New(
|
||||
color.FgMagenta, color.Bold,
|
||||
).SprintFunc()
|
||||
CyanBold = color.New(
|
||||
color.FgCyan, color.Bold,
|
||||
).SprintFunc()
|
||||
HiRedBold = color.New(
|
||||
color.FgHiRed, color.Bold,
|
||||
).SprintFunc()
|
||||
HiMagentaBold = color.New(
|
||||
color.FgHiMagenta, color.Bold,
|
||||
).SprintFunc()
|
||||
HiCyanBold = color.New(
|
||||
color.FgHiCyan, color.Bold,
|
||||
).SprintFunc()
|
||||
|
||||
Dim = color.New(color.Faint).SprintFunc()
|
||||
DimItalic = color.New(
|
||||
color.Faint, color.Italic,
|
||||
).SprintFunc()
|
||||
|
||||
CyanItalic = color.New(
|
||||
color.FgCyan, color.Italic,
|
||||
).SprintFunc()
|
||||
MagentaItalic = color.New(
|
||||
color.FgMagenta, color.Italic,
|
||||
).SprintFunc()
|
||||
RedItalic = color.New(
|
||||
color.FgRed, color.Italic,
|
||||
).SprintFunc()
|
||||
GreenItalic = color.New(
|
||||
color.FgGreen, color.Italic,
|
||||
).SprintFunc()
|
||||
HiBlackItalic = color.New(
|
||||
color.FgHiBlack, color.Italic,
|
||||
).SprintFunc()
|
||||
BlueItalic = color.New(
|
||||
color.FgBlue, color.Italic,
|
||||
).SprintFunc()
|
||||
HiBlueItalic = color.New(
|
||||
color.FgHiBlue, color.Italic,
|
||||
).SprintFunc()
|
||||
|
||||
CyanUnderline = color.New(
|
||||
color.FgCyan, color.Underline,
|
||||
).SprintFunc()
|
||||
MagentaUnderline = color.New(
|
||||
color.FgMagenta, color.Underline,
|
||||
).SprintFunc()
|
||||
|
||||
HiCyanBoldItalic = color.New(
|
||||
color.FgHiCyan, color.Bold, color.Italic,
|
||||
).SprintFunc()
|
||||
HiMagentaBoldItalic = color.New(
|
||||
color.FgHiMagenta, color.Bold, color.Italic,
|
||||
).SprintFunc()
|
||||
HiRedBoldItalic = color.New(
|
||||
color.FgHiRed, color.Bold, color.Italic,
|
||||
).SprintFunc()
|
||||
|
||||
HiBlackCrossed = color.New(
|
||||
color.FgHiBlack, color.CrossedOut,
|
||||
).SprintFunc()
|
||||
)
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
// ©AngelaMos | 2026
|
||||
// spinner.go
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var frames = []string{
|
||||
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
|
||||
}
|
||||
|
||||
type Spinner struct {
|
||||
msg string
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
active bool
|
||||
}
|
||||
|
||||
func NewSpinner(msg string) *Spinner {
|
||||
return &Spinner{msg: msg}
|
||||
}
|
||||
|
||||
func (s *Spinner) Start() {
|
||||
s.mu.Lock()
|
||||
if s.active {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.active = true
|
||||
s.done = make(chan struct{})
|
||||
s.wg.Add(1)
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
func (s *Spinner) Stop() {
|
||||
s.mu.Lock()
|
||||
if !s.active {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.active = false
|
||||
close(s.done)
|
||||
s.mu.Unlock()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Spinner) run() {
|
||||
defer s.wg.Done()
|
||||
fmt.Print("\033[?25l")
|
||||
|
||||
ticker := time.NewTicker(80 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
idx := 0
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
clearLine()
|
||||
fmt.Print("\033[?25h")
|
||||
return
|
||||
case <-ticker.C:
|
||||
frame := frames[idx%len(frames)]
|
||||
fmt.Printf(
|
||||
"\r %s %s",
|
||||
CyanBold(frame),
|
||||
HiMagenta(s.msg),
|
||||
)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearLine() {
|
||||
fmt.Print("\r" + strings.Repeat(" ", 80) + "\r")
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// ©AngelaMos | 2026
|
||||
// symbol.go
|
||||
|
||||
package ui
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
Arrow = "→"
|
||||
ArrowRight = "▸"
|
||||
ArrowUp = "↑"
|
||||
Diamond = "◆"
|
||||
Gem = "◈"
|
||||
Star = "✦"
|
||||
TriangleUp = "▲"
|
||||
Check = "✓"
|
||||
Cross = "✗"
|
||||
Timer = "⏱"
|
||||
DividerChar = "━"
|
||||
)
|
||||
|
||||
func HRule(width int) string {
|
||||
return strings.Repeat(DividerChar, width)
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
# Go Concurrency Patterns Used in angela
|
||||
|
||||
angela fetches data from PyPI and OSV.dev in parallel. These patterns show up in production codebases at places like Uber and Cloudflare, but they're not complicated — they just require knowing which tool to reach for.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
angela needs to query PyPI for every dependency in your `pyproject.toml` or `requirements.txt`. A typical project has 20-50 dependencies. Doing those requests one at a time would take 10-25 seconds. Doing them all at once would hammer PyPI with 50 simultaneous connections.
|
||||
|
||||
The answer is **bounded concurrency** — run up to N requests in parallel, queue the rest.
|
||||
|
||||
---
|
||||
|
||||
## errgroup.SetLimit
|
||||
|
||||
The `golang.org/x/sync/errgroup` package is the go-to for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`:
|
||||
|
||||
```go
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(c.maxWorkers) // max 10 concurrent goroutines
|
||||
|
||||
for _, name := range names {
|
||||
g.Go(func() error {
|
||||
versions, err := c.FetchVersions(ctx, name)
|
||||
mu.Lock()
|
||||
results = append(results, FetchResult{
|
||||
Name: name, Versions: versions, Err: err,
|
||||
})
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
_ = g.Wait()
|
||||
```
|
||||
|
||||
A few things to notice:
|
||||
|
||||
- **`SetLimit(10)`** caps concurrent HTTP requests. PyPI recommends 5-10.
|
||||
- **Always returns nil** — individual failures get collected in results, not propagated. One package failing shouldn't kill the others.
|
||||
- **Mutex on the shared slice** — `results` gets appended to from multiple goroutines, so it needs a lock.
|
||||
|
||||
### Why not channels?
|
||||
|
||||
Channels would work, but errgroup gets you the same thing with half the code:
|
||||
|
||||
```go
|
||||
// Channel-based worker pool: ~30 lines
|
||||
jobs := make(chan string, len(names))
|
||||
results := make(chan FetchResult, len(names))
|
||||
for range maxWorkers {
|
||||
go func() {
|
||||
for name := range jobs {
|
||||
// ... fetch ...
|
||||
results <- result
|
||||
}
|
||||
}()
|
||||
}
|
||||
// ... send jobs, collect results, close channels ...
|
||||
|
||||
// errgroup: ~15 lines
|
||||
g.SetLimit(maxWorkers)
|
||||
for _, name := range names {
|
||||
g.Go(func() error { /* ... */ })
|
||||
}
|
||||
g.Wait()
|
||||
```
|
||||
|
||||
errgroup handles context cancellation for free and there's no channel lifecycle to think about.
|
||||
|
||||
---
|
||||
|
||||
## Panic recovery
|
||||
|
||||
Every goroutine angela launches wraps itself in a recover:
|
||||
|
||||
```go
|
||||
g.Go(func() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic fetching %s: %v", name, r)
|
||||
}
|
||||
}()
|
||||
|
||||
versions, fetchErr := c.FetchVersions(ctx, name)
|
||||
// ...
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
An unrecovered panic in a goroutine kills the entire process. For a CLI tool, that means the user gets a stack trace dump instead of an actual error message. The `defer recover()` turns panics into errors that flow through the normal path.
|
||||
|
||||
One subtle thing — the named return `(err error)` is what makes this work. Without it, the deferred function has no way to set the return value.
|
||||
|
||||
---
|
||||
|
||||
## Context cancellation
|
||||
|
||||
Every HTTP request uses context:
|
||||
|
||||
```go
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
```
|
||||
|
||||
This does a lot of work for you:
|
||||
|
||||
- Ctrl+C cancels pending requests
|
||||
- If errgroup's context gets cancelled, remaining work stops
|
||||
- HTTP timeouts are enforced at the transport level
|
||||
|
||||
The context flows from `cobra.Command.Context()` → `runUpdate()` → `FetchAllVersions()` → `FetchVersions()` → the HTTP call itself. Standard Go pattern — context as the first parameter, threaded through everything.
|
||||
|
||||
---
|
||||
|
||||
## Retry with exponential backoff
|
||||
|
||||
`internal/pypi/client.go` retries transient failures:
|
||||
|
||||
```go
|
||||
for attempt := range maxRetries {
|
||||
if attempt > 0 {
|
||||
delay := time.Duration(1<<shift) * baseRetryMs * time.Millisecond
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
```
|
||||
|
||||
The `1<<shift` doubles the delay each attempt — 500ms, 1s, 2s. The `select` means cancellation is still respected while waiting between retries. Only server errors (5xx) get retried; client errors (4xx) don't because retrying a 404 won't make the package appear. Three attempts is enough to ride out a transient blip without making the user wait forever.
|
||||
|
||||
---
|
||||
|
||||
## Mutex vs channel
|
||||
|
||||
angela uses `sync.Mutex` to protect the results slice:
|
||||
|
||||
```go
|
||||
var mu sync.Mutex
|
||||
results := make([]FetchResult, 0, len(names))
|
||||
|
||||
// In each goroutine:
|
||||
mu.Lock()
|
||||
results = append(results, result)
|
||||
mu.Unlock()
|
||||
```
|
||||
|
||||
Quick rule of thumb:
|
||||
|
||||
| Use Mutex | Use Channel |
|
||||
|-----------|-------------|
|
||||
| Protecting shared state (maps, slices) | Passing data between goroutines |
|
||||
| Simple append/read operations | Producer-consumer pipelines |
|
||||
| Order doesn't matter | Sequential processing needed |
|
||||
|
||||
For appending results where order doesn't matter, a mutex is simpler and cheaper.
|
||||
|
||||
---
|
||||
|
||||
## The bigger picture
|
||||
|
||||
All of these patterns boil down to the same idea: **know when your goroutine exits**. Every goroutine angela spawns has a clear owner (the errgroup), a clear exit condition (the function returns), and panic protection. That's how you avoid leaks.
|
||||
|
||||
`go test -race` catches most of what code review misses. Run it in CI, always.
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
# PEP 440: Python Version Parsing from Scratch
|
||||
|
||||
Parsing Python version strings was one of the harder parts of building angela. If you're working on anything that touches the Python ecosystem, PEP 440 is worth understanding — it's weirder than you'd expect.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
Python versions are **not** semantic versioning. They follow [PEP 440](https://peps.python.org/pep-0440/), which has components semver doesn't:
|
||||
|
||||
```
|
||||
[epoch!] release [pre-release] [.postN] [.devN] [+local]
|
||||
```
|
||||
|
||||
Real examples from PyPI:
|
||||
|
||||
| Version | What it means |
|
||||
|---------|---------------|
|
||||
| `1.2.3` | Normal release |
|
||||
| `2!1.0` | Epoch 2 — beats any version with epoch 0 or 1 |
|
||||
| `1.0a1` | Alpha pre-release |
|
||||
| `1.0b2` | Beta pre-release |
|
||||
| `1.0rc1` | Release candidate |
|
||||
| `1.0.dev3` | Development snapshot |
|
||||
| `1.0.post1` | Post-release (stable — just a docs or metadata fix) |
|
||||
| `1.0+ubuntu1` | Local version label (never shows up on PyPI) |
|
||||
|
||||
---
|
||||
|
||||
## Why you can't just string-compare
|
||||
|
||||
If you naively compare version strings, `1.0a1` looks "newer" than `1.0` because `a` > empty string in ASCII. But Python's actual ordering is:
|
||||
|
||||
```
|
||||
1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1
|
||||
```
|
||||
|
||||
A tool that gets this wrong will upgrade users to unstable pre-releases. That's why angela implements the full spec.
|
||||
|
||||
---
|
||||
|
||||
## How the parser works
|
||||
|
||||
The parser lives in `internal/pypi/version.go`. It uses a single compiled regex to pull out all the components in one pass:
|
||||
|
||||
```
|
||||
(?i)^v?
|
||||
(?:(\d+)!)? # epoch
|
||||
(\d+(?:\.\d+)*) # release segments
|
||||
(?:[-_.]?(alpha|a|beta|b|...|rc)[-_.]?(\d*))? # pre-release
|
||||
(?:[-_.]?(post|rev|r)[-_.]?(\d*)|-(\d+))? # post-release
|
||||
(?:[-_.]?(dev)[-_.]?(\d*))? # dev release
|
||||
(?:\+([a-z0-9]...))?$ # local version
|
||||
```
|
||||
|
||||
Each section maps to a field in the `Version` struct:
|
||||
|
||||
```go
|
||||
type Version struct {
|
||||
Raw string
|
||||
Epoch int
|
||||
Release []int // [1, 2, 3] for "1.2.3"
|
||||
PreKind string // "a", "b", or "rc"
|
||||
PreNum int
|
||||
Post int // -1 means absent
|
||||
Dev int // -1 means absent
|
||||
Local string
|
||||
}
|
||||
```
|
||||
|
||||
The `-1` sentinel for Post and Dev is important — it's how you tell "not present" apart from "present with value 0". Both `1.0.post0` and `1.0.post` are valid PEP 440 (implicit zero), but they're different from `1.0` which has no post-release at all.
|
||||
|
||||
---
|
||||
|
||||
## Normalization
|
||||
|
||||
PEP 440 allows a bunch of different spellings that all mean the same thing:
|
||||
|
||||
| Input | Normalized |
|
||||
|-------|-----------|
|
||||
| `alpha` | `a` |
|
||||
| `beta` | `b` |
|
||||
| `c`, `pre`, `preview` | `rc` |
|
||||
| `rev`, `r` | `post` |
|
||||
| `1.0-1` | `1.0.post1` (implicit post via dash-number) |
|
||||
| `1.0a` | `1.0a0` (implicit zero) |
|
||||
| `v1.0` | `1.0` (strip leading v) |
|
||||
|
||||
The parser normalizes all of these during extraction, so the rest of the codebase never has to think about variant spellings.
|
||||
|
||||
---
|
||||
|
||||
## Version comparison
|
||||
|
||||
Python's `packaging` library compares versions by converting them to tuples that sort naturally. angela does the same thing:
|
||||
|
||||
```go
|
||||
func (v Version) Compare(other Version) int {
|
||||
// 1. Compare epochs
|
||||
// 2. Compare release segments (with implicit zero extension)
|
||||
// 3. Compare pre-release (using sentinel values)
|
||||
// 4. Compare post-release
|
||||
// 5. Compare dev-release
|
||||
}
|
||||
```
|
||||
|
||||
The trick is in the sentinel values:
|
||||
|
||||
| Component state | Sort key |
|
||||
|----------------|----------|
|
||||
| Dev-only (no pre, no post) | `MinInt, MinInt` — sorts before everything |
|
||||
| Pre-release `a1` | `0, 1` — alpha rank 0 |
|
||||
| Pre-release `b2` | `1, 2` — beta rank 1 |
|
||||
| Pre-release `rc1` | `2, 1` — rc rank 2 |
|
||||
| Final release (no pre) | `MaxInt, MaxInt` — sorts after all pre-releases |
|
||||
|
||||
Using `math.MinInt` and `math.MaxInt` means the comparison function is just a sequence of `cmp.Compare` calls — no special-case branching needed.
|
||||
|
||||
---
|
||||
|
||||
## Stability detection
|
||||
|
||||
A version is **stable** if it has no pre-release tag and no dev tag:
|
||||
|
||||
```go
|
||||
func (v Version) IsStable() bool {
|
||||
return v.PreKind == "" && v.Dev < 0
|
||||
}
|
||||
```
|
||||
|
||||
Post-releases count as stable — `1.0.post1` is a docs fix or metadata correction, not an unstable build.
|
||||
|
||||
---
|
||||
|
||||
## Takeaways
|
||||
|
||||
A single compiled regex handles every PEP 440 form in one pass. The alternative would be a hand-written state machine with 3x the code and no real upside.
|
||||
|
||||
Normalizing at parse time (alpha→a, preview→rc) keeps things clean — nothing downstream ever has to handle variant spellings.
|
||||
|
||||
The version parser currently has 90+ test cases across 10 functions. When you're implementing something spec-driven like this, writing the test cases from the spec first and then making them pass is the way to go.
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
# Preserving Comments in Dependency Files
|
||||
|
||||
When angela updates version specifiers, it can't blow away the developer's comments, blank lines, and formatting. This turns out to be a surprisingly annoying problem. Here's how angela handles it for both `pyproject.toml` and `requirements.txt`.
|
||||
|
||||
---
|
||||
|
||||
## The constraint
|
||||
|
||||
A developer's `pyproject.toml` looks like this:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "myapp"
|
||||
version = "1.0.0"
|
||||
|
||||
# Core runtime dependencies
|
||||
dependencies = [
|
||||
"requests>=2.28.0", # HTTP library - pin for security
|
||||
"django>=3.2,<4.0",
|
||||
"flask[async]>=2.0",
|
||||
]
|
||||
```
|
||||
|
||||
After running `angela update`, the file should look like:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "myapp"
|
||||
version = "1.0.0"
|
||||
|
||||
# Core runtime dependencies
|
||||
dependencies = [
|
||||
"requests>=2.32.3", # HTTP library - pin for security
|
||||
"django>=5.1.5",
|
||||
"flask[async]>=3.1.0",
|
||||
]
|
||||
```
|
||||
|
||||
Only the version numbers changed. Every comment, every space, every quote style — still there. Same goes for `requirements.txt` — inline comments, section headers, all of it should survive.
|
||||
|
||||
---
|
||||
|
||||
## Why TOML libraries can't do this
|
||||
|
||||
Both major Go TOML libraries (BurntSushi/toml and pelletier/go-toml) work by unmarshaling into Go structs, then marshaling back:
|
||||
|
||||
```go
|
||||
var proj PyProject
|
||||
toml.Unmarshal(data, &proj) // comments stripped
|
||||
proj.Dependencies[0] = "requests>=2.32.3"
|
||||
output, _ := toml.Marshal(proj) // comments gone, formatting changed
|
||||
```
|
||||
|
||||
Go's reflection system has no way to store comment metadata on struct fields. The unmarshal/marshal round-trip just destroys them. This isn't a library bug — it's a fundamental limitation of how Go struct serialization works.
|
||||
|
||||
pelletier/go-toml v2 does have an `unstable` package that exposes AST access with comments preserved in the parse tree, but there's no serializer — you'd have to write your own TOML emitter from scratch.
|
||||
|
||||
---
|
||||
|
||||
## Regex surgery
|
||||
|
||||
angela's approach: don't parse and re-serialize. Treat the file as a byte buffer and surgically replace only the version specifier.
|
||||
|
||||
The implementation for pyproject.toml lives in `internal/pyproject/writer.go`:
|
||||
|
||||
1. Build a regex that matches the full dependency string inside quotes:
|
||||
```
|
||||
"requests>=2.28.0"
|
||||
```
|
||||
|
||||
2. Capture groups isolate the pieces:
|
||||
- Group 1: package name (`requests`)
|
||||
- Group 2: extras (`[async]` or empty)
|
||||
- Group 3: version specifier (`>=2.28.0`)
|
||||
- Group 4: markers (`;python_version>='3.8'` or empty)
|
||||
|
||||
3. Replace only group 3, keep everything else.
|
||||
|
||||
4. Validate before and after — feed the result through go-toml v2's unmarshaler to make sure the edit didn't break the TOML syntax.
|
||||
|
||||
```go
|
||||
func (u *Updater) UpdateDependency(pkg, newSpec string) error {
|
||||
for _, q := range []byte{'"', '\''} {
|
||||
pattern := buildDepPattern(pkg, q)
|
||||
found := false
|
||||
u.content = pattern.ReplaceAllFunc(u.content,
|
||||
func(match []byte) []byte {
|
||||
found = true
|
||||
return replaceSpec(pattern, match, newSpec, q)
|
||||
},
|
||||
)
|
||||
if found {
|
||||
var probe map[string]any
|
||||
if err := toml.Unmarshal(u.content, &probe); err != nil {
|
||||
return fmt.Errorf("update produced invalid TOML: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("dependency %q not found", pkg)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## requirements.txt uses the same idea
|
||||
|
||||
The `requirements.txt` writer in `internal/requirements/writer.go` works the same way — regex-based surgery on raw bytes. The difference is simpler: no quotes to worry about, no TOML validation needed. Lines look like:
|
||||
|
||||
```
|
||||
django>=3.2.0
|
||||
requests==2.28.1 # HTTP client
|
||||
```
|
||||
|
||||
The regex matches the package name (with PEP 503 normalization) and its version specifier, replaces the spec, and leaves everything else alone — including inline comments.
|
||||
|
||||
Both writers share the same atomic write pattern (temp file + rename) and the same PEP 503 name normalization approach. The pyproject writer just has extra complexity for quote styles and TOML validation.
|
||||
|
||||
---
|
||||
|
||||
## The tricky parts
|
||||
|
||||
### PEP 503 name normalization
|
||||
|
||||
Package names on PyPI are case-insensitive and treat `-`, `_`, and `.` as equivalent:
|
||||
|
||||
```
|
||||
Some_Package == some-package == some.package
|
||||
```
|
||||
|
||||
The regex has to match all variants. angela splits the normalized name on `-` and joins with `[-_.]?`:
|
||||
|
||||
```go
|
||||
parts := strings.Split(normalized, "-")
|
||||
for i, p := range parts {
|
||||
parts[i] = regexp.QuoteMeta(p)
|
||||
}
|
||||
namePattern := strings.Join(parts, `[-_.]?`)
|
||||
```
|
||||
|
||||
So the pattern for `some-package` becomes `some[-_.]?package`, which matches `some_package`, `some.package`, and `somepackage`.
|
||||
|
||||
### Go RE2 doesn't support backreferences
|
||||
|
||||
The TOML research suggested using `\1` to match closing quotes with the opening quote. Go's `regexp` package uses RE2, which doesn't do backreferences.
|
||||
|
||||
The workaround: try each quote style separately. Loop over `{'"', '\''}` and build a pattern specific to that quote character.
|
||||
|
||||
### Atomic file writes
|
||||
|
||||
angela writes to a `.tmp` file first, then renames over the original. If the process dies mid-write, the original is untouched:
|
||||
|
||||
```go
|
||||
func (u *Updater) WriteFile(path string) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, u.content, 0o600); err != nil {
|
||||
return fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
It's two syscalls, but the file is never in a half-written state.
|
||||
|
||||
---
|
||||
|
||||
## This is how production tools do it
|
||||
|
||||
Renovate and Dependabot both use regex/string manipulation for updating dependency files. They don't parse and re-serialize either — they do the same kind of surgical modification. No TOML, YAML, or JSON library in any language perfectly round-trips formatting and comments. Regex surgery sounds hacky, but it's the approach that actually works.
|
||||
|
||||
Go's RE2 engine guarantees linear-time matching, so even on large files with complex patterns there's no risk of catastrophic backtracking. Just keep in mind it doesn't support backreferences or lookaheads — design your patterns around that.
|
||||
|
|
@ -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 (outdated)
|
||||
dependencies = [
|
||||
"requests>=2.28.0",
|
||||
"django>=3.2,<4.0",
|
||||
"click>=8.0.0",
|
||||
"pydantic>=2.0",
|
||||
"flask[async]>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Development and testing tools (outdated)
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"black>=23.0.0", # Code formatter
|
||||
"ruff>=0.1.0",
|
||||
"mypy>=1.0.0",
|
||||
]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# Core dependencies
|
||||
django>=3.2.0
|
||||
requests>=2.28.0
|
||||
flask>=2.0.0
|
||||
|
||||
# Dev tools
|
||||
black>=23.0.0
|
||||
pydantic>=1.10.0
|
||||
|
||||
# Pinned
|
||||
numpy==1.24.0
|
||||
pytest>=7.0.0
|
||||
click>=8.0.0
|
||||
Loading…
Reference in New Issue