From f99c568db2c0522627d669b0ca8825321a5d3b35 Mon Sep 17 00:00:00 2001 From: Zachary Ware Date: Thu, 18 Sep 2025 00:00:35 -0500 Subject: [PATCH] Add --ignore-http-status option This is effectively a replacement for --skip-existing with non-PyPI repositories. It is not allowed when using [Test]PyPI, and is instead a way for users of other repositories to choose which status(es) can be ignored. This commit also rewords the UnsupportedConfiguration message a bit; instead of asserting that the configured repository does not support a particular feature (which is not actually checked), instead assert that Twine does not support using the feature(s) with the configured repository. --- changelog/1271.feature.rst | 5 +++++ tests/test_settings.py | 30 +++++++++++++++++++++++++ tests/test_upload.py | 26 ++++++++++++++++++++++ twine/commands/upload.py | 12 ++++++++++ twine/exceptions.py | 9 +++----- twine/settings.py | 45 +++++++++++++++++++++++++++++--------- 6 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 changelog/1271.feature.rst diff --git a/changelog/1271.feature.rst b/changelog/1271.feature.rst new file mode 100644 index 000000000..cf72c624f --- /dev/null +++ b/changelog/1271.feature.rst @@ -0,0 +1,5 @@ +Add ``--ignore-http-status`` option for use only with non-PyPI repositories. + +This option allows the user of a repository other than PyPI or TestPyPI to +specify one or more HTTP status codes that Twine should not treat as fatal when +encountered on uploading a file, allowing it to continue to upload other files. diff --git a/tests/test_settings.py b/tests/test_settings.py index af7205558..1d2f43a6c 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -114,6 +114,23 @@ def test_settings_verify_feature_compatibility() -> None: f" but got {unexpected_exc!r}" ) + s.ignored_http_statuses = {409} + try: + s.verify_feature_capability() + except exceptions.UnsupportedConfiguration as unexpected_exc: + pytest.fail( + f"Expected feature capability to work with non-PyPI but got" + f" {unexpected_exc!r}" + ) + + s.repository_config["repository"] = repository.WAREHOUSE + with pytest.raises(exceptions.UnsupportedConfiguration): + s.verify_feature_capability() + + s.repository_config["repository"] = repository.TEST_WAREHOUSE + with pytest.raises(exceptions.UnsupportedConfiguration): + s.verify_feature_capability() + @pytest.mark.parametrize( "verbose, log_level", [(True, logging.INFO), (False, logging.WARNING)] @@ -187,6 +204,9 @@ def parse_args(args): settings.Settings.register_argparse_arguments(parser) return parser.parse_args(args) + def parse_args_into_settings(self, args): + return settings.Settings.from_argparse(self.parse_args(args)) + def test_non_interactive_flag(self): args = self.parse_args(["--non-interactive"]) assert args.non_interactive @@ -202,3 +222,13 @@ def test_non_interactive_environment(self, monkeypatch): def test_attestations_flag(self): args = self.parse_args(["--attestations"]) assert args.attestations + + def test_ignore_http_status(self): + s = self.parse_args_into_settings(["--ignore-http-status", "409"]) + assert s.ignored_http_statuses == {409} + + def test_ignore_multiple_http_statuses(self): + s = self.parse_args_into_settings( + ["--ignore-http-status", "409", "--ignore-http-status", "400"] + ) + assert s.ignored_http_statuses == {400, 409} diff --git a/tests/test_upload.py b/tests/test_upload.py index c631f9b4d..a6c7f86e2 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -445,6 +445,32 @@ def test_prints_skip_message_for_response( ] +def test_prints_ignored_message_for_ignored_response( + upload_settings, stub_response, stub_repository, capsys, caplog +): + upload_settings.repository_config["repository"] = "https://notpypi.example.com" + upload_settings.ignored_http_statuses = {409} + + stub_response.status_code = 409 + stub_response.reason = "Doesn't really matter, not checked" + stub_response.text = stub_response.reason + + # Do the upload, triggering the error response + stub_repository.package_is_uploaded = lambda package: False + + result = upload.upload(upload_settings, [helpers.WHEEL_FIXTURE]) + assert result is None + + captured = capsys.readouterr() + assert RELEASE_URL not in captured.out + + assert caplog.messages == [ + "Ignoring HTTP 409 response to twine-1.5.0-py2.py3-none-any.whl" + " upload as requested. Retry with the --verbose option for more" + " details." + ] + + @pytest.mark.parametrize( "response_kwargs", [ diff --git a/twine/commands/upload.py b/twine/commands/upload.py index ffc05b741..6a369f031 100644 --- a/twine/commands/upload.py +++ b/twine/commands/upload.py @@ -209,6 +209,18 @@ def upload(upload_settings: settings.Settings, dists: List[str]) -> None: utils.sanitize_url(resp.headers["location"]), ) + if resp.status_code in upload_settings.ignored_http_statuses: + logger.warning( + f"Ignoring HTTP {resp.status_code} response to " + f"{package.basefilename} upload as requested." + + ( + " Retry with the --verbose option for more details." + if not upload_settings.verbose + else "" + ) + ) + continue + if skip_upload(resp, upload_settings.skip_existing, package): logger.warning(skip_message) continue diff --git a/twine/exceptions.py b/twine/exceptions.py index 1b9b02b67..d33e1e579 100644 --- a/twine/exceptions.py +++ b/twine/exceptions.py @@ -112,12 +112,9 @@ def with_feature(self, feature: str) -> "UnsupportedConfiguration.Builder": def finalize(self) -> "UnsupportedConfiguration": return UnsupportedConfiguration( - f"The configured repository {self.repository_url!r} does not " - "have support for the following features: " - f"{', '.join(self.features)} and is an unsupported " - "configuration", - self.repository_url, - *self.features, + f"Twine does not support using the following features with the" + f" configured repository ({self.repository_url!r}): " + f"{', '.join(self.features)}", ) diff --git a/twine/settings.py b/twine/settings.py index ba473524c..da7ea4f67 100644 --- a/twine/settings.py +++ b/twine/settings.py @@ -55,6 +55,7 @@ def __init__( comment: Optional[str] = None, config_file: str = utils.DEFAULT_CONFIG_FILE, skip_existing: bool = False, + ignored_http_statuses: Optional[list[int]] = None, cacert: Optional[str] = None, client_cert: Optional[str] = None, repository_name: str = "pypi", @@ -88,8 +89,10 @@ def __init__( The path to the configuration file to use. :param skip_existing: Specify whether twine should continue uploading files if one - of them already exists. This primarily supports PyPI. Other - package indexes may not be supported. + of them already exists. Only for use with PyPI. + :param ignored_http_statuses: + Specify a set of HTTP status codes to ignore, continuing to upload + other files. :param cacert: The path to the bundle of certificates used to verify the TLS connection to the package index. @@ -113,6 +116,7 @@ def __init__( self.verbose = verbose self.disable_progress_bar = disable_progress_bar self.skip_existing = skip_existing + self.ignored_http_statuses = set(ignored_http_statuses or []) self._handle_repository_options( repository_name=repository_name, repository_url=repository_url, @@ -245,8 +249,18 @@ def register_argparse_arguments(parser: argparse.ArgumentParser) -> None: default=False, action="store_true", help="Continue uploading files if one already exists. (Only valid " - "when uploading to PyPI. Other implementations may not " - "support this.)", + "when uploading to PyPI. Not supported with other " + "implementations.)", + ) + parser.add_argument( + "--ignore-http-status", + type=int, + action="append", + dest="ignored_http_statuses", + metavar="status_code", + help="Ignore the specified HTTP status code and continue uploading" + " files. May be specified multiple times." + " (Not supported when uploading to PyPI.)", ) parser.add_argument( "--cert", @@ -318,6 +332,7 @@ def verify_feature_capability(self) -> None: This presently checks: - ``--skip-existing`` was only provided for PyPI and TestPyPI + - ``--ignore-http-status`` was not provided for PyPI or TestPyPI :raises twine.exceptions.UnsupportedConfiguration: The configured features are not available with the configured @@ -325,12 +340,22 @@ def verify_feature_capability(self) -> None: """ repository_url = cast(str, self.repository_config["repository"]) - if self.skip_existing and not repository_url.startswith( - (repository.WAREHOUSE, repository.TEST_WAREHOUSE) - ): - raise exceptions.UnsupportedConfiguration.Builder().with_feature( - "--skip-existing" - ).with_repository_url(repository_url).finalize() + exc_builder = exceptions.UnsupportedConfiguration.Builder() + exc_builder.with_repository_url(repository_url) + + pypi_urls = (repository.WAREHOUSE, repository.TEST_WAREHOUSE) + + if repository_url.startswith(pypi_urls): + # is PyPI + if self.ignored_http_statuses: + exc_builder.with_feature("--ignore-http-status") + else: + # is not PyPI + if self.skip_existing: + exc_builder.with_feature("--skip-existing") + + if exc_builder.features: + raise exc_builder.finalize() def check_repository_url(self) -> None: """Verify we are not using legacy PyPI.