Skip to content

Commit c421440

Browse files
committed
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.
1 parent 974a8d8 commit c421440

File tree

5 files changed

+106
-16
lines changed

5 files changed

+106
-16
lines changed

tests/test_settings.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,23 @@ def test_settings_verify_feature_compatibility() -> None:
114114
f" but got {unexpected_exc!r}"
115115
)
116116

117+
s.ignored_http_statuses = {409}
118+
try:
119+
s.verify_feature_capability()
120+
except exceptions.UnsupportedConfiguration as unexpected_exc:
121+
pytest.fail(
122+
f"Expected feature capability to work with non-PyPI but got"
123+
f" {unexpected_exc!r}"
124+
)
125+
126+
s.repository_config["repository"] = repository.WAREHOUSE
127+
with pytest.raises(exceptions.UnsupportedConfiguration):
128+
s.verify_feature_capability()
129+
130+
s.repository_config["repository"] = repository.TEST_WAREHOUSE
131+
with pytest.raises(exceptions.UnsupportedConfiguration):
132+
s.verify_feature_capability()
133+
117134

118135
@pytest.mark.parametrize(
119136
"verbose, log_level", [(True, logging.INFO), (False, logging.WARNING)]
@@ -187,6 +204,9 @@ def parse_args(args):
187204
settings.Settings.register_argparse_arguments(parser)
188205
return parser.parse_args(args)
189206

207+
def parse_args_into_settings(self, args):
208+
return settings.Settings.from_argparse(self.parse_args(args))
209+
190210
def test_non_interactive_flag(self):
191211
args = self.parse_args(["--non-interactive"])
192212
assert args.non_interactive
@@ -202,3 +222,13 @@ def test_non_interactive_environment(self, monkeypatch):
202222
def test_attestations_flag(self):
203223
args = self.parse_args(["--attestations"])
204224
assert args.attestations
225+
226+
def test_ignore_http_status(self):
227+
s = self.parse_args_into_settings(["--ignore-http-status", "409"])
228+
assert s.ignored_http_statuses == {409}
229+
230+
def test_ignore_multiple_http_statuses(self):
231+
s = self.parse_args_into_settings(
232+
["--ignore-http-status", "409", "--ignore-http-status", "400"]
233+
)
234+
assert s.ignored_http_statuses == {400, 409}

tests/test_upload.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,32 @@ def test_prints_skip_message_for_response(
445445
]
446446

447447

448+
def test_prints_ignored_message_for_ignored_response(
449+
upload_settings, stub_response, stub_repository, capsys, caplog
450+
):
451+
upload_settings.repository_config["repository"] = "https://notpypi.example.com"
452+
upload_settings.ignored_http_statuses = {409}
453+
454+
stub_response.status_code = 409
455+
stub_response.reason = "Doesn't really matter, not checked"
456+
stub_response.text = stub_response.reason
457+
458+
# Do the upload, triggering the error response
459+
stub_repository.package_is_uploaded = lambda package: False
460+
461+
result = upload.upload(upload_settings, [helpers.WHEEL_FIXTURE])
462+
assert result is None
463+
464+
captured = capsys.readouterr()
465+
assert RELEASE_URL not in captured.out
466+
467+
assert caplog.messages == [
468+
"Ignoring HTTP 409 response to twine-1.5.0-py2.py3-none-any.whl"
469+
" upload as requested. Retry with the --verbose option for more"
470+
" details."
471+
]
472+
473+
448474
@pytest.mark.parametrize(
449475
"response_kwargs",
450476
[

twine/commands/upload.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,18 @@ def upload(upload_settings: settings.Settings, dists: List[str]) -> None:
209209
utils.sanitize_url(resp.headers["location"]),
210210
)
211211

212+
if resp.status_code in upload_settings.ignored_http_statuses:
213+
logger.warning(
214+
f"Ignoring HTTP {resp.status_code} response to "
215+
f"{package.basefilename} upload as requested."
216+
+ (
217+
" Retry with the --verbose option for more details."
218+
if not upload_settings.verbose
219+
else ""
220+
)
221+
)
222+
continue
223+
212224
if skip_upload(resp, upload_settings.skip_existing, package):
213225
logger.warning(skip_message)
214226
continue

twine/exceptions.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,9 @@ def with_feature(self, feature: str) -> "UnsupportedConfiguration.Builder":
112112

113113
def finalize(self) -> "UnsupportedConfiguration":
114114
return UnsupportedConfiguration(
115-
f"The configured repository {self.repository_url!r} does not "
116-
"have support for the following features: "
117-
f"{', '.join(self.features)} and is an unsupported "
118-
"configuration",
119-
self.repository_url,
120-
*self.features,
115+
f"Twine does not support using the following features with the"
116+
f" configured repository ({self.repository_url!r}): "
117+
f"{', '.join(self.features)}",
121118
)
122119

123120

twine/settings.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def __init__(
5555
comment: Optional[str] = None,
5656
config_file: str = utils.DEFAULT_CONFIG_FILE,
5757
skip_existing: bool = False,
58+
ignored_http_statuses: Optional[list[int]] = None,
5859
cacert: Optional[str] = None,
5960
client_cert: Optional[str] = None,
6061
repository_name: str = "pypi",
@@ -88,8 +89,10 @@ def __init__(
8889
The path to the configuration file to use.
8990
:param skip_existing:
9091
Specify whether twine should continue uploading files if one
91-
of them already exists. This primarily supports PyPI. Other
92-
package indexes may not be supported.
92+
of them already exists. Only for use with PyPI.
93+
:param ignored_http_statuses:
94+
Specify a set of HTTP status codes to ignore, continuing to upload
95+
other files.
9396
:param cacert:
9497
The path to the bundle of certificates used to verify the TLS
9598
connection to the package index.
@@ -113,6 +116,7 @@ def __init__(
113116
self.verbose = verbose
114117
self.disable_progress_bar = disable_progress_bar
115118
self.skip_existing = skip_existing
119+
self.ignored_http_statuses = set(ignored_http_statuses or [])
116120
self._handle_repository_options(
117121
repository_name=repository_name,
118122
repository_url=repository_url,
@@ -245,8 +249,18 @@ def register_argparse_arguments(parser: argparse.ArgumentParser) -> None:
245249
default=False,
246250
action="store_true",
247251
help="Continue uploading files if one already exists. (Only valid "
248-
"when uploading to PyPI. Other implementations may not "
249-
"support this.)",
252+
"when uploading to PyPI. Not supported with other "
253+
"implementations.)",
254+
)
255+
parser.add_argument(
256+
"--ignore-http-status",
257+
type=int,
258+
action="append",
259+
dest="ignored_http_statuses",
260+
metavar="status_code",
261+
help="Ignore the specified HTTP status code and continue uploading"
262+
" files. May be specified multiple times."
263+
" (Not supported when uploading to PyPI.)",
250264
)
251265
parser.add_argument(
252266
"--cert",
@@ -318,19 +332,30 @@ def verify_feature_capability(self) -> None:
318332
319333
This presently checks:
320334
- ``--skip-existing`` was only provided for PyPI and TestPyPI
335+
- ``--ignore-http-status`` was not provided for PyPI or TestPyPI
321336
322337
:raises twine.exceptions.UnsupportedConfiguration:
323338
The configured features are not available with the configured
324339
repository.
325340
"""
326341
repository_url = cast(str, self.repository_config["repository"])
327342

328-
if self.skip_existing and not repository_url.startswith(
329-
(repository.WAREHOUSE, repository.TEST_WAREHOUSE)
330-
):
331-
raise exceptions.UnsupportedConfiguration.Builder().with_feature(
332-
"--skip-existing"
333-
).with_repository_url(repository_url).finalize()
343+
exc_builder = exceptions.UnsupportedConfiguration.Builder()
344+
exc_builder.with_repository_url(repository_url)
345+
346+
pypi_urls = (repository.WAREHOUSE, repository.TEST_WAREHOUSE)
347+
348+
if repository_url.startswith(pypi_urls):
349+
# is PyPI
350+
if self.ignored_http_statuses:
351+
exc_builder.with_feature("--ignore-http-status")
352+
else:
353+
# is not PyPI
354+
if self.skip_existing:
355+
exc_builder.with_feature("--skip-existing")
356+
357+
if exc_builder.features:
358+
raise exc_builder.finalize()
334359

335360
def check_repository_url(self) -> None:
336361
"""Verify we are not using legacy PyPI.

0 commit comments

Comments
 (0)