Skip to content

Commit 1b23f15

Browse files
author
John Pocock
committed
Add opt-in support for --skip-existing on non-PyPI repositories
1 parent e6407ad commit 1b23f15

File tree

5 files changed

+100
-5
lines changed

5 files changed

+100
-5
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ Cyril de Catheu <cdecatheu@gmail.com> (https://catheu.tech/)
3333
Thomas Miedema <thomasmiedema@gmail.com>
3434
Hugo van Kemenade (https://github.com/hugovk)
3535
Jacob Woliver <jacob@jmw.sh> (jmw.sh)
36+
John Pocock <john.oss@pocock.io>

changelog/XXXX.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add opt-in support for ``--skip-existing`` on non-PyPI repositories via the ``--skip-existing-non-pypi`` CLI flag or ``TWINE_SKIP_EXISTING_NON_PYPI`` environment variable, enabling testing scenarios and support for PEP 694-compliant third-party registries.

docs/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ on a CI/build server).
164164
self-signed or untrusted certificates.
165165
* ``TWINE_NON_INTERACTIVE`` - Do not interactively prompt for username/password
166166
if the required credentials are missing.
167+
* ``TWINE_SKIP_EXISTING_NON_PYPI`` - Allow ``--skip-existing`` to work with
168+
non-PyPI repositories. Set to ``1`` or ``true`` to enable.
167169

168170
Proxy Support
169171
^^^^^^^^^^^^^

tests/test_settings.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,62 @@ def test_settings_verify_feature_compatibility() -> None:
115115
)
116116

117117

118+
def test_settings_verify_feature_compatibility_with_flag() -> None:
119+
"""Test that skip_existing_non_pypi parameter allows skip-existing for non-PyPI."""
120+
# Without the flag, should raise
121+
s = settings.Settings(skip_existing=True, skip_existing_non_pypi=False)
122+
s.repository_config = {"repository": "https://not-pypi.example.com/legacy"}
123+
with pytest.raises(exceptions.UnsupportedConfiguration):
124+
s.verify_feature_capability()
125+
126+
# With the flag set to True, should not raise
127+
s = settings.Settings(skip_existing=True, skip_existing_non_pypi=True)
128+
s.repository_config = {"repository": "https://not-pypi.example.com/legacy"}
129+
try:
130+
s.verify_feature_capability()
131+
except exceptions.UnsupportedConfiguration as unexpected_exc:
132+
pytest.fail(
133+
"Expected skip-existing to work with non-PyPI when "
134+
f"skip_existing_non_pypi=True but got {unexpected_exc!r}"
135+
)
136+
137+
138+
def test_settings_skip_existing_non_pypi_still_works_for_pypi() -> None:
139+
"""Test that PyPI and TestPyPI work regardless of skip_existing_non_pypi flag."""
140+
# PyPI should work without the flag
141+
s = settings.Settings(skip_existing=True, skip_existing_non_pypi=False)
142+
s.repository_config = {"repository": repository.WAREHOUSE}
143+
try:
144+
s.verify_feature_capability()
145+
except exceptions.UnsupportedConfiguration as unexpected_exc:
146+
pytest.fail(
147+
"Expected PyPI to work without skip_existing_non_pypi "
148+
f"but got {unexpected_exc!r}"
149+
)
150+
151+
# TestPyPI should work without the flag
152+
s = settings.Settings(skip_existing=True, skip_existing_non_pypi=False)
153+
s.repository_config = {"repository": repository.TEST_WAREHOUSE}
154+
try:
155+
s.verify_feature_capability()
156+
except exceptions.UnsupportedConfiguration as unexpected_exc:
157+
pytest.fail(
158+
"Expected TestPyPI to work without skip_existing_non_pypi "
159+
f"but got {unexpected_exc!r}"
160+
)
161+
162+
# PyPI should still work with the flag
163+
s = settings.Settings(skip_existing=True, skip_existing_non_pypi=True)
164+
s.repository_config = {"repository": repository.WAREHOUSE}
165+
try:
166+
s.verify_feature_capability()
167+
except exceptions.UnsupportedConfiguration as unexpected_exc:
168+
pytest.fail(
169+
"Expected PyPI to work with skip_existing_non_pypi "
170+
f"but got {unexpected_exc!r}"
171+
)
172+
173+
118174
@pytest.mark.parametrize(
119175
"verbose, log_level", [(True, logging.INFO), (False, logging.WARNING)]
120176
)
@@ -202,3 +258,15 @@ def test_non_interactive_environment(self, monkeypatch):
202258
def test_attestations_flag(self):
203259
args = self.parse_args(["--attestations"])
204260
assert args.attestations
261+
262+
def test_skip_existing_non_pypi_flag(self):
263+
args = self.parse_args(["--skip-existing-non-pypi"])
264+
assert args.skip_existing_non_pypi
265+
266+
def test_skip_existing_non_pypi_environment(self, monkeypatch):
267+
monkeypatch.setenv("TWINE_SKIP_EXISTING_NON_PYPI", "1")
268+
args = self.parse_args([])
269+
assert args.skip_existing_non_pypi
270+
monkeypatch.setenv("TWINE_SKIP_EXISTING_NON_PYPI", "0")
271+
args = self.parse_args([])
272+
assert not args.skip_existing_non_pypi

twine/settings.py

Lines changed: 28 additions & 5 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+
skip_existing_non_pypi: bool = False,
5859
cacert: Optional[str] = None,
5960
client_cert: Optional[str] = None,
6061
repository_name: str = "pypi",
@@ -90,6 +91,9 @@ def __init__(
9091
Specify whether twine should continue uploading files if one
9192
of them already exists. This primarily supports PyPI. Other
9293
package indexes may not be supported.
94+
:param skip_existing_non_pypi:
95+
Allow --skip-existing to work with non-PyPI repositories.
96+
Use at your own risk with third-party registries.
9397
:param cacert:
9498
The path to the bundle of certificates used to verify the TLS
9599
connection to the package index.
@@ -113,6 +117,7 @@ def __init__(
113117
self.verbose = verbose
114118
self.disable_progress_bar = disable_progress_bar
115119
self.skip_existing = skip_existing
120+
self.skip_existing_non_pypi = skip_existing_non_pypi
116121
self._handle_repository_options(
117122
repository_name=repository_name,
118123
repository_url=repository_url,
@@ -245,9 +250,18 @@ def register_argparse_arguments(parser: argparse.ArgumentParser) -> None:
245250
default=False,
246251
action="store_true",
247252
help="Continue uploading files if one already exists. (Only valid "
248-
"when uploading to PyPI. Other implementations may not "
253+
"when uploading to PyPI by default. For other repositories, use "
254+
"--skip-existing-non-pypi flag. Other implementations may not "
249255
"support this.)",
250256
)
257+
parser.add_argument(
258+
"--skip-existing-non-pypi",
259+
action=utils.EnvironmentFlag,
260+
env="TWINE_SKIP_EXISTING_NON_PYPI",
261+
help="Allow --skip-existing to work with non-PyPI repositories. "
262+
"Use at your own risk with third-party registries. (Can also be "
263+
"set via %(env)s environment variable.)",
264+
)
251265
parser.add_argument(
252266
"--cert",
253267
action=utils.EnvironmentDefault,
@@ -317,7 +331,10 @@ def verify_feature_capability(self) -> None:
317331
"""Verify configured settings are supported for the configured repository.
318332
319333
This presently checks:
320-
- ``--skip-existing`` was only provided for PyPI and TestPyPI
334+
335+
- ``--skip-existing`` was only provided for PyPI and TestPyPI, unless
336+
explicitly allowed via --skip-existing-non-pypi flag or
337+
TWINE_SKIP_EXISTING_NON_PYPI environment variable
321338
322339
:raises twine.exceptions.UnsupportedConfiguration:
323340
The configured features are not available with the configured
@@ -328,9 +345,15 @@ def verify_feature_capability(self) -> None:
328345
if self.skip_existing and not repository_url.startswith(
329346
(repository.WAREHOUSE, repository.TEST_WAREHOUSE)
330347
):
331-
raise exceptions.UnsupportedConfiguration.Builder().with_feature(
332-
"--skip-existing"
333-
).with_repository_url(repository_url).finalize()
348+
# Allow opt-in for non-PyPI repositories (e.g., for testing or
349+
# third-party registries that support PEP 694's 409 response)
350+
if not self.skip_existing_non_pypi:
351+
raise (
352+
exceptions.UnsupportedConfiguration.Builder()
353+
.with_feature("--skip-existing")
354+
.with_repository_url(repository_url)
355+
.finalize()
356+
)
334357

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

0 commit comments

Comments
 (0)