mirror of
https://github.com/astral-sh/uv.git
synced 2026-05-06 08:56:53 -04:00
Use trusted publishing for crates.io (#18709)
Moves from a crates.io API key to trusted publishing. Setup of trusted publishing is automated via a script which creates the trust relationship and disables publish by API key. The main breakage here is that now, when we add a new crate, a release will fail. The script is invoked during `release.sh` to catch this case and supports creating a stub crate so the release can subsequently succeed — but this will require the release author to have a local crates.io API key with permissions to create projects and configure publishing. I tested this script a few times end-to-end, but would not be surprised if it bites us in the future.
This commit is contained in:
@@ -20,15 +20,15 @@ jobs:
|
||||
deployment: false
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# TODO(zanieb): Switch to trusted publishing once published
|
||||
# - uses: rust-lang/crates-io-auth-action@v1
|
||||
# id: auth
|
||||
- uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4
|
||||
id: auth
|
||||
- name: Publish workspace crates
|
||||
# Note `--no-verify` is safe because we do a publish dry-run elsewhere in CI
|
||||
run: cargo publish --workspace --no-verify
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_TOKEN }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
|
||||
|
||||
@@ -273,6 +273,7 @@ jobs:
|
||||
# publish jobs get escalated permissions
|
||||
permissions:
|
||||
"contents": "read"
|
||||
"id-token": "write"
|
||||
|
||||
# Create a GitHub Release while uploading all files to it
|
||||
announce:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# GENERATED-BY scripts/setup-crates-io-publish.py
|
||||
uv
|
||||
uv-audit
|
||||
uv-auth
|
||||
uv-bin-install
|
||||
uv-build
|
||||
uv-build-backend
|
||||
uv-build-frontend
|
||||
uv-cache
|
||||
uv-cache-info
|
||||
uv-cache-key
|
||||
uv-cli
|
||||
uv-client
|
||||
uv-configuration
|
||||
uv-console
|
||||
uv-dirs
|
||||
uv-dispatch
|
||||
uv-distribution
|
||||
uv-distribution-filename
|
||||
uv-distribution-types
|
||||
uv-extract
|
||||
uv-flags
|
||||
uv-fs
|
||||
uv-git
|
||||
uv-git-types
|
||||
uv-globfilter
|
||||
uv-install-wheel
|
||||
uv-installer
|
||||
uv-keyring
|
||||
uv-logging
|
||||
uv-macros
|
||||
uv-metadata
|
||||
uv-normalize
|
||||
uv-once-map
|
||||
uv-options-metadata
|
||||
uv-pep440
|
||||
uv-pep508
|
||||
uv-performance-memory-allocator
|
||||
uv-platform
|
||||
uv-platform-tags
|
||||
uv-preview
|
||||
uv-publish
|
||||
uv-pypi-types
|
||||
uv-python
|
||||
uv-redacted
|
||||
uv-requirements
|
||||
uv-requirements-txt
|
||||
uv-resolver
|
||||
uv-scripts
|
||||
uv-settings
|
||||
uv-shell
|
||||
uv-small-str
|
||||
uv-state
|
||||
uv-static
|
||||
uv-test
|
||||
uv-tool
|
||||
uv-torch
|
||||
uv-trampoline-builder
|
||||
uv-types
|
||||
uv-unix
|
||||
uv-version
|
||||
uv-virtualenv
|
||||
uv-warnings
|
||||
uv-windows
|
||||
uv-workspace
|
||||
@@ -27,6 +27,9 @@ uv lock
|
||||
echo "Generating JSON schema..."
|
||||
cargo dev generate-json-schema
|
||||
|
||||
echo "Checking crates.io publish setup..."
|
||||
uv run "$project_root/scripts/setup-crates-io-publish.py" --quiet
|
||||
|
||||
echo "Creating release branch..."
|
||||
git checkout -b "release/$(uv version --short)"
|
||||
git commit -am "Bump version to $(uv version --short)"
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
# Ensure workspace crates are ready for trusted publishing on crates.io.
|
||||
#
|
||||
# This script performs four steps for each candidate crate:
|
||||
#
|
||||
# 1. Publish a placeholder crate if the crate doesn't yet exist on crates.io.
|
||||
# 2. Remove trusted publisher configs that don't match our desired config.
|
||||
# 3. Ensure exactly one desired trusted publishing config exists.
|
||||
# 4. Enable `trustpub_only` ("Require trusted publishing for all new versions").
|
||||
#
|
||||
# It authenticates with `CARGO_REGISTRY_TOKEN`, which must have the `publish-new` and
|
||||
# `trusted-publishing` scopes.
|
||||
#
|
||||
# Crates tracked in `.known-crates` are assumed to be configured and are skipped unless `--force` is
|
||||
# used.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# CARGO_REGISTRY_TOKEN=<tok> uv run scripts/setup-crates-io-publish.py [--dry-run] [--force] [--quiet]
|
||||
|
||||
# /// script
|
||||
# requires-python = ">=3.13"
|
||||
# dependencies = ["httpx"]
|
||||
# ///
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import tomllib
|
||||
|
||||
import httpx
|
||||
|
||||
CRATES_IO_API = "https://crates.io/api/v1"
|
||||
USER_AGENT = "uv-crates-io-publish-setup (github.com/astral-sh/uv)"
|
||||
|
||||
REPOSITORY_OWNER = "astral-sh"
|
||||
REPOSITORY_NAME = "uv"
|
||||
WORKFLOW_FILENAME = "publish-crates.yml"
|
||||
ENVIRONMENT = "release"
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
WORKSPACE_MANIFEST_PATH = REPO_ROOT / "Cargo.toml"
|
||||
KNOWN_CRATES_PATH = REPO_ROOT / ".known-crates"
|
||||
KNOWN_CRATES_HEADER = "# GENERATED-BY scripts/setup-crates-io-publish.py\n"
|
||||
|
||||
PLACEHOLDER_VERSION = "0.0.0"
|
||||
|
||||
# Delay between `cargo publish` calls to respect crates.io rate limits.
|
||||
PUBLISH_DELAY_SECS = 15
|
||||
|
||||
|
||||
def get_publishable_crates() -> list[dict[str, str]]:
|
||||
"""Return publishable workspace crates as ``[{"name": …, "version": …}]``."""
|
||||
result = subprocess.run(
|
||||
["cargo", "metadata", "--format-version", "1", "--no-deps"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
metadata = json.loads(result.stdout)
|
||||
|
||||
workspace_member_ids = set(metadata["workspace_members"])
|
||||
crates = []
|
||||
for package in metadata["packages"]:
|
||||
if package["id"] not in workspace_member_ids:
|
||||
continue
|
||||
# ``publish = false`` is represented as an empty list in cargo metadata.
|
||||
if package.get("publish") == []:
|
||||
continue
|
||||
crates.append({"name": package["name"], "version": package["version"]})
|
||||
|
||||
return sorted(crates, key=lambda c: c["name"])
|
||||
|
||||
|
||||
def load_known_crates() -> set[str]:
|
||||
"""Load crate names from ``.known-crates``."""
|
||||
if not KNOWN_CRATES_PATH.exists():
|
||||
return set()
|
||||
|
||||
known = set()
|
||||
for line in KNOWN_CRATES_PATH.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
known.add(line)
|
||||
return known
|
||||
|
||||
|
||||
def save_known_crates(crates: set[str]) -> None:
|
||||
"""Persist the sorted set of fully-configured crate names."""
|
||||
lines = [KNOWN_CRATES_HEADER]
|
||||
for name in sorted(crates):
|
||||
lines.append(f"{name}\n")
|
||||
KNOWN_CRATES_PATH.write_text("".join(lines))
|
||||
|
||||
|
||||
def get_crate_metadata(
|
||||
client: httpx.Client, crate_name: str
|
||||
) -> dict[str, object] | None:
|
||||
"""Get crate metadata from the public crates.io API."""
|
||||
response = client.get(f"{CRATES_IO_API}/crates/{crate_name}")
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def load_workspace_package_metadata() -> dict[str, object]:
|
||||
"""Load shared package metadata from the workspace manifest."""
|
||||
manifest = tomllib.loads(WORKSPACE_MANIFEST_PATH.read_text())
|
||||
workspace_package = manifest.get("workspace", {}).get("package")
|
||||
if not isinstance(workspace_package, dict):
|
||||
raise RuntimeError("workspace.package is missing from Cargo.toml")
|
||||
return workspace_package
|
||||
|
||||
|
||||
def publish_placeholder_crate(
|
||||
crate_name: str,
|
||||
workspace_package: dict[str, object],
|
||||
) -> bool:
|
||||
"""Publish a generated placeholder crate to reserve a new crates.io name."""
|
||||
with tempfile.TemporaryDirectory(prefix=f"{crate_name}-placeholder-") as temp_dir:
|
||||
temp_path = pathlib.Path(temp_dir)
|
||||
src_dir = temp_path / "src"
|
||||
src_dir.mkdir(parents=True)
|
||||
|
||||
authors = workspace_package.get("authors", [])
|
||||
if not isinstance(authors, list):
|
||||
raise RuntimeError("workspace.package.authors must be a list")
|
||||
|
||||
edition = workspace_package.get("edition")
|
||||
rust_version = workspace_package.get("rust-version")
|
||||
homepage = workspace_package.get("homepage")
|
||||
repository = workspace_package.get("repository")
|
||||
license_expression = workspace_package.get("license")
|
||||
|
||||
description = (
|
||||
"This is a placeholder release for an internal component crate of uv"
|
||||
)
|
||||
|
||||
manifest = (
|
||||
"[package]\n"
|
||||
f"name = {json.dumps(crate_name)}\n"
|
||||
f"version = {json.dumps(PLACEHOLDER_VERSION)}\n"
|
||||
f"edition = {json.dumps(edition)}\n"
|
||||
f"rust-version = {json.dumps(rust_version)}\n"
|
||||
f"authors = {json.dumps(authors)}\n"
|
||||
f"license = {json.dumps(license_expression)}\n"
|
||||
f"homepage = {json.dumps(homepage)}\n"
|
||||
f"repository = {json.dumps(repository)}\n"
|
||||
f"description = {json.dumps(description)}\n"
|
||||
'readme = "README.md"\n'
|
||||
)
|
||||
(temp_path / "Cargo.toml").write_text(manifest)
|
||||
(temp_path / "README.md").write_text(
|
||||
"<!-- This file is generated. DO NOT EDIT -->\n\n"
|
||||
f"# {crate_name}\n\n"
|
||||
f"This crate is an internal component of [uv](https://crates.io/crates/uv). "
|
||||
f"This placeholder version ({PLACEHOLDER_VERSION}) only exists to reserve the "
|
||||
"crate name and enable trusted publishing for future releases.\n"
|
||||
)
|
||||
(src_dir / "lib.rs").write_text(
|
||||
"//! Placeholder crate published to reserve the name on crates.io.\n\n"
|
||||
"/// Marker type for the placeholder release.\n"
|
||||
"pub struct Placeholder;\n"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"cargo",
|
||||
"publish",
|
||||
"--manifest-path",
|
||||
str(temp_path / "Cargo.toml"),
|
||||
"--no-verify",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(result.stderr, file=sys.stderr, end="")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def create_trusted_publisher(client: httpx.Client, crate_name: str) -> None:
|
||||
"""Create a Trusted Publishing GitHub config for *crate_name*."""
|
||||
response = client.post(
|
||||
f"{CRATES_IO_API}/trusted_publishing/github_configs",
|
||||
json={
|
||||
"github_config": {
|
||||
"crate": crate_name,
|
||||
"repository_owner": REPOSITORY_OWNER,
|
||||
"repository_name": REPOSITORY_NAME,
|
||||
"workflow_filename": WORKFLOW_FILENAME,
|
||||
"environment": ENVIRONMENT,
|
||||
}
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def list_trusted_publishers(
|
||||
client: httpx.Client, crate_name: str
|
||||
) -> list[dict[str, object]]:
|
||||
"""List Trusted Publishing GitHub configs for *crate_name*."""
|
||||
response = client.get(
|
||||
f"{CRATES_IO_API}/trusted_publishing/github_configs",
|
||||
params={"crate": crate_name},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload.get("github_configs", [])
|
||||
|
||||
|
||||
def delete_trusted_publisher(client: httpx.Client, config_id: int) -> None:
|
||||
"""Delete a Trusted Publishing GitHub config by ID."""
|
||||
response = client.delete(
|
||||
f"{CRATES_IO_API}/trusted_publishing/github_configs/{config_id}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def set_trustpub_only(client: httpx.Client, crate_name: str, enabled: bool) -> None:
|
||||
"""Enable or disable `trustpub_only` for a crate."""
|
||||
response = client.patch(
|
||||
f"{CRATES_IO_API}/crates/{crate_name}",
|
||||
json={"crate": {"trustpub_only": enabled}},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def is_desired_config(config: dict[str, object]) -> bool:
|
||||
"""Return True if *config* matches the workflow this repo uses."""
|
||||
return (
|
||||
config.get("repository_owner") == REPOSITORY_OWNER
|
||||
and config.get("repository_name") == REPOSITORY_NAME
|
||||
and config.get("workflow_filename") == WORKFLOW_FILENAME
|
||||
and config.get("environment") == ENVIRONMENT
|
||||
)
|
||||
|
||||
|
||||
def handle_trusted_publisher_error(exc: httpx.HTTPStatusError) -> None:
|
||||
"""Print a helpful hint for common trusted-publishing API errors, then exit."""
|
||||
print(
|
||||
f"error {exc.response.status_code}: {exc.response.text}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if exc.response.status_code == 401:
|
||||
if "GitHub session has expired" in exc.response.text:
|
||||
print(
|
||||
"\nhint: your crates.io account's linked GitHub OAuth"
|
||||
" token is stale.\nLog out and back in at"
|
||||
" https://crates.io to refresh it, then re-run.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"\nhint: your CARGO_REGISTRY_TOKEN may be invalid or revoked.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif exc.response.status_code == 403:
|
||||
print(
|
||||
"\nhint: your token may lack the trusted-publishing scope,"
|
||||
" or you are not an owner of this crate.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Ensure workspace crates are ready for trusted publishing on crates.io."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be done without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Re-check crates already in .known-crates"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quiet", "-q", action="store_true", help="Suppress informational output"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
dry_run = args.dry_run
|
||||
force = args.force
|
||||
quiet = args.quiet
|
||||
|
||||
workspace_package = load_workspace_package_metadata()
|
||||
|
||||
crates = get_publishable_crates()
|
||||
known = set() if force else load_known_crates()
|
||||
candidates = [c for c in crates if c["name"] not in known]
|
||||
|
||||
if not candidates:
|
||||
if not quiet:
|
||||
print(
|
||||
f"All {len(crates)} publishable crates are in .known-crates — nothing to do."
|
||||
)
|
||||
return
|
||||
|
||||
token = os.environ.get("CARGO_REGISTRY_TOKEN", "")
|
||||
if not token:
|
||||
print(
|
||||
"error: CARGO_REGISTRY_TOKEN is required (with `publish-new`"
|
||||
" and `trusted-publishing` scopes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
client = httpx.Client(headers={"User-Agent": USER_AGENT}, timeout=30)
|
||||
auth_client = httpx.Client(
|
||||
headers={"Authorization": token, "User-Agent": USER_AGENT},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
s = "" if len(candidates) == 1 else "s"
|
||||
if not quiet:
|
||||
print(f"Checking {len(candidates)} crate{s}")
|
||||
|
||||
published_any = False
|
||||
for crate in candidates:
|
||||
name = crate["name"]
|
||||
version = crate["version"]
|
||||
metadata = get_crate_metadata(client, name)
|
||||
exists = metadata is not None
|
||||
|
||||
trustpub_only_enabled = False
|
||||
if exists:
|
||||
crate_payload = metadata.get("crate")
|
||||
if isinstance(crate_payload, dict):
|
||||
trustpub_only_enabled = bool(crate_payload.get("trustpub_only", False))
|
||||
|
||||
configs: list[dict[str, object]] = []
|
||||
if exists:
|
||||
try:
|
||||
configs = list_trusted_publishers(auth_client, name)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
print(f"{name}: failed to list trusted publishers", file=sys.stderr)
|
||||
handle_trusted_publisher_error(exc)
|
||||
|
||||
desired_configs = [config for config in configs if is_desired_config(config)]
|
||||
extra_desired_configs = desired_configs[1:]
|
||||
non_matching_configs = [
|
||||
config for config in configs if not is_desired_config(config)
|
||||
]
|
||||
configs_to_delete = non_matching_configs + extra_desired_configs
|
||||
|
||||
needs_initial_publish = not exists
|
||||
needs_add_publisher = not desired_configs
|
||||
needs_trustpub_only = not trustpub_only_enabled
|
||||
|
||||
if dry_run:
|
||||
actions: list[str] = []
|
||||
if needs_initial_publish:
|
||||
actions.append("publish placeholder")
|
||||
if configs_to_delete:
|
||||
count = len(configs_to_delete)
|
||||
noun = "publisher" if count == 1 else "publishers"
|
||||
actions.append(f"remove {count} {noun}")
|
||||
if needs_add_publisher:
|
||||
actions.append("add trusted publisher")
|
||||
if needs_trustpub_only:
|
||||
actions.append("enable trustpub_only")
|
||||
|
||||
if actions:
|
||||
print(f"{name}: would {' and '.join(actions)}")
|
||||
elif not quiet:
|
||||
print(f"{name}: already configured")
|
||||
continue
|
||||
|
||||
if needs_initial_publish:
|
||||
if version == PLACEHOLDER_VERSION:
|
||||
print(
|
||||
f"{name}: workspace version matches placeholder version {PLACEHOLDER_VERSION}; "
|
||||
"bump the real crate version before running this script",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if published_any:
|
||||
print(f"waiting {PUBLISH_DELAY_SECS}s for rate limit")
|
||||
time.sleep(PUBLISH_DELAY_SECS)
|
||||
|
||||
print(f"{name}: publishing placeholder")
|
||||
if not publish_placeholder_crate(name, workspace_package):
|
||||
print(f"{name}: placeholder publish failed", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
published_any = True
|
||||
|
||||
if configs_to_delete:
|
||||
deleted = 0
|
||||
for config in configs_to_delete:
|
||||
config_id = config.get("id")
|
||||
if not isinstance(config_id, int):
|
||||
print(
|
||||
f"{name}: unexpected trusted publisher payload: missing `id`",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
try:
|
||||
delete_trusted_publisher(auth_client, config_id)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
print(
|
||||
f"{name}: failed to delete trusted publisher {config_id}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
handle_trusted_publisher_error(exc)
|
||||
deleted += 1
|
||||
noun = "publisher" if deleted == 1 else "publishers"
|
||||
print(f"{name}: removed {deleted} {noun}")
|
||||
|
||||
if needs_add_publisher:
|
||||
print(f"{name}: registering trusted publisher")
|
||||
try:
|
||||
create_trusted_publisher(auth_client, name)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
handle_trusted_publisher_error(exc)
|
||||
|
||||
if needs_trustpub_only:
|
||||
print(f"{name}: enabling trustpub_only")
|
||||
try:
|
||||
set_trustpub_only(auth_client, name, enabled=True)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
handle_trusted_publisher_error(exc)
|
||||
|
||||
if (
|
||||
not configs_to_delete
|
||||
and not needs_add_publisher
|
||||
and not needs_trustpub_only
|
||||
and not quiet
|
||||
):
|
||||
print(f"{name}: already configured")
|
||||
|
||||
known.add(name)
|
||||
|
||||
if not dry_run:
|
||||
save_known_crates(known)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[manifest]
|
||||
requirements = [{ name = "httpx" }]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user