Skip to content
Open
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
5 changes: 5 additions & 0 deletions changelog/1271.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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
Expand All @@ -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}
26 changes: 26 additions & 0 deletions tests/test_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
12 changes: 12 additions & 0 deletions twine/commands/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions twine/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}",
)


Expand Down
45 changes: 35 additions & 10 deletions twine/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -318,19 +332,30 @@ 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
repository.
"""
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.
Expand Down
Loading