@@ -157,6 +157,12 @@ def _parser() -> argparse.ArgumentParser:
157157 help = "Use the staging environment" ,
158158 )
159159
160+ verify_pypi_command .add_argument (
161+ "--provenance-file" ,
162+ type = Path ,
163+ help = "Provide the provenance file instead of downloading it from PyPI" ,
164+ )
165+
160166 inspect_command = subcommands .add_parser (
161167 name = "inspect" ,
162168 help = "Inspect one or more inputs" ,
@@ -233,9 +239,33 @@ def _download_file(url: str, dest: Path) -> None:
233239 _die (f"Error downloading file: { e } " )
234240
235241
236- def _get_direct_url_from_arg (arg : str ) -> URIReference :
242+ def _get_distribution_from_arg (arg : str ) -> Distribution :
237243 """Parse the artifact argument for the `verify pypi` subcommand.
238244
245+ The argument can be:
246+ - A pypi: prefixed filename (e.g. pypi:sampleproject-1.0.0.tar.gz)
247+ - A direct URL to a PyPI-hosted artifact
248+ - A path to a local file
249+ """
250+ if arg .startswith ("pypi:" ) or arg .startswith ("https://" ):
251+ pypi_url = _get_direct_url_from_arg (arg )
252+ dist_filename = pypi_url .path .split ("/" )[- 1 ]
253+ with TemporaryDirectory () as temp_dir :
254+ dist_path = Path (temp_dir ) / dist_filename
255+ _download_file (url = pypi_url .unsplit (), dest = dist_path )
256+ dist = Distribution .from_file (dist_path )
257+ else :
258+ dist_path = Path (arg )
259+ if not dist_path .exists ():
260+ _die (f"File does not exist: { dist_path } " )
261+ dist = Distribution .from_file (dist_path )
262+
263+ return dist
264+
265+
266+ def _get_direct_url_from_arg (arg : str ) -> URIReference :
267+ """Get the URL from the artifact argument for the `verify pypi` subcommand.
268+
239269 The argument can be:
240270 - A pypi: prefixed filename (e.g. pypi:sampleproject-1.0.0.tar.gz)
241271 - A direct URL to a PyPI-hosted artifact
@@ -288,17 +318,14 @@ def _get_direct_url_from_arg(arg: str) -> URIReference:
288318 return pypi_url
289319
290320
291- def _get_provenance_from_pypi (filename : str ) -> Provenance :
321+ def _get_provenance_from_pypi (dist : Distribution ) -> Provenance :
292322 """Use PyPI's integrity API to get a distribution's provenance."""
293- try :
294- if filename .endswith (".tar.gz" ) or filename .endswith (".zip" ):
295- name , version = parse_sdist_filename (filename )
296- elif filename .endswith (".whl" ):
297- name , version , _ , _ = parse_wheel_filename (filename )
298- else :
299- _die ("URL should point to a wheel (*.whl) or a source distribution (*.zip or *.tar.gz)" )
300- except (InvalidSdistFilename , InvalidWheelFilename ) as e :
301- _die (f"Invalid distribution filename: { e } " )
323+ filename = dist .name
324+ # Filename is already validated when creating the Distribution object
325+ if filename .endswith (".tar.gz" ) or filename .endswith (".zip" ):
326+ name , version = parse_sdist_filename (filename )
327+ else :
328+ name , version , _ , _ = parse_wheel_filename (filename )
302329
303330 provenance_url = f"https://pypi.org/integrity/{ name } /{ version } /{ filename } /provenance"
304331 response = requests .get (provenance_url )
@@ -480,31 +507,34 @@ def _verify_attestation(args: argparse.Namespace) -> None:
480507def _verify_pypi (args : argparse .Namespace ) -> None :
481508 """Verify a distribution hosted on PyPI.
482509
483- The distribution is downloaded and verified. The verification is against
484- the provenance file hosted on PyPI (if any), and against the repository URL
485- passed by the user as a CLI argument.
510+ The distribution is downloaded (if needed) and verified. The verification is against
511+ the provenance file (passed using the `--provenance-file` option, or downloaded
512+ from PyPI if not provided), and against the repository URL passed by the user
513+ as a CLI argument.
486514 """
487- pypi_url = _get_direct_url_from_arg (args .distribution_file )
515+ dist = _get_distribution_from_arg (args .distribution_file )
488516
489- with TemporaryDirectory () as temp_dir :
490- dist_filename = pypi_url .path .split ("/" )[- 1 ]
491- dist_path = Path (temp_dir ) / dist_filename
492- _download_file (url = pypi_url .unsplit (), dest = dist_path )
493- provenance = _get_provenance_from_pypi (dist_filename )
494- dist = Distribution .from_file (dist_path )
517+ if args .provenance_file is None :
518+ provenance = _get_provenance_from_pypi (dist )
519+ else :
520+ if not args .provenance_file .exists ():
521+ _die (f"Provenance file does not exist: { args .provenance_file } " )
495522 try :
496- for attestation_bundle in provenance .attestation_bundles :
497- publisher = attestation_bundle .publisher
498- _check_repository_identity (
499- expected_repository_url = args .repository , publisher = publisher
500- )
501- policy = publisher ._as_policy () # noqa: SLF001.
502- for attestation in attestation_bundle .attestations :
503- attestation .verify (policy , dist , staging = args .staging )
504- except VerificationError as verification_error :
505- _die (f"Verification failed for { dist_filename } : { verification_error } " )
506-
507- _logger .info (f"OK: { dist_filename } " )
523+ provenance = Provenance .model_validate_json (args .provenance_file .read_bytes ())
524+ except ValidationError as validation_error :
525+ _die (f"Invalid provenance: { validation_error } " )
526+
527+ try :
528+ for attestation_bundle in provenance .attestation_bundles :
529+ publisher = attestation_bundle .publisher
530+ _check_repository_identity (expected_repository_url = args .repository , publisher = publisher )
531+ policy = publisher ._as_policy () # noqa: SLF001.
532+ for attestation in attestation_bundle .attestations :
533+ attestation .verify (policy , dist , staging = args .staging )
534+ except VerificationError as verification_error :
535+ _die (f"Verification failed for { dist .name } : { verification_error } " )
536+
537+ _logger .info (f"OK: { dist .name } " )
508538
509539
510540def main () -> None :
0 commit comments