Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add uv publish: Basic upload with username/password or keyring #7475

Merged
merged 16 commits into from
Sep 24, 2024
Merged
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,61 @@ jobs:
env:
UV_PROJECT_ENVIRONMENT: "/home/runner/example"

integration-test-publish:
timeout-minutes: 10
needs: build-binary-linux
name: "integration test | uv publish"
runs-on: ubuntu-latest
# Only the main repository is a trusted publisher
if: github.repository == 'astral-sh/uv'
environment: uv-test-publish
env:
# No dbus in GitHub Actions
PYTHON_KEYRING_BACKEND: keyrings.alt.file.PlaintextKeyring
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

# Only publish a new release if the
- uses: tj-actions/changed-files@v45
id: changed
with:
files_yaml: |
code:
- "crates/uv-publish/**/*"
- "scripts/publish/**/*"

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: "Download binary"
uses: actions/download-artifact@v4
with:
name: uv-linux-${{ github.sha }}

- name: "Prepare binary"
run: chmod +x ./uv

- name: "Add password to keyring"
run: |
# `keyrings.alt` contains the plaintext keyring
./uv tool install --with keyrings.alt "keyring<25.4.0" # TODO(konsti): Remove upper bound once fix is released
echo $UV_TEST_PUBLISH_KEYRING | keyring set https://test.pypi.org/legacy/?astral-test-keyring __token__
env:
UV_TEST_PUBLISH_KEYRING: ${{ secrets.UV_TEST_PUBLISH_KEYRING }}

- name: "Publish test packages"
if: ${{ steps.changed.outputs.code_any_changed }}
# `-p 3.12` prefers the python we just installed over the one locked in `.python_version`.
run: ./uv run -p 3.12 scripts/publish/test_publish.py --uv ./uv all
env:
RUST_LOG: uv=debug,uv_publish=trace
UV_TEST_PUBLISH_TOKEN: ${{ secrets.UV_TEST_PUBLISH_TOKEN }}
UV_TEST_PUBLISH_PASSWORD: ${{ secrets.UV_TEST_PUBLISH_PASSWORD }}
UV_TEST_PUBLISH_GITLAB_PAT: ${{ secrets.UV_TEST_PUBLISH_GITLAB_PAT }}

cache-test-ubuntu:
timeout-minutes: 10
needs: build-binary-linux
Expand Down
51 changes: 51 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ uv-metadata = { path = "crates/uv-metadata" }
uv-normalize = { path = "crates/uv-normalize" }
uv-options-metadata = { path = "crates/uv-options-metadata" }
uv-pubgrub = { path = "crates/uv-pubgrub" }
uv-publish = { path = "crates/uv-publish" }
uv-python = { path = "crates/uv-python" }
uv-requirements = { path = "crates/uv-requirements" }
uv-resolver = { path = "crates/uv-resolver" }
Expand Down Expand Up @@ -123,8 +124,8 @@ quote = { version = "1.0.37" }
rayon = { version = "1.10.0" }
reflink-copy = { version = "0.1.19" }
regex = { version = "1.10.6" }
reqwest = { version = "0.12.7", default-features = false, features = ["json", "gzip", "stream", "rustls-tls", "rustls-tls-native-roots", "socks"] }
reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "5e3eaf254b5bd481c75d2710eed055f95b756913" }
reqwest = { version = "0.12.7", default-features = false, features = ["json", "gzip", "stream", "rustls-tls", "rustls-tls-native-roots", "socks", "multipart"] }
reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "5e3eaf254b5bd481c75d2710eed055f95b756913", features = ["multipart"] }
reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "5e3eaf254b5bd481c75d2710eed055f95b756913" }
rkyv = { version = "0.8.8", features = ["bytecheck"] }
rmp-serde = { version = "1.3.0" }
Expand Down
8 changes: 8 additions & 0 deletions crates/distribution-filename/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ impl DistFilename {
Self::WheelFilename(filename) => &filename.version,
}
}

/// Whether the file is a `bdist_wheel` or an `sdist`.
pub fn filetype(&self) -> &'static str {
match self {
Self::SourceDistFilename(_) => "sdist",
Self::WheelFilename(_) => "bdist_wheel",
}
}
}

impl Display for DistFilename {
Expand Down
199 changes: 199 additions & 0 deletions crates/pypi-types/src/metadata/metadata23.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
//! Vendored from <https://github.com/PyO3/python-pkginfo-rs>
use crate::metadata::Headers;
use crate::MetadataError;
use std::str;
use std::str::FromStr;

/// Code Metadata 2.3 as specified in
/// <https://packaging.python.org/specifications/core-metadata/>.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Metadata23 {
/// Version of the file format; legal values are `1.0`, `1.1`, `1.2`, `2.1`, `2.2` and `2.3`.
pub metadata_version: String,
/// The name of the distribution.
pub name: String,
/// A string containing the distribution’s version number.
pub version: String,
/// A Platform specification describing an operating system supported by the distribution
/// which is not listed in the “Operating System” Trove classifiers.
pub platforms: Vec<String>,
/// Binary distributions containing a PKG-INFO file will use the Supported-Platform field
/// in their metadata to specify the OS and CPU for which the binary distribution was compiled.
pub supported_platforms: Vec<String>,
/// A one-line summary of what the distribution does.
pub summary: Option<String>,
/// A longer description of the distribution that can run to several paragraphs.
pub description: Option<String>,
/// A list of additional keywords, separated by commas, to be used to
/// assist searching for the distribution in a larger catalog.
pub keywords: Option<String>,
/// A string containing the URL for the distribution’s home page.
pub home_page: Option<String>,
/// A string containing the URL from which this version of the distribution can be downloaded.
pub download_url: Option<String>,
/// A string containing the author’s name at a minimum; additional contact information may be provided.
pub author: Option<String>,
/// A string containing the author’s e-mail address. It can contain a name and e-mail address in the legal forms for a RFC-822 `From:` header.
pub author_email: Option<String>,
/// Text indicating the license covering the distribution where the license is not a selection from the `License` Trove classifiers or an SPDX license expression.
pub license: Option<String>,
/// An SPDX expression indicating the license covering the distribution.
pub license_expression: Option<String>,
/// Paths to files containing the text of the licenses covering the distribution.
pub license_files: Vec<String>,
/// Each entry is a string giving a single classification value for the distribution.
pub classifiers: Vec<String>,
/// Each entry contains a string naming some other distutils project required by this distribution.
pub requires_dist: Vec<String>,
/// Each entry contains a string naming a Distutils project which is contained within this distribution.
pub provides_dist: Vec<String>,
/// Each entry contains a string describing a distutils project’s distribution which this distribution renders obsolete,
/// meaning that the two projects should not be installed at the same time.
pub obsoletes_dist: Vec<String>,
/// A string containing the maintainer’s name at a minimum; additional contact information may be provided.
///
/// Note that this field is intended for use when a project is being maintained by someone other than the original author:
/// it should be omitted if it is identical to `author`.
pub maintainer: Option<String>,
/// A string containing the maintainer’s e-mail address.
/// It can contain a name and e-mail address in the legal forms for a RFC-822 `From:` header.
///
/// Note that this field is intended for use when a project is being maintained by someone other than the original author:
/// it should be omitted if it is identical to `author_email`.
pub maintainer_email: Option<String>,
/// This field specifies the Python version(s) that the distribution is guaranteed to be compatible with.
pub requires_python: Option<String>,
/// Each entry contains a string describing some dependency in the system that the distribution is to be used.
pub requires_external: Vec<String>,
/// A string containing a browsable URL for the project and a label for it, separated by a comma.
pub project_urls: Vec<String>,
/// A string containing the name of an optional feature. Must be a valid Python identifier.
/// May be used to make a dependency conditional on whether the optional feature has been requested.
pub provides_extras: Vec<String>,
/// A string stating the markup syntax (if any) used in the distribution’s description,
/// so that tools can intelligently render the description.
pub description_content_type: Option<String>,
/// A string containing the name of another core metadata field.
pub dynamic: Vec<String>,
}

impl Metadata23 {
/// Parse distribution metadata from metadata `MetadataError`
pub fn parse(content: &[u8]) -> Result<Self, MetadataError> {
let headers = Headers::parse(content)?;

let metadata_version = headers
.get_first_value("Metadata-Version")
.ok_or(MetadataError::FieldNotFound("Metadata-Version"))?;
let name = headers
.get_first_value("Name")
.ok_or(MetadataError::FieldNotFound("Name"))?;
let version = headers
.get_first_value("Version")
.ok_or(MetadataError::FieldNotFound("Version"))?;
let platforms = headers.get_all_values("Platform").collect();
let supported_platforms = headers.get_all_values("Supported-Platform").collect();
let summary = headers.get_first_value("Summary");
let body = str::from_utf8(&content[headers.body_start..])
.map_err(MetadataError::DescriptionEncoding)?;
let description = if body.trim().is_empty() {
headers.get_first_value("Description")
} else {
Some(body.to_string())
};
let keywords = headers.get_first_value("Keywords");
let home_page = headers.get_first_value("Home-Page");
let download_url = headers.get_first_value("Download-URL");
let author = headers.get_first_value("Author");
let author_email = headers.get_first_value("Author-email");
let license = headers.get_first_value("License");
let license_expression = headers.get_first_value("License-Expression");
let license_files = headers.get_all_values("License-File").collect();
let classifiers = headers.get_all_values("Classifier").collect();
let requires_dist = headers.get_all_values("Requires-Dist").collect();
let provides_dist = headers.get_all_values("Provides-Dist").collect();
let obsoletes_dist = headers.get_all_values("Obsoletes-Dist").collect();
let maintainer = headers.get_first_value("Maintainer");
let maintainer_email = headers.get_first_value("Maintainer-email");
let requires_python = headers.get_first_value("Requires-Python");
let requires_external = headers.get_all_values("Requires-External").collect();
let project_urls = headers.get_all_values("Project-URL").collect();
let provides_extras = headers.get_all_values("Provides-Extra").collect();
let description_content_type = headers.get_first_value("Description-Content-Type");
let dynamic = headers.get_all_values("Dynamic").collect();
Ok(Metadata23 {
metadata_version,
name,
version,
platforms,
supported_platforms,
summary,
description,
keywords,
home_page,
download_url,
author,
author_email,
license,
license_expression,
license_files,
classifiers,
requires_dist,
provides_dist,
obsoletes_dist,
maintainer,
maintainer_email,
requires_python,
requires_external,
project_urls,
provides_extras,
description_content_type,
dynamic,
})
}
}

impl FromStr for Metadata23 {
type Err = MetadataError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Metadata23::parse(s.as_bytes())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::MetadataError;

#[test]
fn test_parse_from_str() {
let s = "Metadata-Version: 1.0";
let meta: Result<Metadata23, MetadataError> = s.parse();
assert!(matches!(meta, Err(MetadataError::FieldNotFound("Name"))));

let s = "Metadata-Version: 1.0\nName: asdf";
let meta = Metadata23::parse(s.as_bytes());
assert!(matches!(meta, Err(MetadataError::FieldNotFound("Version"))));

let s = "Metadata-Version: 1.0\nName: asdf\nVersion: 1.0";
let meta = Metadata23::parse(s.as_bytes()).unwrap();
assert_eq!(meta.metadata_version, "1.0");
assert_eq!(meta.name, "asdf");
assert_eq!(meta.version, "1.0");

let s = "Metadata-Version: 1.0\nName: asdf\nVersion: 1.0\nDescription: a Python package";
let meta: Metadata23 = s.parse().unwrap();
assert_eq!(meta.description.as_deref(), Some("a Python package"));

let s = "Metadata-Version: 1.0\nName: asdf\nVersion: 1.0\n\na Python package";
let meta: Metadata23 = s.parse().unwrap();
assert_eq!(meta.description.as_deref(), Some("a Python package"));

let s = "Metadata-Version: 1.0\nName: asdf\nVersion: 1.0\nAuthor: 中文\n\n一个 Python 包";
let meta: Metadata23 = s.parse().unwrap();
assert_eq!(meta.author.as_deref(), Some("中文"));
assert_eq!(meta.description.as_deref(), Some("一个 Python 包"));
}
}
Loading
Loading