|
37 | 37 | from collections.abc import Iterable |
38 | 38 | from typing import NoReturn |
39 | 39 |
|
| 40 | + from rfc3986 import URIReference |
| 41 | + |
40 | 42 | logging.basicConfig(format="%(message)s", datefmt="[%X]", handlers=[logging.StreamHandler()]) |
41 | 43 | _logger = logging.getLogger(__name__) |
42 | 44 | _logger.setLevel(logging.INFO) |
@@ -134,10 +136,12 @@ def _parser() -> argparse.ArgumentParser: |
134 | 136 | verify_pypi_command = verify_subcommands.add_parser(name="pypi", help="Verify a PyPI release") |
135 | 137 |
|
136 | 138 | verify_pypi_command.add_argument( |
137 | | - "distribution_url", |
138 | | - metavar="URL_PYPI_FILE", |
| 139 | + "distribution_file", |
| 140 | + metavar="PYPI_FILE", |
139 | 141 | type=str, |
140 | | - help='URL of the PyPI file to verify, i.e: "https://files.pythonhosted.org/..."', |
| 142 | + help="PyPI file to verify formatted as $PKG_NAME/$FILE_NAME, e.g: " |
| 143 | + "sampleproject/sampleproject-1.0.0.tar.gz. Direct URLs to the hosted file are also " |
| 144 | + "supported.", |
141 | 145 | ) |
142 | 146 |
|
143 | 147 | verify_pypi_command.add_argument( |
@@ -230,6 +234,52 @@ def _download_file(url: str, dest: Path) -> None: |
230 | 234 | _die(f"Error downloading file: {e}") |
231 | 235 |
|
232 | 236 |
|
| 237 | +def _get_direct_url_from_arg(arg: str) -> URIReference: |
| 238 | + """Parse the artifact argument for the `verify pypi` subcommand. |
| 239 | +
|
| 240 | + The argument can be either a direct URL to a PyPI-hosted artifact, |
| 241 | + or a friendly-formatted alternative: '$PKG_NAME/$FILE_NAME'. |
| 242 | + """ |
| 243 | + direct_url = None |
| 244 | + components = arg.split("/") |
| 245 | + # We support passing the file argument as $PKG_NAME/$FILE_NAME |
| 246 | + if len(components) == 2: |
| 247 | + pkg_name, file_name = components |
| 248 | + |
| 249 | + provenance_url = f"https://pypi.org/simple/{pkg_name}" |
| 250 | + response = requests.get( |
| 251 | + provenance_url, headers={"Accept": "application/vnd.pypi.simple.v1+json"} |
| 252 | + ) |
| 253 | + try: |
| 254 | + response.raise_for_status() |
| 255 | + except requests.exceptions.HTTPError as e: |
| 256 | + _die(f"Error trying to get information for '{pkg_name}' from PyPI: {e}") |
| 257 | + |
| 258 | + response_json = response.json() |
| 259 | + for file_json in response_json.get("files", []): |
| 260 | + if file_json.get("filename", "") == file_name: |
| 261 | + direct_url = file_json.get("url", "") |
| 262 | + break |
| 263 | + if not direct_url: |
| 264 | + _die(f"Could not find the artifact '{file_name}' for '{pkg_name}'") |
| 265 | + else: |
| 266 | + direct_url = arg |
| 267 | + |
| 268 | + validator = ( |
| 269 | + validators.Validator() |
| 270 | + .allow_schemes("https") |
| 271 | + .allow_hosts("files.pythonhosted.org") |
| 272 | + .require_presence_of("scheme", "host") |
| 273 | + ) |
| 274 | + try: |
| 275 | + pypi_url = uri_reference(direct_url) |
| 276 | + validator.validate(pypi_url) |
| 277 | + except exceptions.RFC3986Exception as e: |
| 278 | + _die(f"Unsupported/invalid URL: {e}") |
| 279 | + |
| 280 | + return pypi_url |
| 281 | + |
| 282 | + |
233 | 283 | def _get_provenance_from_pypi(filename: str) -> Provenance: |
234 | 284 | """Use PyPI's integrity API to get a distribution's provenance.""" |
235 | 285 | try: |
@@ -425,17 +475,7 @@ def _verify_pypi(args: argparse.Namespace) -> None: |
425 | 475 | the provenance file hosted on PyPI (if any), and against the repository URL |
426 | 476 | passed by the user as a CLI argument. |
427 | 477 | """ |
428 | | - validator = ( |
429 | | - validators.Validator() |
430 | | - .allow_schemes("https") |
431 | | - .allow_hosts("files.pythonhosted.org") |
432 | | - .require_presence_of("scheme", "host") |
433 | | - ) |
434 | | - try: |
435 | | - pypi_url = uri_reference(args.distribution_url) |
436 | | - validator.validate(pypi_url) |
437 | | - except exceptions.RFC3986Exception as e: |
438 | | - _die(f"Unsupported/invalid URL: {e}") |
| 478 | + pypi_url = _get_direct_url_from_arg(args.distribution_file) |
439 | 479 |
|
440 | 480 | with TemporaryDirectory() as temp_dir: |
441 | 481 | dist_filename = pypi_url.path.split("/")[-1] |
|
0 commit comments