Skip to content

Commit

Permalink
add --skip-existing to publish
Browse files Browse the repository at this point in the history
add `--skip-existing` option to publish command to continue if an upload
fails due to the file already existing. this is helpful in e.g. ci
settings. resolves #2254
  • Loading branch information
davidszotten committed Jun 17, 2021
1 parent 925429f commit 383e3cc
Show file tree
Hide file tree
Showing 7 changed files with 103 additions and 14 deletions.
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ Should match a repository name set by the [`config`](#config) command.
* `--username (-u)`: The username to access the repository.
* `--password (-p)`: The password to access the repository.
* `--dry-run`: Perform all actions except upload the package.
* `--skip-existing`: Ignore errors from files already existing in the repository.

## config

Expand Down
6 changes: 6 additions & 0 deletions poetry/console/commands/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ class PublishCommand(Command):
),
option("build", None, "Build the package before publishing."),
option("dry-run", None, "Perform all actions except upload the package."),
option(
"skip-existing",
None,
"Ignore errors from files already existing in the repository.",
),
]

help = """The publish command builds and uploads the package to a remote repository.
Expand Down Expand Up @@ -82,4 +87,5 @@ def handle(self) -> Optional[int]:
cert,
client_cert,
self.option("dry-run"),
self.option("skip-existing"),
)
2 changes: 2 additions & 0 deletions poetry/publishing/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def publish(
cert: Optional[Path] = None,
client_cert: Optional[Path] = None,
dry_run: Optional[bool] = False,
skip_existing: Optional[bool] = False,
) -> None:
if not repository_name:
url = "https://upload.pypi.org/legacy/"
Expand Down Expand Up @@ -102,4 +103,5 @@ def publish(
cert=cert or get_cert(self._poetry.config, repository_name),
client_cert=resolved_client_cert,
dry_run=dry_run,
skip_existing=skip_existing,
)
55 changes: 49 additions & 6 deletions poetry/publishing/uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,32 @@ def __init__(self, error: Union[ConnectionError, HTTPError, str]) -> None:
super().__init__(message)


def _ignore_existing(
response, skip_existing
): # type: (requests.Response, bool) -> bool
# based on https://github.com/pypa/twine/blob/62cec5b77037345b4fe1a85bbc6b104440799496/twine/commands/upload.py#L32
if not skip_existing:
return False

status = response.status_code
reason = getattr(response, "reason", "").lower()
text = getattr(response, "text", "").lower()

# NOTE(sigmavirus24): PyPI presently returns a 400 status code with the
# error message in the reason attribute. Other implementations return a
# 403 or 409 status code.
return (
# pypiserver (https://pypi.org/project/pypiserver)
status == 409
# PyPI / TestPyPI
or (status == 400 and "already exist" in reason)
# Nexus Repository OSS (https://www.sonatype.com/nexus-repository-oss)
or (status == 400 and "updating asset" in reason)
# Artifactory (https://jfrog.com/artifactory/)
or (status == 403 and "overwrite artifact" in text)
)


class Uploader:
def __init__(self, poetry: "Poetry", io: "NullIO") -> None:
self._poetry = poetry
Expand Down Expand Up @@ -113,6 +139,7 @@ def upload(
cert: Optional[Path] = None,
client_cert: Optional[Path] = None,
dry_run: bool = False,
skip_existing: Optional[bool] = False,
) -> None:
session = self.make_session()

Expand All @@ -123,7 +150,7 @@ def upload(
session.cert = str(client_cert)

try:
self._upload(session, url, dry_run)
self._upload(session, url, dry_run, skip_existing)
finally:
session.close()

Expand Down Expand Up @@ -206,10 +233,14 @@ def post_data(self, file: Path) -> Dict[str, Any]:
return data

def _upload(
self, session: requests.Session, url: str, dry_run: Optional[bool] = False
self,
session: requests.Session,
url: str,
dry_run: Optional[bool] = False,
skip_existing: Optional[bool] = False,
) -> None:
try:
self._do_upload(session, url, dry_run)
self._do_upload(session, url, dry_run, skip_existing)
except HTTPError as e:
if (
e.response.status_code == 400
Expand All @@ -223,14 +254,18 @@ def _upload(
raise UploadError(e)

def _do_upload(
self, session: requests.Session, url: str, dry_run: Optional[bool] = False
self,
session: requests.Session,
url: str,
dry_run: Optional[bool] = False,
skip_existing: Optional[bool] = False,
) -> None:
for file in self.files:
# TODO: Check existence

resp = self._upload_file(session, url, file, dry_run)
resp = self._upload_file(session, url, file, dry_run, skip_existing)

if not dry_run:
if not dry_run and not _ignore_existing(resp, skip_existing):
resp.raise_for_status()

def _upload_file(
Expand All @@ -239,6 +274,7 @@ def _upload_file(
url: str,
file: Path,
dry_run: Optional[bool] = False,
skip_existing: Optional[bool] = False,
) -> requests.Response:
from cleo.ui.progress_bar import ProgressBar

Expand Down Expand Up @@ -296,7 +332,14 @@ def _upload_file(
f" - Uploading <c1>{file.name}</c1> <error>FAILED</>"
)
raise UploadError(e)
else:
if _ignore_existing(resp, skip_existing):
if self._io.output.is_decorated():
self._io.overwrite(
f" - Uploading <c1>{file.name}</c1> <warning>File exists. Skipping</>"
)
finally:
bar.finish()
self._io.write_line("")

return resp
Expand Down
23 changes: 21 additions & 2 deletions tests/console/commands/test_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def test_publish_with_cert(app_tester, mocker):
app_tester.execute("publish --cert path/to/ca.pem")

assert [
(None, None, None, Path("path/to/ca.pem"), None, False)
(None, None, None, Path("path/to/ca.pem"), None, False, False)
] == publisher_publish.call_args


Expand All @@ -59,7 +59,7 @@ def test_publish_with_client_cert(app_tester, mocker):

app_tester.execute("publish --client-cert path/to/client.pem")
assert [
(None, None, None, None, Path("path/to/client.pem"), False)
(None, None, None, None, Path("path/to/client.pem"), False, False)
] == publisher_publish.call_args


Expand All @@ -78,3 +78,22 @@ def test_publish_dry_run(app_tester, http):
assert "Publishing simple-project (1.2.3) to PyPI" in output
assert "- Uploading simple-project-1.2.3.tar.gz" in error
assert "- Uploading simple_project-1.2.3-py2.py3-none-any.whl" in error


def test_publish_skip_existing(app_tester, http):
http.register_uri(
http.POST, "https://upload.pypi.org/legacy/", status=409, body="Conflict"
)

exit_code = app_tester.execute(
"publish --skip-existing --username foo --password bar"
)

assert 0 == exit_code

output = app_tester.io.fetch_output()
error = app_tester.io.fetch_error()

assert "Publishing simple-project (1.2.3) to PyPI" in output
assert "- Uploading simple-project-1.2.3.tar.gz" in error
assert "- Uploading simple_project-1.2.3-py2.py3-none-any.whl" in error
22 changes: 16 additions & 6 deletions tests/publishing/test_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker, config):
assert [("foo", "bar")] == uploader_auth.call_args
assert [
("https://upload.pypi.org/legacy/",),
{"cert": None, "client_cert": None, "dry_run": False},
{"cert": None, "client_cert": None, "dry_run": False, "skip_existing": False},
] == uploader_upload.call_args


Expand Down Expand Up @@ -57,7 +57,7 @@ def test_publish_can_publish_to_given_repository(
assert [("foo", "bar")] == uploader_auth.call_args
assert [
("http://foo.bar",),
{"cert": None, "client_cert": None, "dry_run": False},
{"cert": None, "client_cert": None, "dry_run": False, "skip_existing": False},
] == uploader_upload.call_args
assert "Publishing my-package (1.2.3) to foo" in io.fetch_output()

Expand Down Expand Up @@ -87,7 +87,7 @@ def test_publish_uses_token_if_it_exists(fixture_dir, mocker, config):
assert [("__token__", "my-token")] == uploader_auth.call_args
assert [
("https://upload.pypi.org/legacy/",),
{"cert": None, "client_cert": None, "dry_run": False},
{"cert": None, "client_cert": None, "dry_run": False, "skip_existing": False},
] == uploader_upload.call_args


Expand All @@ -111,7 +111,12 @@ def test_publish_uses_cert(fixture_dir, mocker, config):
assert [("foo", "bar")] == uploader_auth.call_args
assert [
("https://foo.bar",),
{"cert": Path(cert), "client_cert": None, "dry_run": False},
{
"cert": Path(cert),
"client_cert": None,
"dry_run": False,
"skip_existing": False,
},
] == uploader_upload.call_args


Expand All @@ -132,7 +137,12 @@ def test_publish_uses_client_cert(fixture_dir, mocker, config):

assert [
("https://foo.bar",),
{"cert": None, "client_cert": Path(client_cert), "dry_run": False},
{
"cert": None,
"client_cert": Path(client_cert),
"dry_run": False,
"skip_existing": False,
},
] == uploader_upload.call_args


Expand All @@ -150,5 +160,5 @@ def test_publish_read_from_environment_variable(fixture_dir, environ, mocker, co
assert [("bar", "baz")] == uploader_auth.call_args
assert [
("https://foo.bar",),
{"cert": None, "client_cert": None, "dry_run": False},
{"cert": None, "client_cert": None, "dry_run": False, "skip_existing": False},
] == uploader_upload.call_args
8 changes: 8 additions & 0 deletions tests/publishing/test_uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,11 @@ def test_uploader_registers_for_appropriate_400_errors(mocker, http, uploader):
uploader.upload("https://foo.com")

assert 1 == register.call_count


def test_uploader_skips_existing(http):
http.register_uri(http.POST, "https://foo.com", status=409, body="Conflict")
uploader = Uploader(Factory().create_poetry(project("simple_project")), NullIO())

# should not raise
uploader.upload("https://foo.com", skip_existing=True)

0 comments on commit 383e3cc

Please sign in to comment.