fix: update mixed case dependencies in python to be normalized (#4573)

---------
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
This commit is contained in:
Christopher Angelo Phillips 2026-01-27 15:16:32 -05:00 committed by GitHub
parent e8b4527bfb
commit 9a250a4b4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 174 additions and 2 deletions

View File

@ -635,6 +635,7 @@ func Test_PackageCataloger_Relationships(t *testing.T) {
"jinja2 @ 3.1.4 (.) [dependency-of] fastapi @ 0.111.0 (.)",
"jinja2 @ 3.1.4 (.) [dependency-of] starlette @ 0.37.2 (.)",
"markdown-it-py @ 3.0.0 (.) [dependency-of] rich @ 13.7.1 (.)",
"markupsafe @ 2.1.5 (.) [dependency-of] jinja2 @ 3.1.4 (.)", // MarkupSafe (mixed case) -> markupsafe
"mdurl @ 0.1.2 (.) [dependency-of] markdown-it-py @ 3.0.0 (.)",
"orjson @ 3.10.3 (.) [dependency-of] fastapi @ 0.111.0 (.)",
"pydantic @ 2.7.1 (.) [dependency-of] fastapi @ 0.111.0 (.)",

View File

@ -74,8 +74,10 @@ func isDependencyForExtra(dep pkg.PythonPoetryLockDependencyEntry) bool {
}
func packageRef(name, extra string) string {
cleanExtra := strings.TrimSpace(extra)
cleanName := strings.TrimSpace(name)
// normalize both package name and extra to ensure case-insensitive matching per Python packaging spec
// https://packaging.python.org/en/latest/specifications/name-normalization/
cleanName := normalize(strings.TrimSpace(name))
cleanExtra := normalize(strings.TrimSpace(extra))
if cleanExtra == "" {
return cleanName
}

View File

@ -181,6 +181,30 @@ func Test_poetryLockDependencySpecifier(t *testing.T) {
},
},
},
{
name: "dependency names with mixed case should be normalized",
p: pkg.Package{
Name: "dj-rest-auth",
Metadata: pkg.PythonPoetryLockEntry{
Dependencies: []pkg.PythonPoetryLockDependencyEntry{
{
Name: "Django", // note: capital D
Version: ">=4.2,<6.0",
},
{
Name: "djangorestframework",
Version: ">=3.13.0",
},
},
},
},
want: dependency.Specification{
ProvidesRequires: dependency.ProvidesRequires{
Provides: []string{"dj-rest-auth"},
Requires: []string{"django", "djangorestframework"}, // "Django" should be normalized to "django"
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -195,6 +219,38 @@ func Test_poetryLockDependencySpecifier_againstPoetryLock(t *testing.T) {
fixture string
want []dependency.Specification
}{
{
name: "case-insensitive dependency resolution",
fixture: "test-fixtures/poetry/case-sensitivity/poetry.lock",
want: []dependency.Specification{
// packages are in the order they appear in the lock file
{
ProvidesRequires: dependency.ProvidesRequires{
Provides: []string{"django"},
Requires: []string{"asgiref", "sqlparse"},
},
},
{
ProvidesRequires: dependency.ProvidesRequires{
Provides: []string{"djangorestframework"},
Requires: []string{"django"},
},
},
{
// dj-rest-auth depends on Django (capital D) which should resolve to django
ProvidesRequires: dependency.ProvidesRequires{
Provides: []string{"dj-rest-auth"},
Requires: []string{"django", "djangorestframework"}, // Django normalized to django
},
Variants: []dependency.ProvidesRequires{
{
Provides: []string{"dj-rest-auth[with-social]"},
Requires: []string{"django-allauth"},
},
},
},
},
},
{
name: "simple dependencies with extras",
fixture: "test-fixtures/poetry/simple-deps/poetry.lock",
@ -276,6 +332,64 @@ func Test_poetryLockDependencySpecifier_againstPoetryLock(t *testing.T) {
}
}
// Test_packageRef verifies that package references are normalized according to
// the Python Packaging specification for names and extras:
// https://packaging.python.org/en/latest/specifications/name-normalization/
func Test_packageRef(t *testing.T) {
tests := []struct {
name string
pkg string
extra string
want string
}{
{
name: "simple package name",
pkg: "requests",
want: "requests",
},
{
name: "package with extra",
pkg: "requests",
extra: "security",
want: "requests[security]",
},
{
name: "package name with mixed case",
pkg: "Django",
want: "django",
},
{
name: "package name with underscores",
pkg: "some_package",
want: "some-package",
},
{
name: "package name with mixed case and extra",
pkg: "Django",
extra: "argon2",
want: "django[argon2]",
},
{
name: "extra with mixed case",
pkg: "package",
extra: "Security",
want: "package[security]",
},
{
name: "both with mixed case and separators",
pkg: "Some_Package",
extra: "Dev_Extra",
want: "some-package[dev-extra]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := packageRef(tt.pkg, tt.extra)
assert.Equal(t, tt.want, got)
})
}
}
func Test_extractPackageName(t *testing.T) {
tests := []struct {
name string

View File

@ -0,0 +1,55 @@
# This file is automatically @generated by Poetry and should not be edited manually.
[[package]]
name = "django"
version = "5.2.6"
description = "A high-level Python web framework"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "Django-5.2.6-py3-none-any.whl", hash = "sha256:example1"},
{file = "django-5.2.6.tar.gz", hash = "sha256:example2"},
]
[package.dependencies]
asgiref = ">=3.8.1,<4"
sqlparse = ">=0.3.1"
[[package]]
name = "djangorestframework"
version = "3.16.1"
description = "Web APIs for Django, made easy."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:example3"},
{file = "djangorestframework-3.16.1.tar.gz", hash = "sha256:example4"},
]
[package.dependencies]
django = ">=4.2"
[[package]]
name = "dj-rest-auth"
version = "7.0.1"
description = "Authentication and Registration in Django Rest Framework"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "dj-rest-auth-7.0.1.tar.gz", hash = "sha256:3f8c744cbcf05355ff4bcbef0c8a63645da38e29a0fdef3c3332d4aced52fb90"},
]
[package.dependencies]
Django = ">=4.2,<6.0"
djangorestframework = ">=3.13.0"
[package.extras]
with-social = ["django-allauth[socialaccount] (>=64.0.0)"]
[metadata]
lock-version = "2.1"
python-versions = "^3.10"
content-hash = "example"