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

twine/upload: attestations scaffolding #1095

Merged
merged 7 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,7 @@ def test_non_interactive_environment(self, monkeypatch):
monkeypatch.setenv("TWINE_NON_INTERACTIVE", "0")
args = self.parse_args([])
assert not args.non_interactive

def test_attestations_flag(self):
args = self.parse_args(["--attestations"])
assert args.attestations
40 changes: 40 additions & 0 deletions tests/test_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,46 @@ def stub_sign(package, *_):
]


def test_split_inputs():
"""Split inputs into dists, signatures, and attestations."""
inputs = [
helpers.WHEEL_FIXTURE,
helpers.WHEEL_FIXTURE + ".asc",
helpers.WHEEL_FIXTURE + ".build.attestation",
helpers.WHEEL_FIXTURE + ".publish.attestation",
helpers.SDIST_FIXTURE,
helpers.SDIST_FIXTURE + ".asc",
helpers.NEW_WHEEL_FIXTURE,
helpers.NEW_WHEEL_FIXTURE + ".frob.attestation",
helpers.NEW_SDIST_FIXTURE,
]

inputs = upload._split_inputs(inputs)

assert inputs.dists == [
helpers.WHEEL_FIXTURE,
helpers.SDIST_FIXTURE,
helpers.NEW_WHEEL_FIXTURE,
helpers.NEW_SDIST_FIXTURE,
]

expected_signatures = {
os.path.basename(dist) + ".asc": dist + ".asc"
for dist in [helpers.WHEEL_FIXTURE, helpers.SDIST_FIXTURE]
}
assert inputs.signatures == expected_signatures

assert inputs.attestations_by_dist == {
helpers.WHEEL_FIXTURE: [
helpers.WHEEL_FIXTURE + ".build.attestation",
helpers.WHEEL_FIXTURE + ".publish.attestation",
],
helpers.SDIST_FIXTURE: [],
helpers.NEW_WHEEL_FIXTURE: [helpers.NEW_WHEEL_FIXTURE + ".frob.attestation"],
helpers.NEW_SDIST_FIXTURE: [],
}


def test_successs_prints_release_urls(upload_settings, stub_repository, capsys):
"""Print PyPI release URLS for each uploaded package."""
stub_repository.release_urls = lambda packages: {RELEASE_URL, NEW_RELEASE_URL}
Expand Down
49 changes: 44 additions & 5 deletions twine/commands/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import fnmatch
import logging
import os.path
from typing import Dict, List, cast
from typing import Dict, List, NamedTuple, cast

import requests
from rich import print
Expand Down Expand Up @@ -91,6 +92,44 @@ def _make_package(
return package


class Inputs(NamedTuple):
"""Represents structured user inputs."""

dists: List[str]
signatures: Dict[str, str]
attestations_by_dist: Dict[str, List[str]]


def _split_inputs(
inputs: List[str],
) -> Inputs:
"""
Split the unstructured list of input files provided by the user into groups.

Three groups are returned: upload files (i.e. dists), signatures, and attestations.

Upload files are returned as a linear list, signatures are returned as a
dict of ``basename -> path``, and attestations are returned as a dict of
``dist-path -> [attestation-path]``.
"""
signatures = {os.path.basename(i): i for i in fnmatch.filter(inputs, "*.asc")}
attestations = fnmatch.filter(inputs, "*.*.attestation")
dists = [
dist
for dist in inputs
if dist not in (set(signatures.values()) | set(attestations))
]

attestations_by_dist = {}
for dist in dists:
dist_basename = os.path.basename(dist)
attestations_by_dist[dist] = [
a for a in attestations if os.path.basename(a).startswith(dist_basename)
]

return Inputs(dists, signatures, attestations_by_dist)


def upload(upload_settings: settings.Settings, dists: List[str]) -> None:
"""Upload one or more distributions to a repository, and display the progress.

Expand All @@ -105,17 +144,17 @@ def upload(upload_settings: settings.Settings, dists: List[str]) -> None:
The configured options related to uploading to a repository.
:param dists:
The distribution files to upload to the repository. This can also include
``.asc`` files; the GPG signatures will be added to the corresponding uploads.
``.asc`` and ``.attestation`` files, which will be added to their respective
file uploads.

:raises twine.exceptions.TwineException:
The upload failed due to a configuration error.
:raises requests.HTTPError:
The repository responded with an error.
"""
dists = commands._find_dists(dists)
# Determine if the user has passed in pre-signed distributions
signatures = {os.path.basename(d): d for d in dists if d.endswith(".asc")}
uploads = [i for i in dists if not i.endswith(".asc")]
# Determine if the user has passed in pre-signed distributions or any attestations.
uploads, signatures, _ = _split_inputs(dists)

upload_settings.check_repository_url()
repository_url = cast(str, upload_settings.repository_config["repository"])
Expand Down
10 changes: 10 additions & 0 deletions twine/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class Settings:
def __init__(
self,
*,
attestations: bool = False,
sign: bool = False,
sign_with: str = "gpg",
identity: Optional[str] = None,
Expand All @@ -64,6 +65,8 @@ def __init__(
) -> None:
"""Initialize our settings instance.

:param attestations:
Whether the package file should be uploaded with attestations.
:param sign:
Configure whether the package file should be signed.
:param sign_with:
Expand Down Expand Up @@ -114,6 +117,7 @@ def __init__(
repository_name=repository_name,
repository_url=repository_url,
)
self.attestations = attestations
self._handle_package_signing(
sign=sign,
sign_with=sign_with,
Expand Down Expand Up @@ -175,6 +179,12 @@ def register_argparse_arguments(parser: argparse.ArgumentParser) -> None:
" This overrides --repository. "
"(Can also be set via %(env)s environment variable.)",
)
parser.add_argument(
"--attestations",
action="store_true",
default=False,
help="Upload each file's associated attestations.",
)
parser.add_argument(
"-s",
"--sign",
Expand Down
Loading