Skip to content

Commit c1c02d1

Browse files
committed
Remove --skip-existing support for non-PyPI indices
Closes #1251
1 parent a24d308 commit c1c02d1

File tree

7 files changed

+136
-59
lines changed

7 files changed

+136
-59
lines changed

changelog/1251.removal.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Remove hacks that support ``--skip-existing`` for indexes other than PyPI and
2+
TestPyPI.
3+
4+
To date, these hacks continue to accrue and there have been numerous issues
5+
with them, not the least of which being that every time we update them, the
6+
paid index providers change things to break the compatibility we implement for
7+
them. Beyond that, these hacks do not work when text is internationalized in
8+
the response from the index provider.
9+
10+
For a sample of past issues, see:
11+
12+
- https://github.com/pypa/twine/issues/1251
13+
14+
- https://github.com/pypa/twine/issues/918
15+
16+
- https://github.com/pypa/twine/issues/856
17+
18+
- https://github.com/pypa/twine/issues/693
19+
20+
- https://github.com/pypa/twine/issues/332

tests/test_settings.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import pytest
2020

2121
from twine import exceptions
22+
from twine import repository
2223
from twine import settings
2324

2425

@@ -76,11 +77,44 @@ def test_settings_transforms_repository_config_non_pypi(write_config_file):
7677
assert s.identity is None
7778
assert s.username == "someusername"
7879
assert s.password == "password"
79-
assert s.cacert is None
8080
assert s.client_cert is None
8181
assert s.disable_progress_bar is False
8282

8383

84+
def test_settings_verify_feature_compatibility() -> None:
85+
s = settings.Settings(skip_existing=True)
86+
s.repository_config = {"repository": repository.WAREHOUSE}
87+
try:
88+
s.verify_feature_capability()
89+
except exceptions.UnsupportedConfiguration as unexpected_exc:
90+
pytest.fail(
91+
"Expected feature capability to work with production PyPI"
92+
f" but got {unexpected_exc!r}"
93+
)
94+
95+
s.repository_config["repository"] = repository.TEST_WAREHOUSE
96+
try:
97+
s.verify_feature_capability()
98+
except exceptions.UnsupportedConfiguration as unexpected_exc:
99+
pytest.fail(
100+
"Expected feature capability to work with TestPyPI but got"
101+
f" {unexpected_exc!r}"
102+
)
103+
104+
s.repository_config["repository"] = "https://not-really-pypi.example.com/legacy"
105+
with pytest.raises(exceptions.UnsupportedConfiguration):
106+
s.verify_feature_capability()
107+
108+
s.skip_existing = False
109+
try:
110+
s.verify_feature_capability()
111+
except exceptions.UnsupportedConfiguration as unexpected_exc:
112+
pytest.fail(
113+
"Expected an exception only when --skip-existing is provided"
114+
f" but got {unexpected_exc!r}"
115+
)
116+
117+
84118
@pytest.mark.parametrize(
85119
"verbose, log_level", [(True, logging.INFO), (False, logging.WARNING)]
86120
)

tests/test_upload.py

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -458,27 +458,6 @@ def test_prints_skip_message_for_response(
458458
),
459459
id="pypi",
460460
),
461-
pytest.param(
462-
dict(
463-
status_code=400,
464-
reason=(
465-
"Repository does not allow updating assets: pypi for url: "
466-
"http://www.foo.bar"
467-
),
468-
),
469-
id="nexus",
470-
),
471-
pytest.param(
472-
dict(
473-
status_code=400,
474-
text=(
475-
'<div class="content-section">\n'
476-
" Repository does not allow updating assets: pypi-local\n"
477-
"</div>\n"
478-
),
479-
),
480-
id="nexus_new",
481-
),
482461
pytest.param(
483462
dict(
484463
status_code=409,
@@ -489,37 +468,6 @@ def test_prints_skip_message_for_response(
489468
),
490469
id="pypiserver",
491470
),
492-
pytest.param(
493-
dict(
494-
status_code=403,
495-
text=(
496-
"Not enough permissions to overwrite artifact "
497-
"'pypi-local:twine/1.5.0/twine-1.5.0-py2.py3-none-any.whl'"
498-
"(user 'twine-deployer' needs DELETE permission)."
499-
),
500-
),
501-
id="artifactory_old",
502-
),
503-
pytest.param(
504-
dict(
505-
status_code=403,
506-
text=(
507-
"Not enough permissions to delete/overwrite artifact "
508-
"'pypi-local:twine/1.5.0/twine-1.5.0-py2.py3-none-any.whl'"
509-
"(user 'twine-deployer' needs DELETE permission)."
510-
),
511-
),
512-
id="artifactory_new",
513-
),
514-
pytest.param(
515-
dict(
516-
status_code=400,
517-
text=(
518-
'{"message":"validation failed: file name has already been taken"}'
519-
),
520-
),
521-
id="gitlab_enterprise",
522-
),
523471
],
524472
)
525473
def test_skip_existing_skips_files_on_repository(response_kwargs):

twine/commands/upload.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,6 @@ def skip_upload(
6161
status == 409
6262
# PyPI / TestPyPI / GCP Artifact Registry
6363
or (status == 400 and any("already exist" in x for x in [reason, text]))
64-
# Nexus Repository OSS (https://www.sonatype.com/nexus-repository-oss)
65-
or (status == 400 and any("updating asset" in x for x in [reason, text]))
66-
# Artifactory (https://jfrog.com/artifactory/)
67-
or (status == 403 and "overwrite artifact" in text)
68-
# Gitlab Enterprise Edition (https://about.gitlab.com)
69-
or (status == 400 and "already been taken" in text)
7064
)
7165

7266

@@ -131,6 +125,7 @@ def upload(upload_settings: settings.Settings, dists: List[str]) -> None:
131125
The repository responded with an error.
132126
"""
133127
upload_settings.check_repository_url()
128+
upload_settings.verify_feature_capability()
134129
repository_url = cast(str, upload_settings.repository_config["repository"])
135130

136131
# Attestations are only supported on PyPI and TestPyPI at the moment.

twine/exceptions.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
16+
import typing as t
1617

1718

1819
class TwineException(Exception):
@@ -77,6 +78,49 @@ def from_args(
7778
)
7879

7980

81+
class UnsupportedConfiguration(TwineException):
82+
"""An upload attempt was detected using features not supported by a repository.
83+
84+
The features specified either in configuration or on the command-line.
85+
"""
86+
87+
class Builder:
88+
"""Build the parameters for an UnsupportedConfiguration exception.
89+
90+
In the event we add additional features we are not allowing with
91+
something other than PyPI or TestPyPI, we can use a builder to
92+
accumulate them all instead of requiring someone to run multiple times
93+
to discover all unsupported configuration options.
94+
"""
95+
96+
repository_url: str
97+
features: t.List[str]
98+
99+
def __init__(self) -> None:
100+
self.repository_url = ""
101+
self.features = []
102+
103+
def with_repository_url(
104+
self, repository_url: str
105+
) -> "UnsupportedConfiguration.Builder":
106+
self.repository_url = repository_url
107+
return self
108+
109+
def with_feature(self, feature: str) -> "UnsupportedConfiguration.Builder":
110+
self.features.append(feature)
111+
return self
112+
113+
def finalize(self) -> "UnsupportedConfiguration":
114+
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,
121+
)
122+
123+
80124
class UnreachableRepositoryURLDetected(TwineException):
81125
"""An upload attempt was detected to a URL without a protocol prefix.
82126

twine/repository.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,23 @@ def upload(
181181
def package_is_uploaded(
182182
self, package: package_file.PackageFile, bypass_cache: bool = False
183183
) -> bool:
184+
"""Determine if a package has been uploaded to PyPI already.
185+
186+
.. warning:: This does not support indexes other than PyPI or TestPyPI
187+
188+
:param package:
189+
The package file that will otherwise be uploaded.
190+
:type package:
191+
:class:`~twine.package.PackageFile`
192+
:param bypass_cache:
193+
Force a request to PyPI.
194+
:type bypass_cache:
195+
bool
196+
:returns:
197+
True if package has already been uploaded, False otherwise
198+
:rtype:
199+
bool
200+
"""
184201
# NOTE(sigmavirus24): Not all indices are PyPI and pypi.io doesn't
185202
# have a similar interface for finding the package versions.
186203
if not self.url.startswith((LEGACY_PYPI, WAREHOUSE, OLD_WAREHOUSE)):

twine/settings.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,25 @@ def _handle_certificates(
313313
self.cacert = utils.get_cacert(cacert, self.repository_config)
314314
self.client_cert = utils.get_clientcert(client_cert, self.repository_config)
315315

316+
def verify_feature_capability(self) -> None:
317+
"""Verify configured settings are supported for the configured repository.
318+
319+
This presently checks:
320+
- ``--skip-existing`` was only provided for PyPI and TestPyPI
321+
322+
:raises twine.exceptions.UnsupportedConfiguration:
323+
The configured features are not available with the configured
324+
repository.
325+
"""
326+
repository_url = cast(str, self.repository_config["repository"])
327+
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()
334+
316335
def check_repository_url(self) -> None:
317336
"""Verify we are not using legacy PyPI.
318337

0 commit comments

Comments
 (0)