Skip to content

Commit

Permalink
Handle connection errors when publishing
Browse files Browse the repository at this point in the history
This change ensures connection errors are handled explicitly for
cases where there are network issues.

In addition to the above, this includes type hint updates and minor
refactor.
  • Loading branch information
abn committed Apr 9, 2020
1 parent 4f5cc84 commit ebd1fbf
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 52 deletions.
36 changes: 16 additions & 20 deletions poetry/publishing/publisher.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import logging

from typing import Optional

from poetry.utils._compat import Path
from poetry.utils.helpers import get_cert
from poetry.utils.helpers import get_client_cert
from poetry.utils.password_manager import PasswordManager
Expand Down Expand Up @@ -34,24 +37,7 @@ def publish(
cert=None,
client_cert=None,
dry_run=False,
):
if repository_name:
self._io.write_line(
"Publishing <c1>{}</c1> (<c2>{}</c2>) "
"to <info>{}</info>".format(
self._package.pretty_name,
self._package.pretty_version,
repository_name,
)
)
else:
self._io.write_line(
"Publishing <c1>{}</c1> (<c2>{}</c2>) "
"to <info>PyPI</info>".format(
self._package.pretty_name, self._package.pretty_version
)
)

): # type: (Optional[str], Optional[str], Optional[str], Optional[Path], Optional[Path], Optional[bool]) -> None
if not repository_name:
url = "https://upload.pypi.org/legacy/"
repository_name = "pypi"
Expand Down Expand Up @@ -89,12 +75,22 @@ def publish(
if username is None:
username = self._io.ask("Username:")

if password is None:
# skip password input if no username is provided, assume unauthenticated
if username and password is None:
password = self._io.ask_hidden("Password:")

self._uploader.auth(username, password)

return self._uploader.upload(
self._io.write_line(
"Publishing <c1>{}</c1> (<c2>{}</c2>) "
"to <info>{}</info>".format(
self._package.pretty_name,
self._package.pretty_version,
{"pypi": "PyPI"}.get(repository_name, "PyPI"),
)
)

self._uploader.upload(
url,
cert=cert or get_cert(self._poetry.config, repository_name),
client_cert=resolved_client_cert,
Expand Down
81 changes: 49 additions & 32 deletions poetry/publishing/uploader.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import hashlib
import io
import math

from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Union

import requests

from requests import adapters
from requests.exceptions import ConnectionError
from requests.exceptions import HTTPError
from requests.packages.urllib3 import util
from requests_toolbelt import user_agent
Expand All @@ -27,12 +30,19 @@


class UploadError(Exception):
def __init__(self, error): # type: (HTTPError) -> None
super(UploadError, self).__init__(
"HTTP Error {}: {}".format(
def __init__(self, error): # type: (Union[ConnectionError, HTTPError]) -> None
if isinstance(error, HTTPError):
message = "HTTP Error {}: {}".format(
error.response.status_code, error.response.reason
)
)
elif isinstance(error, ConnectionError):
message = (
"Connection Error: We were unable to connect to the repository, "
"ensure the url is correct and can be reached."
)
else:
message = str(error)
super(UploadError, self).__init__(message)


class Uploader:
Expand All @@ -59,7 +69,7 @@ def adapter(self):
return adapters.HTTPAdapter(max_retries=retry)

@property
def files(self): # type: () -> List[str]
def files(self): # type: () -> List[Path]
dist = self._poetry.file.parent / "dist"
version = normalize_version(self._package.version.text)

Expand All @@ -80,7 +90,7 @@ def auth(self, username, password):
self._username = username
self._password = password

def make_session(self):
def make_session(self): # type: () -> requests.Session
session = requests.session()
if self.is_authenticated():
session.auth = (self._username, self._password)
Expand Down Expand Up @@ -110,7 +120,7 @@ def upload(
finally:
session.close()

def post_data(self, file):
def post_data(self, file): # type: (Path) -> Dict[str, Any]
meta = Metadata.from_package(self._package)

file_type = self._get_type(file)
Expand Down Expand Up @@ -188,7 +198,9 @@ def post_data(self, file):

return data

def _upload(self, session, url, dry_run=False):
def _upload(
self, session, url, dry_run=False
): # type: (requests.Session, str, Optional[bool]) -> None
try:
self._do_upload(session, url, dry_run)
except HTTPError as e:
Expand All @@ -203,7 +215,9 @@ def _upload(self, session, url, dry_run=False):

raise UploadError(e)

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

Expand All @@ -212,7 +226,9 @@ def _do_upload(self, session, url, dry_run=False):
if not dry_run:
resp.raise_for_status()

def _upload_file(self, session, url, file, dry_run=False):
def _upload_file(
self, session, url, file, dry_run=False
): # type: (requests.Session, str, Path, Optional[bool]) -> requests.Response
data = self.post_data(file)
data.update(
{
Expand Down Expand Up @@ -241,36 +257,37 @@ def _upload_file(self, session, url, file, dry_run=False):

resp = None

if not dry_run:
resp = session.post(
url,
data=monitor,
allow_redirects=False,
headers={"Content-Type": monitor.content_type},
)

if dry_run or resp.ok:
bar.set_format(
" - Uploading <c1>{0}</c1> <fg=green>%percent%%</>".format(
file.name
try:
if not dry_run:
resp = session.post(
url,
data=monitor,
allow_redirects=False,
headers={"Content-Type": monitor.content_type},
)
)
bar.finish()

self._io.write_line("")
else:
if dry_run or resp.ok:
bar.set_format(
" - Uploading <c1>{0}</c1> <fg=green>%percent%%</>".format(
file.name
)
)
bar.finish()
except (requests.ConnectionError, requests.HTTPError) as e:
if self._io.output.supports_ansi():
self._io.overwrite(
" - Uploading <c1>{0}</c1> <error>{1}%</>".format(
file.name, int(math.floor(bar._percent * 100))
" - Uploading <c1>{0}</c1> <error>{1}</>".format(
file.name, "FAILED"
)
)

raise UploadError(e)
finally:
self._io.write_line("")

return resp

def _register(self, session, url):
def _register(
self, session, url
): # type: (requests.Session, str) -> requests.Response
"""
Register a package to a repository.
"""
Expand Down
21 changes: 21 additions & 0 deletions tests/console/commands/test_publish.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from typing import NoReturn

import pytest
import requests

from poetry.publishing.uploader import UploadError
from poetry.utils._compat import PY36
from poetry.utils._compat import Path

Expand Down Expand Up @@ -28,6 +32,23 @@ def test_publish_returns_non_zero_code_for_upload_errors(app, app_tester, http):
assert expected in app_tester.io.fetch_output()


def test_publish_returns_non_zero_code_for_connection_errors(app, app_tester, http):
def request_callback(*_, **__) -> NoReturn:
raise requests.ConnectionError()

http.register_uri(
http.POST, "https://upload.pypi.org/legacy/", body=request_callback
)

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

assert 1 == exit_code

expected = str(UploadError(error=requests.ConnectionError()))

assert expected in app_tester.io.fetch_output()


@pytest.mark.skipif(
PY36, reason="Improved error rendering is not available on Python <3.6"
)
Expand Down

0 comments on commit ebd1fbf

Please sign in to comment.