Skip to content

Commit 188e0ee

Browse files
authored
Use Project-URL metadata field to get a PyPI package's homepage URL (#33089)
Resolves #33085.
1 parent 68972a9 commit 188e0ee

File tree

3 files changed

+116
-10
lines changed

3 files changed

+116
-10
lines changed

routers/api/packages/pypi/pypi.go

+43-4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"regexp"
1111
"sort"
1212
"strings"
13+
"unicode"
1314

1415
packages_model "code.gitea.io/gitea/models/packages"
1516
packages_module "code.gitea.io/gitea/modules/packages"
@@ -139,9 +140,30 @@ func UploadPackageFile(ctx *context.Context) {
139140
return
140141
}
141142

142-
projectURL := ctx.Req.FormValue("home_page")
143-
if !validation.IsValidURL(projectURL) {
144-
projectURL = ""
143+
// Ensure ctx.Req.Form exists.
144+
_ = ctx.Req.ParseForm()
145+
146+
var homepageURL string
147+
projectURLs := ctx.Req.Form["project_urls"]
148+
for _, purl := range projectURLs {
149+
label, url, found := strings.Cut(purl, ",")
150+
if !found {
151+
continue
152+
}
153+
if normalizeLabel(label) != "homepage" {
154+
continue
155+
}
156+
homepageURL = strings.TrimSpace(url)
157+
break
158+
}
159+
160+
if len(homepageURL) == 0 {
161+
// TODO: Home-page is a deprecated metadata field. Remove this branch once it's no longer apart of the spec.
162+
homepageURL = ctx.Req.FormValue("home_page")
163+
}
164+
165+
if !validation.IsValidURL(homepageURL) {
166+
homepageURL = ""
145167
}
146168

147169
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
@@ -160,7 +182,7 @@ func UploadPackageFile(ctx *context.Context) {
160182
Description: ctx.Req.FormValue("description"),
161183
LongDescription: ctx.Req.FormValue("long_description"),
162184
Summary: ctx.Req.FormValue("summary"),
163-
ProjectURL: projectURL,
185+
ProjectURL: homepageURL,
164186
License: ctx.Req.FormValue("license"),
165187
RequiresPython: ctx.Req.FormValue("requires_python"),
166188
},
@@ -189,6 +211,23 @@ func UploadPackageFile(ctx *context.Context) {
189211
ctx.Status(http.StatusCreated)
190212
}
191213

214+
// Normalizes a Project-URL label.
215+
// See https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization.
216+
func normalizeLabel(label string) string {
217+
var builder strings.Builder
218+
219+
// "A label is normalized by deleting all ASCII punctuation and whitespace, and then converting the result
220+
// to lowercase."
221+
for _, r := range label {
222+
if unicode.IsPunct(r) || unicode.IsSpace(r) {
223+
continue
224+
}
225+
builder.WriteRune(unicode.ToLower(r))
226+
}
227+
228+
return builder.String()
229+
}
230+
192231
func isValidNameAndVersion(packageName, packageVersion string) bool {
193232
return nameMatcher.MatchString(packageName) && versionMatcher.MatchString(packageVersion)
194233
}

routers/api/packages/pypi/pypi_test.go

+10
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,13 @@ func TestIsValidNameAndVersion(t *testing.T) {
3636
assert.False(t, isValidNameAndVersion("test-name", "1.0.1aa"))
3737
assert.False(t, isValidNameAndVersion("test-name", "1.0.0-alpha.beta"))
3838
}
39+
40+
func TestNormalizeLabel(t *testing.T) {
41+
// Cases fetched from https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization.
42+
assert.Equal(t, "homepage", normalizeLabel("Homepage"))
43+
assert.Equal(t, "homepage", normalizeLabel("Home-page"))
44+
assert.Equal(t, "homepage", normalizeLabel("Home page"))
45+
assert.Equal(t, "changelog", normalizeLabel("Change_Log"))
46+
assert.Equal(t, "whatsnew", normalizeLabel("What's New?"))
47+
assert.Equal(t, "github", normalizeLabel("github"))
48+
}

tests/integration/api_packages_pypi_test.go

+63-6
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,16 @@ func TestPackagePyPI(t *testing.T) {
3232
packageVersion := "1!1.0.1+r1234"
3333
packageAuthor := "KN4CK3R"
3434
packageDescription := "Test Description"
35+
projectURL := "https://example.com"
3536

3637
content := "test"
3738
hashSHA256 := "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
3839

3940
root := fmt.Sprintf("/api/packages/%s/pypi", user.Name)
4041

41-
uploadFile := func(t *testing.T, filename, content string, expectedStatus int) {
42-
body := &bytes.Buffer{}
43-
writer := multipart.NewWriter(body)
42+
createBasicMultipartFile := func(filename, packageName, content string) (body *bytes.Buffer, writer *multipart.Writer, closer func() error) {
43+
body = &bytes.Buffer{}
44+
writer = multipart.NewWriter(body)
4445
part, _ := writer.CreateFormFile("content", filename)
4546
_, _ = io.Copy(part, strings.NewReader(content))
4647

@@ -52,14 +53,27 @@ func TestPackagePyPI(t *testing.T) {
5253
writer.WriteField("sha256_digest", hashSHA256)
5354
writer.WriteField("requires_python", "3.6")
5455

55-
_ = writer.Close()
56+
return body, writer, writer.Close
57+
}
5658

59+
uploadHelper := func(t *testing.T, body *bytes.Buffer, contentType string, expectedStatus int) {
5760
req := NewRequestWithBody(t, "POST", root, body).
58-
SetHeader("Content-Type", writer.FormDataContentType()).
61+
SetHeader("Content-Type", contentType).
5962
AddBasicAuth(user.Name)
6063
MakeRequest(t, req, expectedStatus)
6164
}
6265

66+
uploadFile := func(t *testing.T, filename, content string, expectedStatus int) {
67+
body, writer, closeFunc := createBasicMultipartFile(filename, packageName, content)
68+
69+
writer.WriteField("project_urls", "DOCUMENTATION , https://readthedocs.org")
70+
writer.WriteField("project_urls", fmt.Sprintf("Home-page, %s", projectURL))
71+
72+
_ = closeFunc()
73+
74+
uploadHelper(t, body, writer.FormDataContentType(), expectedStatus)
75+
}
76+
6377
t.Run("Upload", func(t *testing.T) {
6478
defer tests.PrintCurrentTest(t)()
6579

@@ -74,6 +88,7 @@ func TestPackagePyPI(t *testing.T) {
7488
assert.NoError(t, err)
7589
assert.Nil(t, pd.SemVer)
7690
assert.IsType(t, &pypi.Metadata{}, pd.Metadata)
91+
assert.Equal(t, projectURL, pd.Metadata.(*pypi.Metadata).ProjectURL)
7792
assert.Equal(t, packageName, pd.Package.Name)
7893
assert.Equal(t, packageVersion, pd.Version.Version)
7994

@@ -133,6 +148,48 @@ func TestPackagePyPI(t *testing.T) {
133148
uploadFile(t, "test.tar.gz", content, http.StatusConflict)
134149
})
135150

151+
t.Run("UploadUsingDeprecatedHomepageMetadata", func(t *testing.T) {
152+
defer tests.PrintCurrentTest(t)()
153+
154+
pkgName := "homepage-package"
155+
body, writer, closeFunc := createBasicMultipartFile("test.whl", pkgName, content)
156+
157+
writer.WriteField("home_page", projectURL)
158+
159+
_ = closeFunc()
160+
161+
uploadHelper(t, body, writer.FormDataContentType(), http.StatusCreated)
162+
163+
pvs, err := packages.GetVersionsByPackageName(db.DefaultContext, user.ID, packages.TypePyPI, pkgName)
164+
assert.NoError(t, err)
165+
assert.Len(t, pvs, 1)
166+
167+
pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
168+
assert.NoError(t, err)
169+
assert.IsType(t, &pypi.Metadata{}, pd.Metadata)
170+
assert.Equal(t, projectURL, pd.Metadata.(*pypi.Metadata).ProjectURL)
171+
})
172+
173+
t.Run("UploadWithoutAnyHomepageURLMetadata", func(t *testing.T) {
174+
defer tests.PrintCurrentTest(t)()
175+
176+
pkgName := "no-project-url-or-homepage-package"
177+
body, writer, closeFunc := createBasicMultipartFile("test.whl", pkgName, content)
178+
179+
_ = closeFunc()
180+
181+
uploadHelper(t, body, writer.FormDataContentType(), http.StatusCreated)
182+
183+
pvs, err := packages.GetVersionsByPackageName(db.DefaultContext, user.ID, packages.TypePyPI, pkgName)
184+
assert.NoError(t, err)
185+
assert.Len(t, pvs, 1)
186+
187+
pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
188+
assert.NoError(t, err)
189+
assert.IsType(t, &pypi.Metadata{}, pd.Metadata)
190+
assert.Empty(t, pd.Metadata.(*pypi.Metadata).ProjectURL)
191+
})
192+
136193
t.Run("Download", func(t *testing.T) {
137194
defer tests.PrintCurrentTest(t)()
138195

@@ -147,7 +204,7 @@ func TestPackagePyPI(t *testing.T) {
147204
downloadFile("test.whl")
148205
downloadFile("test.tar.gz")
149206

150-
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePyPI)
207+
pvs, err := packages.GetVersionsByPackageName(db.DefaultContext, user.ID, packages.TypePyPI, packageName)
151208
assert.NoError(t, err)
152209
assert.Len(t, pvs, 1)
153210
assert.Equal(t, int64(2), pvs[0].DownloadCount)

0 commit comments

Comments
 (0)