Skip to content

Commit

Permalink
feat: search for open port before starting run_local_server flow (#36)
Browse files Browse the repository at this point in the history
* feat: search for open port before starting run_local_server flow

This prevents issues if port 8080 is already occupied. It also makes the
system tests less flakey, as we get fewer cases when the authorization
code goes to the wrong test.

* test: add unit tests for webserver module, update changelog

* fix: use PyDataConnectionError to support py2.7

* fix: test with assert_called_once_with for py3.5
  • Loading branch information
tswast authored Apr 23, 2020
1 parent bef9ff1 commit 29566c1
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 7 deletions.
8 changes: 8 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Changelog
=========

.. _changelog-1.1.0:

1.1.0 / TBD
-----------

- Try a range of ports between 8080 and 8090 when ``use_local_webserver`` is
``True``. (:issue:`35`)

.. _changelog-1.0.0:

1.0.0 / (2020-04-20)
Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def unit(session):
@nox.session(python=latest_python)
def cover(session):
session.install("coverage", "pytest-cov")
session.run("coverage", "report", "--show-missing", "--fail-under=40")
session.run("coverage", "report", "--show-missing", "--fail-under=50")
session.run("coverage", "erase")


Expand Down
5 changes: 3 additions & 2 deletions pydata_google_auth/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@
)
LOGIN_USE_LOCAL_WEBSERVER_HELP = (
"Use a local webserver for the user authentication. This starts "
"a webserver on localhost, which allows the browser to pass a token "
"directly to the program."
"a webserver on localhost with a port between 8080 and 8089, "
"inclusive, which allows the browser to pass a token directly to the "
"program."
)

PRINT_TOKEN_HELP = "Load a credentials JSON file and print an access token."
Expand Down
89 changes: 89 additions & 0 deletions pydata_google_auth/_webserver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Helpers for running a local webserver to receive authorization code."""

import socket
from contextlib import closing

from pydata_google_auth import exceptions


LOCALHOST = "localhost"
DEFAULT_PORTS_TO_TRY = 100


def is_port_open(port):
"""Check if a port is open on localhost.
Based on StackOverflow answer: https://stackoverflow.com/a/43238489/101923
Parameters
----------
port : int
A port to check on localhost.
Returns
-------
is_open : bool
True if a socket can be opened at the requested port.
"""
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
try:
sock.bind((LOCALHOST, port))
sock.listen(1)
except socket.error:
is_open = False
else:
is_open = True
return is_open


def find_open_port(start=8080, stop=None):
"""Find an open port between ``start`` and ``stop``.
Parameters
----------
start : Optional[int]
Beginning of range of ports to try. Defaults to 8080.
stop : Optional[int]
End of range of ports to try (not including exactly equals ``stop``).
This function tries 100 possible ports if no ``stop`` is specified.
Returns
-------
Optional[int]
``None`` if no open port is found, otherwise an integer indicating an
open port.
"""
if not stop:
stop = start + DEFAULT_PORTS_TO_TRY

for port in range(start, stop):
if is_port_open(port):
return port

# No open ports found.
return None


def run_local_server(app_flow):
"""Run local webserver installed app flow on some open port.
Parameters
----------
app_flow : google_auth_oauthlib.flow.InstalledAppFlow
Installed application flow to fetch user credentials.
Returns
-------
google.auth.credentials.Credentials
User credentials from installed application flow.
Raises
------
pydata_google_auth.exceptions.PyDataConnectionError
If no open port can be found in the range from 8080 to 8089,
inclusive.
"""
port = find_open_port()
if not port:
raise exceptions.PyDataConnectionError("Could not find open port.")
return app_flow.run_local_server(host=LOCALHOST, port=port)
15 changes: 11 additions & 4 deletions pydata_google_auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from pydata_google_auth import exceptions
from pydata_google_auth import cache
from pydata_google_auth import _webserver


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -69,7 +70,9 @@ def default(
Windows.
use_local_webserver : bool, optional
Use a local webserver for the user authentication
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Defaults to
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Binds a
webserver to an open port on ``localhost`` between 8080 and 8089,
inclusive, to receive authentication token. If not set, defaults to
``False``, which requests a token via the console.
auth_local_webserver : deprecated
Use the ``use_local_webserver`` parameter instead.
Expand Down Expand Up @@ -210,7 +213,9 @@ def get_user_credentials(
Windows.
use_local_webserver : bool, optional
Use a local webserver for the user authentication
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Defaults to
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Binds a
webserver to an open port on ``localhost`` between 8080 and 8089,
inclusive, to receive authentication token. If not set, defaults to
``False``, which requests a token via the console.
auth_local_webserver : deprecated
Use the ``use_local_webserver`` parameter instead.
Expand Down Expand Up @@ -256,7 +261,7 @@ def get_user_credentials(

try:
if use_local_webserver:
credentials = app_flow.run_local_server()
credentials = _webserver.run_local_server(app_flow)
else:
credentials = app_flow.run_console()
except oauthlib.oauth2.rfc6749.errors.OAuth2Error as exc:
Expand Down Expand Up @@ -310,7 +315,9 @@ def save_user_credentials(
client's identity when using Google APIs.
use_local_webserver : bool, optional
Use a local webserver for the user authentication
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Defaults to
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Binds a
webserver to an open port on ``localhost`` between 8080 and 8089,
inclusive, to receive authentication token. If not set, defaults to
``False``, which requests a token via the console.
Returns
Expand Down
6 changes: 6 additions & 0 deletions pydata_google_auth/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ class PyDataCredentialsError(ValueError):
"""
Raised when invalid credentials are provided, or tokens have expired.
"""


class PyDataConnectionError(RuntimeError):
"""
Raised when unable to fetch credentials due to connection error.
"""
74 changes: 74 additions & 0 deletions tests/unit/test_webserver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-

import socket

try:
from unittest import mock
except ImportError: # pragma: NO COVER
import mock

import google_auth_oauthlib.flow
import pytest

from pydata_google_auth import exceptions


@pytest.fixture
def module_under_test():
from pydata_google_auth import _webserver

return _webserver


def test_find_open_port_finds_start_port(monkeypatch, module_under_test):
monkeypatch.setattr(socket, "socket", mock.create_autospec(socket.socket))
port = module_under_test.find_open_port(9999)
assert port == 9999


def test_find_open_port_finds_stop_port(monkeypatch, module_under_test):
socket_instance = mock.create_autospec(socket.socket, instance=True)

def mock_socket(family, type_):
return socket_instance

monkeypatch.setattr(socket, "socket", mock_socket)
socket_instance.listen.side_effect = [socket.error] * 99 + [None]
port = module_under_test.find_open_port(9000, stop=9100)
assert port == 9099


def test_find_open_port_returns_none(monkeypatch, module_under_test):
socket_instance = mock.create_autospec(socket.socket, instance=True)

def mock_socket(family, type_):
return socket_instance

monkeypatch.setattr(socket, "socket", mock_socket)
socket_instance.listen.side_effect = socket.error
port = module_under_test.find_open_port(9000)
assert port is None
socket_instance.listen.assert_has_calls(mock.call(1) for _ in range(100))


def test_run_local_server_calls_flow(monkeypatch, module_under_test):
mock_flow = mock.create_autospec(
google_auth_oauthlib.flow.InstalledAppFlow, instance=True
)
module_under_test.run_local_server(mock_flow)
mock_flow.run_local_server.assert_called_once_with(host="localhost", port=8080)


def test_run_local_server_raises_connectionerror(monkeypatch, module_under_test):
def mock_find_open_port():
return None

monkeypatch.setattr(module_under_test, "find_open_port", mock_find_open_port)
mock_flow = mock.create_autospec(
google_auth_oauthlib.flow.InstalledAppFlow, instance=True
)

with pytest.raises(exceptions.PyDataConnectionError):
module_under_test.run_local_server(mock_flow)

mock_flow.run_local_server.assert_not_called()

0 comments on commit 29566c1

Please sign in to comment.