Skip to content

Commit

Permalink
Upload: All metadata incl. PEP 639
Browse files Browse the repository at this point in the history
We were previously not uploading all metadata in the formdata of an upload request in the legacy api. Notably, we were missing the PEP 639 license-files field.

I had to switch to pdm due to pypa/hatch#1828
  • Loading branch information
konstin committed Nov 26, 2024
1 parent f886d08 commit cded997
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 37 deletions.
85 changes: 56 additions & 29 deletions crates/uv-publish/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,20 +613,49 @@ async fn form_metadata(
) -> Result<Vec<(&'static str, String)>, PublishPrepareError> {
let hash_hex = hash_file(file, Hasher::from(HashAlgorithm::Sha256)).await?;

let metadata = metadata(file, filename).await?;
let Metadata23 {
metadata_version,
name,
version,
platforms,
// Not used by PyPI legacy upload
supported_platforms: _,
summary,
description,
description_content_type,
keywords,
home_page,
download_url,
author,
author_email,
maintainer,
maintainer_email,
license,
license_expression,
license_files,
classifiers,
requires_dist,
provides_dist,
obsoletes_dist,
requires_python,
requires_external,
project_urls,
provides_extras,
dynamic,
} = metadata(file, filename).await?;

let mut form_metadata = vec![
(":action", "file_upload".to_string()),
("sha256_digest", hash_hex.digest.to_string()),
("protocol_version", "1".to_string()),
("metadata_version", metadata.metadata_version.clone()),
("metadata_version", metadata_version.clone()),
// Twine transforms the name with `re.sub("[^A-Za-z0-9.]+", "-", name)`
// * <https://github.com/pypa/twine/issues/743>
// * <https://github.com/pypa/twine/blob/5bf3f38ff3d8b2de47b7baa7b652c697d7a64776/twine/package.py#L57-L65>
// warehouse seems to call `packaging.utils.canonicalize_name` nowadays and has a separate
// `normalized_name`, so we'll start with this and we'll readjust if there are user reports.
("name", metadata.name.clone()),
("version", metadata.version.clone()),
("name", name.clone()),
("version", version.clone()),
("filetype", filename.filetype().to_string()),
];

Expand All @@ -642,41 +671,39 @@ async fn form_metadata(
}
};

add_option("summary", metadata.summary);
add_option("description", metadata.description);
add_option(
"description_content_type",
metadata.description_content_type,
);
add_option("author", metadata.author);
add_option("author_email", metadata.author_email);
add_option("maintainer", metadata.maintainer);
add_option("maintainer_email", metadata.maintainer_email);
add_option("license", metadata.license);
add_option("keywords", metadata.keywords);
add_option("home_page", metadata.home_page);
add_option("download_url", metadata.download_url);
add_option("author", author);
add_option("author_email", author_email);
add_option("description", description);
add_option("description_content_type", description_content_type);
add_option("download_url", download_url);
add_option("home_page", home_page);
add_option("keywords", keywords);
add_option("license", license);
add_option("license_expression", license_expression);
add_option("maintainer", maintainer);
add_option("maintainer_email", maintainer_email);
add_option("summary", summary);

// The GitLab PyPI repository API implementation requires this metadata field and twine always
// includes it in the request, even when it's empty.
form_metadata.push((
"requires_python",
metadata.requires_python.unwrap_or(String::new()),
));
form_metadata.push(("requires_python", requires_python.unwrap_or(String::new())));

let mut add_vec = |name, values: Vec<String>| {
for i in values {
form_metadata.push((name, i.clone()));
}
};

add_vec("classifiers", metadata.classifiers);
add_vec("platform", metadata.platforms);
add_vec("requires_dist", metadata.requires_dist);
add_vec("provides_dist", metadata.provides_dist);
add_vec("obsoletes_dist", metadata.obsoletes_dist);
add_vec("requires_external", metadata.requires_external);
add_vec("project_urls", metadata.project_urls);
add_vec("classifiers", classifiers);
add_vec("dynamic", dynamic);
add_vec("license_file", license_files);
add_vec("obsoletes_dist", obsoletes_dist);
add_vec("platform", platforms);
add_vec("project_urls", project_urls);
add_vec("provides_dist", provides_dist);
add_vec("provides_extra", provides_extras);
add_vec("requires_dist", requires_dist);
add_vec("requires_external", requires_external);

Ok(form_metadata)
}
Expand Down
54 changes: 46 additions & 8 deletions scripts/publish/test_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@

import os
import re
import shutil
import time
from argparse import ArgumentParser
from dataclasses import dataclass
Expand All @@ -70,6 +71,30 @@

TEST_PYPI_PUBLISH_URL = "https://test.pypi.org/legacy/"
PYTHON_VERSION = os.environ.get("UV_TEST_PUBLISH_PYTHON_VERSION", "3.12")
# `pyproject.toml` contents using all supported metadata fields, except for the
# generated header with `[project]`, name and version.
PYPROJECT_TAIL = """
authors = [{ name = "konstin", email = "konstin@mailbox.org" }]
classifiers = ["Topic :: Software Development :: Testing"]
# Empty for simplicity with the `uv compile` check, anyio still tests,
# optional-dependencies still test the `Requires-Dist` field.
dependencies = []
description = "Add your description here"
dynamic = ["gui-scripts", "scripts"]
keywords = ["test", "publish"]
license = "MIT OR Apache-2.0"
license-files = ["LICENSE*"]
maintainers = [{ name = "konstin", email = "konstin@mailbox.org" }]
optional-dependencies = { "async" = ["anyio>=4,<5"] }
readme = "README.md"
requires-python = ">=3.12"
urls = { "github" = "https://github.com/astral-sh/uv" }
# https://github.com/pypa/hatch/issues/1828
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
""".lstrip()

cwd = Path(__file__).parent

Expand Down Expand Up @@ -200,12 +225,22 @@ def build_project_at_version(
[uv, "init", "-p", PYTHON_VERSION, "--lib", "--name", project_name, dir_name],
cwd=cwd,
)
pyproject_toml = project_root.joinpath("pyproject.toml")

# Set to an unclaimed version
toml = pyproject_toml.read_text()
toml = re.sub('version = ".*"', f'version = "{version}"', toml)
pyproject_toml.write_text(toml)
project_root.joinpath("pyproject.toml").write_text(
"[project]\n"
+ f'name = "{project_name}"\n'
# Set to an unclaimed version
+ f'version = "{version}"\n'
# Add all supported metadata
+ PYPROJECT_TAIL
)
shutil.copy(
cwd.parent.parent.joinpath("LICENSE-APACHE"),
cwd.joinpath(dir_name).joinpath("LICENSE-APACHE"),
)
shutil.copy(
cwd.parent.parent.joinpath("LICENSE-MIT"),
cwd.joinpath(dir_name).joinpath("LICENSE-MIT"),
)

# Modify the code so we get a different source dist and wheel
if modified:
Expand Down Expand Up @@ -262,8 +297,11 @@ def wait_for_index(
break

print(
f"uv pip compile not updated, missing 2 files for {version}: `{output.replace("\\\n ", "")}`, "
f"sleeping for 2s: `{index_url}`"
f"uv pip compile not updated, missing 2 files for {version}, "
+ f"sleeping for 2s: `{index_url}`:\n"
+ f"```\n"
+ output.replace("\\\n ", "")
+ "```"
)
sleep(2)

Expand Down

0 comments on commit cded997

Please sign in to comment.