Skip to content

Commit 0ec5d18

Browse files
authored
Merge pull request #1098 from woodruffw-forks/ww/attestations-attach
upload: add attestations to PackageFile
2 parents de2acee + 4fbc0d0 commit 0ec5d18

File tree

5 files changed

+103
-9
lines changed

5 files changed

+103
-9
lines changed

tests/test_package.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
import json
1415
import string
1516

1617
import pretend
@@ -114,6 +115,40 @@ def test_package_signed_name_is_correct():
114115
assert package.signed_filename == (filename + ".asc")
115116

116117

118+
def test_package_add_attestations(tmp_path):
119+
package = package_file.PackageFile.from_filename(helpers.WHEEL_FIXTURE, None)
120+
121+
assert package.attestations is None
122+
123+
attestations = []
124+
for i in range(3):
125+
path = tmp_path / f"fake.{i}.attestation"
126+
path.write_text(json.dumps({"fake": f"attestation {i}"}))
127+
attestations.append(str(path))
128+
129+
package.add_attestations(attestations)
130+
131+
assert package.attestations == [
132+
{"fake": "attestation 0"},
133+
{"fake": "attestation 1"},
134+
{"fake": "attestation 2"},
135+
]
136+
137+
138+
def test_package_add_attestations_invalid_json(tmp_path):
139+
package = package_file.PackageFile.from_filename(helpers.WHEEL_FIXTURE, None)
140+
141+
assert package.attestations is None
142+
143+
attestation = tmp_path / "fake.publish.attestation"
144+
attestation.write_text("this is not valid JSON")
145+
146+
with pytest.raises(
147+
exceptions.InvalidDistribution, match="invalid JSON in attestation"
148+
):
149+
package.add_attestations([attestation])
150+
151+
117152
@pytest.mark.parametrize(
118153
"pkg_name,expected_name",
119154
[
@@ -185,7 +220,8 @@ def test_metadata_dictionary_keys():
185220

186221

187222
@pytest.mark.parametrize("gpg_signature", [(None), (pretend.stub())])
188-
def test_metadata_dictionary_values(gpg_signature):
223+
@pytest.mark.parametrize("attestation", [(None), ({"fake": "attestation"})])
224+
def test_metadata_dictionary_values(gpg_signature, attestation):
189225
"""Pass values from pkginfo.Distribution through to dictionary."""
190226
meta = pretend.stub(
191227
name="whatever",
@@ -226,6 +262,8 @@ def test_metadata_dictionary_values(gpg_signature):
226262
filetype=pretend.stub(),
227263
)
228264
package.gpg_signature = gpg_signature
265+
if attestation:
266+
package.attestations = [attestation]
229267

230268
result = package.metadata_dictionary()
231269

@@ -277,6 +315,12 @@ def test_metadata_dictionary_values(gpg_signature):
277315
# GPG signature
278316
assert result.get("gpg_signature") == gpg_signature
279317

318+
# Attestations
319+
if attestation:
320+
assert result["attestations"] == json.dumps(package.attestations)
321+
else:
322+
assert "attestations" not in result
323+
280324

281325
TWINE_1_5_0_WHEEL_HEXDIGEST = package_file.Hexdigest(
282326
"1919f967e990bee7413e2a4bc35fd5d1",

tests/test_upload.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,11 @@ def test_make_package_pre_signed_dist(upload_settings, caplog):
6969
upload_settings.sign = True
7070
upload_settings.verbose = True
7171

72-
package = upload._make_package(filename, signatures, upload_settings)
72+
package = upload._make_package(filename, signatures, [], upload_settings)
7373

7474
assert package.filename == filename
7575
assert package.gpg_signature is not None
76+
assert package.attestations is None
7677

7778
assert caplog.messages == [
7879
f"{filename} ({expected_size})",
@@ -94,7 +95,7 @@ def stub_sign(package, *_):
9495

9596
monkeypatch.setattr(package_file.PackageFile, "sign", stub_sign)
9697

97-
package = upload._make_package(filename, signatures, upload_settings)
98+
package = upload._make_package(filename, signatures, [], upload_settings)
9899

99100
assert package.filename == filename
100101
assert package.gpg_signature is not None
@@ -105,6 +106,16 @@ def stub_sign(package, *_):
105106
]
106107

107108

109+
def test_make_package_attestations_flagged_but_missing(upload_settings):
110+
"""Fail when the user requests attestations but does not supply any attestations."""
111+
upload_settings.attestations = True
112+
113+
with pytest.raises(
114+
exceptions.InvalidDistribution, match="Upload with attestations requested"
115+
):
116+
upload._make_package(helpers.NEW_WHEEL_FIXTURE, {}, [], upload_settings)
117+
118+
108119
def test_split_inputs():
109120
"""Split inputs into dists, signatures, and attestations."""
110121
inputs = [

twine/commands/upload.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,16 @@ def skip_upload(
7373

7474

7575
def _make_package(
76-
filename: str, signatures: Dict[str, str], upload_settings: settings.Settings
76+
filename: str,
77+
signatures: Dict[str, str],
78+
attestations: List[str],
79+
upload_settings: settings.Settings,
7780
) -> package_file.PackageFile:
78-
"""Create and sign a package, based off of filename, signatures and settings."""
81+
"""Create and sign a package, based off of filename, signatures, and settings.
82+
83+
Additionally, any supplied attestations are attached to the package when
84+
the settings indicate to do so.
85+
"""
7986
package = package_file.PackageFile.from_filename(filename, upload_settings.comment)
8087

8188
signed_name = package.signed_basefilename
@@ -84,6 +91,17 @@ def _make_package(
8491
elif upload_settings.sign:
8592
package.sign(upload_settings.sign_with, upload_settings.identity)
8693

94+
# Attestations are only attached if explicitly requested with `--attestations`.
95+
if upload_settings.attestations:
96+
# Passing `--attestations` without any actual attestations present
97+
# indicates user confusion, so we fail rather than silently allowing it.
98+
if not attestations:
99+
raise exceptions.InvalidDistribution(
100+
"Upload with attestations requested, but "
101+
f"{filename} has no associated attestations"
102+
)
103+
package.add_attestations(attestations)
104+
87105
file_size = utils.get_file_size(package.filename)
88106
logger.info(f"{package.filename} ({file_size})")
89107
if package.gpg_signature:
@@ -154,14 +172,17 @@ def upload(upload_settings: settings.Settings, dists: List[str]) -> None:
154172
"""
155173
dists = commands._find_dists(dists)
156174
# Determine if the user has passed in pre-signed distributions or any attestations.
157-
uploads, signatures, _ = _split_inputs(dists)
175+
uploads, signatures, attestations_by_dist = _split_inputs(dists)
158176

159177
upload_settings.check_repository_url()
160178
repository_url = cast(str, upload_settings.repository_config["repository"])
161179
print(f"Uploading distributions to {repository_url}")
162180

163181
packages_to_upload = [
164-
_make_package(filename, signatures, upload_settings) for filename in uploads
182+
_make_package(
183+
filename, signatures, attestations_by_dist[filename], upload_settings
184+
)
185+
for filename in uploads
165186
]
166187

167188
if any(p.gpg_signature for p in packages_to_upload):

twine/package.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
# limitations under the License.
1414
import hashlib
1515
import io
16+
import json
1617
import logging
1718
import os
1819
import re
1920
import subprocess
20-
from typing import Dict, NamedTuple, Optional, Sequence, Tuple, Union, cast
21+
from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union, cast
2122

2223
import importlib_metadata
2324
import pkginfo
@@ -78,6 +79,7 @@ def __init__(
7879
self.signed_filename = self.filename + ".asc"
7980
self.signed_basefilename = self.basefilename + ".asc"
8081
self.gpg_signature: Optional[Tuple[str, bytes]] = None
82+
self.attestations: Optional[List[Dict[Any, str]]] = None
8183

8284
hasher = HashManager(filename)
8385
hasher.hash()
@@ -186,6 +188,9 @@ def metadata_dictionary(self) -> Dict[str, MetadataValue]:
186188
if self.gpg_signature is not None:
187189
data["gpg_signature"] = self.gpg_signature
188190

191+
if self.attestations is not None:
192+
data["attestations"] = json.dumps(self.attestations)
193+
189194
# FIPS disables MD5 and Blake2, making the digest values None. Some package
190195
# repositories don't allow null values, so this only sends non-null values.
191196
# See also: https://github.com/pypa/twine/issues/775
@@ -197,6 +202,19 @@ def metadata_dictionary(self) -> Dict[str, MetadataValue]:
197202

198203
return data
199204

205+
def add_attestations(self, attestations: List[str]) -> None:
206+
loaded_attestations = []
207+
for attestation in attestations:
208+
with open(attestation, "rb") as att:
209+
try:
210+
loaded_attestations.append(json.load(att))
211+
except json.JSONDecodeError:
212+
raise exceptions.InvalidDistribution(
213+
f"invalid JSON in attestation: {attestation}"
214+
)
215+
216+
self.attestations = loaded_attestations
217+
200218
def add_gpg_signature(
201219
self, signature_filepath: str, signature_filename: str
202220
) -> None:

twine/repository.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import twine
2626
from twine import package as package_file
2727

28-
KEYWORDS_TO_NOT_FLATTEN = {"gpg_signature", "content"}
28+
KEYWORDS_TO_NOT_FLATTEN = {"gpg_signature", "attestations", "content"}
2929

3030
LEGACY_PYPI = "https://pypi.python.org/"
3131
LEGACY_TEST_PYPI = "https://testpypi.python.org/"

0 commit comments

Comments
 (0)