Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,30 @@ mistaken for a safe patch upgrade.
- The module now requires **Go 1.27.2** (was 1.25). Consumers on an older Go
toolchain must upgrade, and `go mod tidy` will raise their own `go` directive
to at least 1.27.2.
- `marker`, `requirement`, `reqtxt`: **non-ASCII `extra` marker literals now
normalize like Python's `str.lower()`** (see the `extras` entry under
`Fixed`). This changes `String()` output and `Evaluate` results:
`extra == "Ä"` now renders as `extra == "ä"`. `extra == "ΑΣ.Β"` no longer
matches the active extra `ΑΣ_Β` (`ασ-β` vs `ας-β`). Invalid UTF-8 bytes no
longer collapse to U+FFFD, so `"\xff"` no longer matches `"\xfe"`. (#25)

### Added

- `packagename`: new package with `Normalize`, the PEP 503 entry point for
project-name normalization. It returns exactly what `extras.Normalize`
returns; both now delegate to one shared internal implementation. (#25)
- `distribution`: Metadata 2.5 (PEP 794) and 2.6 (PEP 808) are now recognized.
Metadata 2.5 adds `Import-Name` and `Import-Namespace` fields; 2.6 has no new
fields (behavior change for `Dynamic` only).

### Fixed

- `extras`: `Normalize` now lowercases non-ASCII letters the way Python's
`str.lower()` does, matching pypa/packaging's `canonicalize_name`: `Ä`
becomes `ä`, `İ` becomes `i` plus U+0307, and `Σ` becomes `ς` at the end
of a word. Previously only ASCII letters were lowercased. Bytes that are not
valid UTF-8 now pass through unchanged; previously each was replaced with
U+FFFD. Valid PEP 508 names are ASCII, so their results do not change. (#25)
Comment thread
nodivbyzero marked this conversation as resolved.
- `distribution`: `License-File` is now parsed as a multi-value field, matching
the PEP 639 spec. Previously it was incorrectly treated as single-value.

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ One module, one package per PEP concern (single import path for downstream conve
| `requirement/` | PEP 508 dependency specifiers |
| `marker/` | PEP 508 environment markers |
| `extras/` | PEP 685 extra-name normalization |
| `packagename/` | PEP 503 project-name normalization |
| `reqtxt/` | pip `requirements.txt` format |
| `distribution/` | METADATA / wheel parsing *(added by migration #18630)* |
| `wheelname/` | wheel filename → structured fields |
Expand Down
10 changes: 6 additions & 4 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,11 @@ License, Version 2.0; and from Astral's uv (https://github.com/astral-sh/uv),
specifically the uv-distribution-filename crate, dual-licensed under the
Apache License, Version 2.0, or the MIT license.

The extras/ package adapts PEP 685/503 name-canonicalization from
pypa/packaging (https://github.com/pypa/packaging), specifically
The internal/canonname package adapts PEP 503/685 name-canonicalization
from pypa/packaging (https://github.com/pypa/packaging), specifically
packaging/utils.py's `canonicalize_name`, licensed under the Apache
License, Version 2.0.
License, Version 2.0. The extras/ and packagename/ packages expose it as
thin wrappers.

The internal/pep508 package's tokenizer adapts the lexer design of
pypa/packaging (https://github.com/pypa/packaging), specifically
Expand Down Expand Up @@ -213,7 +214,8 @@ test_canonicalize_name and the non-validating half of
test_canonicalize_name_invalid, pinned at commit
4eb0753dba8fcaaac8eb75463374e448f0931558, licensed under the Apache License,
Version 2.0. Translated from Python/pytest to Go table-driven tests, in
extras/conformance_test.go.
extras/conformance_test.go. The test_canonicalize_name cases are ported a
second time, under the same terms, in packagename/conformance_test.go.

The internal/pep508 package's error-position conformance tests port input
strings and caret span offsets - not message text - from Astral uv's
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Part of [RFD 0001 — Native Go PyPI Dependency Resolution](https://github.com/r
| `requirement/` | PEP 508 dependency specifiers (implemented) |
| `marker/` | PEP 508 environment markers (implemented) |
| `extras/` | PEP 685 extra-name normalization (implemented) |
| `packagename/` | PEP 503 project-name normalization (implemented) |
| `reqtxt/` | pip `requirements.txt` format (implemented) |
| [`distribution/`](https://github.com/rstudio/python-distribution-parser) | METADATA / wheel parsing |
| `wheelname/` | PEP 427 wheel filename → name/version/build/tags (implemented) |
Expand Down
4 changes: 2 additions & 2 deletions extras/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

// Package extras normalizes Python extra names per PEP 685.
//
// Normalize lowercases an extra name and collapses any run of "-", "_", or
// "." into a single "-", matching the PEP 503 name-canonicalization rule
// Normalize lowercases an extra name as Python's str.lower() does and
// collapses any run of "-", "_", or "." into a single "-", matching the PEP 503 name-canonicalization rule
// that PEP 685 mandates for extras. The result is deterministic and
// idempotent: normalizing an already-normalized name is a no-op.
package extras
46 changes: 11 additions & 35 deletions extras/extras.go
Original file line number Diff line number Diff line change
@@ -1,45 +1,21 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
//
// Portions of this file are ported from pypa/packaging
// (https://github.com/pypa/packaging), specifically packaging/utils.py's
// canonicalize_name, used under the Apache License, Version 2.0
// (dual-licensed Apache-2.0 OR BSD-2-Clause; see NOTICE for full license and
// copyright detail).
// Changed: translated from a single re.sub regex into an explicit
// rune-by-rune loop, since extra-name normalization sits on a
// per-dependency hot path; behavior (lowercase, collapse -/_/. runs to one
// "-") is unchanged.

package extras

import "strings"
import "github.com/posit-dev/go-python-packaging/internal/canonname"

// Normalize canonicalizes a Python extra name per PEP 685: lowercase the
// name, then collapse any run of "-", "_", or "." into a single "-".
//
// This mirrors pypa/packaging's canonicalize_name (PEP 503/685) without
// using a regular expression, since extra-name normalization sits on a
// per-dependency, per-extra hot path during dependency resolution.
// Lowercasing follows Python's str.lower() across all of Unicode, so the
// result matches pypa/packaging for non-ASCII input too (up to differences
// between Go's and Python's Unicode versions). Bytes that are not valid UTF-8
// pass through unchanged.
//
// PEP 685 adopts the PEP 503 project-name rule unchanged, so this returns
// exactly what packagename.Normalize returns; both delegate to one shared
// implementation. It mirrors pypa/packaging's canonicalize_name and uses no
// regular expression.
func Normalize(name string) string {
var b strings.Builder
b.Grow(len(name))

inSep := false
for _, r := range name {
switch r {
case '-', '_', '.':
if !inSep {
b.WriteByte('-')
inSep = true
}
default:
inSep = false
if r >= 'A' && r <= 'Z' {
r += 'a' - 'A'
}
b.WriteRune(r)
}
}

return b.String()
return canonname.Normalize(name)
}
193 changes: 193 additions & 0 deletions internal/canonname/canonname.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
//
// Portions of this file are ported from pypa/packaging
// (https://github.com/pypa/packaging), specifically packaging/utils.py's
// canonicalize_name, used under the Apache License, Version 2.0
// (dual-licensed Apache-2.0 OR BSD-2-Clause; see NOTICE for full license and
// copyright detail). The Final_Sigma rule in finalSigma follows CPython's
// handle_capital_sigma in Objects/unicodeobject.c (Unicode Standard §3.13).
// Changed: upstream's str.lower(), str.replace, and repeated "--" collapse
// (which emulate PEP 503's re.sub(r"[-_.]+", "-", name).lower()) are
// translated into a single loop with an ASCII fast path and a non-allocating
// early return for already-normalized names, since name normalization sits on
// a per-dependency hot path; behavior (lowercase, collapse -/_/. runs to one
// "-") is unchanged. str.lower() is reproduced with Go's unicode tables, so
// results for characters whose case data differs between Go's and the running
// Python's Unicode version can differ. The validate=True option is not ported.

// Package canonname holds the single implementation of Python name
// canonicalization shared by PEP 503 project names (package packagename) and
// PEP 685 extra names (package extras). The two PEPs specify the same
// transformation; keeping one implementation means the public entry points
// cannot drift apart.
package canonname

import (
"strings"
"unicode"
"unicode/utf8"
)

const (
capitalDottedI = 'İ' // U+0130; the only unconditional multi-rune lowercase mapping
capitalSigma = 'Σ' // U+03A3; lowercases to ς or σ depending on context
smallSigma = 'σ' // U+03C3
finalSmallSig = 'ς' // U+03C2
)

// Normalize returns the canonical form of a Python name per PEP 503 (and
// PEP 685, which adopts the same rule): the name is lowercased the way
// Python's str.lower() does it, and any run of "-", "_", or "." collapses to
// a single "-".
//
// Lowercasing covers all of Unicode, not just ASCII, including str.lower()'s
// two special cases: "İ" (U+0130) becomes "i" plus U+0307, and "Σ" becomes
// final "ς" at the end of a word and "σ" elsewhere. Bytes that are not valid
// UTF-8 pass through unchanged.
//
// This mirrors pypa/packaging's canonicalize_name without using a regular
// expression, since it sits on a per-dependency, per-extra hot path during
// dependency resolution. An already-normalized name is returned as-is,
// without allocating.
func Normalize(name string) string {
// Fast path: most names are already normalized; avoid allocating.
if isNormalized(name) {
return name
}

// strings.Builder, not a []byte plus string(b): String() does not copy,
// so the slow path allocates once.
var b strings.Builder
b.Grow(len(name) + 1)
inSep := false
for i := 0; i < len(name); {
c := name[i]
if c < utf8.RuneSelf {
switch c {
case '-', '_', '.':
if !inSep {
b.WriteByte('-')
inSep = true
}
default:
inSep = false
if 'A' <= c && c <= 'Z' {
c += 'a' - 'A'
}
b.WriteByte(c)
}
i++
continue
}

// No non-ASCII rune lowercases to a separator, so this always ends a
// separator run.
inSep = false
r, size := utf8.DecodeRuneInString(name[i:])
switch {
case r == utf8.RuneError && size == 1:
b.WriteByte(c) // invalid UTF-8: copy the byte unchanged
case r == capitalDottedI:
b.WriteString("i̇")
case r == capitalSigma:
if finalSigma(name, i, i+size) {
b.WriteRune(finalSmallSig)
} else {
b.WriteRune(smallSigma)
}
default:
b.WriteRune(unicode.ToLower(r))
}
i += size
}
return b.String()
}

// isNormalized reports whether Normalize(name) == name: name has no "_" or
// ".", no run of two or more "-", and no rune that lowercasing would change.
func isNormalized(name string) bool {
prevSep := false
for i := 0; i < len(name); {
c := name[i]
if c < utf8.RuneSelf {
switch {
case c == '_' || c == '.', 'A' <= c && c <= 'Z':
return false
case c == '-':
if prevSep {
return false
}
prevSep = true
default:
prevSep = false
}
i++
continue
}

prevSep = false
r, size := utf8.DecodeRuneInString(name[i:])
valid := r != utf8.RuneError || size != 1
if valid && (r == capitalDottedI || unicode.ToLower(r) != r) {
return false
}
i += size
}
return true
}

// finalSigma reports whether the capital sigma at name[start:end] is in the
// Final_Sigma context: preceded by a cased letter (skipping case-ignorable
// runes) and not followed by one (again skipping case-ignorable runes).
//
// The context is evaluated on the original name, before separators are
// replaced, because str.lower() runs first upstream. Invalid UTF-8 bytes,
// which Python strings cannot hold, count as neither cased nor
// case-ignorable.
func finalSigma(name string, start, end int) bool {
before := false
for j := start; j > 0; {
r, size := utf8.DecodeLastRuneInString(name[:j])
if size == 1 && r == utf8.RuneError {
break
}
if !isCaseIgnorable(r) {
before = isCased(r)
break
}
j -= size
}
if !before {
return false
}
for j := end; j < len(name); {
r, size := utf8.DecodeRuneInString(name[j:])
if size == 1 && r == utf8.RuneError {
return true
}
if !isCaseIgnorable(r) {
return !isCased(r)
}
j += size
}
return true
}

// isCased reports the Unicode Cased property: Lowercase, Uppercase, or Lt.
func isCased(r rune) bool {
return unicode.IsLower(r) || unicode.IsUpper(r) || unicode.IsTitle(r) ||
unicode.Is(unicode.Other_Lowercase, r) || unicode.Is(unicode.Other_Uppercase, r)
}

// isCaseIgnorable reports the Unicode Case_Ignorable property: general
// category Mn, Me, Cf, Lm, or Sk, or Word_Break MidLetter, MidNumLet, or
// Single_Quote. Go has no Word_Break tables, so those runes are listed.
func isCaseIgnorable(r rune) bool {
switch r {
case '\'', // Single_Quote
'.', '‘', '’', '․', '﹒', ''', '.', // MidNumLet
':', '·', '·', '՟', '״', '‧', '︓', '﹕', ':': // MidLetter
return true
}
return unicode.In(r, unicode.Mn, unicode.Me, unicode.Cf, unicode.Lm, unicode.Sk)
}
Loading
Loading