From 066dbe92cb100affddac262db542f42cb3a65a62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20=27sil2100=27=20Zemczak?= Date: Tue, 13 Jan 2026 11:27:51 +0100 Subject: [PATCH] feat(pkg/lint): flag packages that depend on unversioned python packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ɓukasz 'sil2100' Zemczak --- pkg/lint/rules.go | 52 +++++++++++++ pkg/lint/rules_test.go | 167 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/pkg/lint/rules.go b/pkg/lint/rules.go index 23a63ac0a..40bbddad8 100644 --- a/pkg/lint/rules.go +++ b/pkg/lint/rules.go @@ -621,6 +621,43 @@ var AllRules = func(l *Linter) Rules { //nolint:gocyclo return fmt.Errorf("update identifier does not match the repository URI") }, }, + { + Name: "depends-on-unversioned-py-provides", + Description: "packages should not depend on unversioned py3- provides for python multi-version packages", + Severity: SeverityError, + LintFunc: func(cfg config.Configuration) error { + var violations []string + + // Main package runtime deps + for _, dep := range cfg.Package.Dependencies.Runtime { + if isUnversionedPy3Dependency(dep) { + violations = append(violations, dep) + } + } + + // Build-time environment packages + for _, pkg := range cfg.Environment.Contents.Packages { + if isUnversionedPy3Dependency(pkg) { + violations = append(violations, pkg) + } + } + + // Subpackage runtime deps + for _, subPkg := range cfg.Subpackages { + for _, dep := range subPkg.Dependencies.Runtime { + if isUnversionedPy3Dependency(dep) { + violations = append(violations, dep) + } + } + } + + if len(violations) > 0 { + return fmt.Errorf("found unversioned py3-* dependencies: %s (use py3.X-* or py3-supported-* instead)", strings.Join(violations, ", ")) + } + + return nil + }, + }, } } @@ -764,3 +801,18 @@ func isVariableReferencedInRawYAML(root *yaml.Node, varRef string) bool { return walkNode(root, 0) } + +// isUnversionedPy3Dependency checks if a dependency is an unversioned py3-* package +func isUnversionedPy3Dependency(dep string) bool { + if !strings.HasPrefix(dep, "py3-") { + return false + } + + // Exception: py3-supported-* packages + if strings.HasPrefix(dep, "py3-supported-") { + return false + } + + // ...unversioned py3-* dep + return true +} diff --git a/pkg/lint/rules_test.go b/pkg/lint/rules_test.go index 70700e338..505a3b57b 100644 --- a/pkg/lint/rules_test.go +++ b/pkg/lint/rules_test.go @@ -865,3 +865,170 @@ func TestPickPipelinesUsing(t *testing.T) { }) } } + +func TestIsUnversionedPy3Dependency(t *testing.T) { + cases := []struct { + name string + dep string + expected bool + }{ + // Should be flagged as unversioned + { + name: "py3-pip", + dep: "py3-pip", + expected: true, + }, + { + name: "py3-setuptools", + dep: "py3-setuptools", + expected: true, + }, + { + name: "py3-pandas", + dep: "py3-pandas", + expected: true, + }, + { + name: "py3-attrs", + dep: "py3-attrs", + expected: true, + }, + { + name: "py3-cryptography", + dep: "py3-cryptography", + expected: true, + }, + { + name: "py3-foo-bar-baz", + dep: "py3-foo-bar-baz", + expected: true, + }, + // Version constraints should be stripped + { + name: "py3-foo with tilde constraint", + dep: "py3-foo~1.2.3", + expected: true, + }, + { + name: "py3-foo with >= constraint", + dep: "py3-foo>=1.0.0", + expected: true, + }, + { + name: "py3-foo with < constraint", + dep: "py3-foo<2.0.0", + expected: true, + }, + { + name: "py3-foo with = constraint", + dep: "py3-foo=1.2.3", + expected: true, + }, + // Should NOT be flagged - py3-supported-* packages + { + name: "py3-supported-pip", + dep: "py3-supported-pip", + expected: false, + }, + { + name: "py3-supported-setuptools", + dep: "py3-supported-setuptools", + expected: false, + }, + { + name: "py3-supported-build-base", + dep: "py3-supported-build-base", + expected: false, + }, + { + name: "py3-supported-python", + dep: "py3-supported-python", + expected: false, + }, + // Should NOT be flagged - versioned py3.X-* packages + { + name: "py3.10-pip", + dep: "py3.10-pip", + expected: false, + }, + { + name: "py3.11-setuptools", + dep: "py3.11-setuptools", + expected: false, + }, + { + name: "py3.12-pandas", + dep: "py3.12-pandas", + expected: false, + }, + { + name: "py3.13-attrs", + dep: "py3.13-attrs", + expected: false, + }, + { + name: "py3.14-foo", + dep: "py3.14-foo", + expected: false, + }, + // Should NOT be flagged - different patterns + { + name: "python3", + dep: "python3", + expected: false, + }, + { + name: "python-3", + dep: "python-3", + expected: false, + }, + { + name: "python-3.12-base", + dep: "python-3.12-base", + expected: false, + }, + { + name: "python-3.12-base-dev", + dep: "python-3.12-base-dev", + expected: false, + }, + { + name: "busybox", + dep: "busybox", + expected: false, + }, + { + name: "build-base", + dep: "build-base", + expected: false, + }, + // Edge cases + { + name: "empty string", + dep: "", + expected: false, + }, + { + name: "just py3-", + dep: "py3-", + expected: true, + }, + { + name: "py3 without dash", + dep: "py3", + expected: false, + }, + { + name: "py3.X without dash after", + dep: "py3.12", + expected: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := isUnversionedPy3Dependency(c.dep) + assert.Equal(t, c.expected, got, "isUnversionedPy3Dependency(%q) = %v, want %v", c.dep, got, c.expected) + }) + } +}