Skip to content

Commit 29566c1

Browse files
authored
feat: search for open port before starting run_local_server flow (#36)
* 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
1 parent bef9ff1 commit 29566c1

File tree

7 files changed

+192
-7
lines changed

7 files changed

+192
-7
lines changed

docs/source/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
Changelog
22
=========
33

4+
.. _changelog-1.1.0:
5+
6+
1.1.0 / TBD
7+
-----------
8+
9+
- Try a range of ports between 8080 and 8090 when ``use_local_webserver`` is
10+
``True``. (:issue:`35`)
11+
412
.. _changelog-1.0.0:
513

614
1.0.0 / (2020-04-20)

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def unit(session):
5656
@nox.session(python=latest_python)
5757
def cover(session):
5858
session.install("coverage", "pytest-cov")
59-
session.run("coverage", "report", "--show-missing", "--fail-under=40")
59+
session.run("coverage", "report", "--show-missing", "--fail-under=50")
6060
session.run("coverage", "erase")
6161

6262

pydata_google_auth/__main__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@
3131
)
3232
LOGIN_USE_LOCAL_WEBSERVER_HELP = (
3333
"Use a local webserver for the user authentication. This starts "
34-
"a webserver on localhost, which allows the browser to pass a token "
35-
"directly to the program."
34+
"a webserver on localhost with a port between 8080 and 8089, "
35+
"inclusive, which allows the browser to pass a token directly to the "
36+
"program."
3637
)
3738

3839
PRINT_TOKEN_HELP = "Load a credentials JSON file and print an access token."

pydata_google_auth/_webserver.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Helpers for running a local webserver to receive authorization code."""
2+
3+
import socket
4+
from contextlib import closing
5+
6+
from pydata_google_auth import exceptions
7+
8+
9+
LOCALHOST = "localhost"
10+
DEFAULT_PORTS_TO_TRY = 100
11+
12+
13+
def is_port_open(port):
14+
"""Check if a port is open on localhost.
15+
16+
Based on StackOverflow answer: https://stackoverflow.com/a/43238489/101923
17+
18+
Parameters
19+
----------
20+
port : int
21+
A port to check on localhost.
22+
23+
Returns
24+
-------
25+
is_open : bool
26+
True if a socket can be opened at the requested port.
27+
"""
28+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
29+
try:
30+
sock.bind((LOCALHOST, port))
31+
sock.listen(1)
32+
except socket.error:
33+
is_open = False
34+
else:
35+
is_open = True
36+
return is_open
37+
38+
39+
def find_open_port(start=8080, stop=None):
40+
"""Find an open port between ``start`` and ``stop``.
41+
42+
Parameters
43+
----------
44+
start : Optional[int]
45+
Beginning of range of ports to try. Defaults to 8080.
46+
stop : Optional[int]
47+
End of range of ports to try (not including exactly equals ``stop``).
48+
This function tries 100 possible ports if no ``stop`` is specified.
49+
50+
Returns
51+
-------
52+
Optional[int]
53+
``None`` if no open port is found, otherwise an integer indicating an
54+
open port.
55+
"""
56+
if not stop:
57+
stop = start + DEFAULT_PORTS_TO_TRY
58+
59+
for port in range(start, stop):
60+
if is_port_open(port):
61+
return port
62+
63+
# No open ports found.
64+
return None
65+
66+
67+
def run_local_server(app_flow):
68+
"""Run local webserver installed app flow on some open port.
69+
70+
Parameters
71+
----------
72+
app_flow : google_auth_oauthlib.flow.InstalledAppFlow
73+
Installed application flow to fetch user credentials.
74+
75+
Returns
76+
-------
77+
google.auth.credentials.Credentials
78+
User credentials from installed application flow.
79+
80+
Raises
81+
------
82+
pydata_google_auth.exceptions.PyDataConnectionError
83+
If no open port can be found in the range from 8080 to 8089,
84+
inclusive.
85+
"""
86+
port = find_open_port()
87+
if not port:
88+
raise exceptions.PyDataConnectionError("Could not find open port.")
89+
return app_flow.run_local_server(host=LOCALHOST, port=port)

pydata_google_auth/auth.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from pydata_google_auth import exceptions
1313
from pydata_google_auth import cache
14+
from pydata_google_auth import _webserver
1415

1516

1617
logger = logging.getLogger(__name__)
@@ -69,7 +70,9 @@ def default(
6970
Windows.
7071
use_local_webserver : bool, optional
7172
Use a local webserver for the user authentication
72-
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Defaults to
73+
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Binds a
74+
webserver to an open port on ``localhost`` between 8080 and 8089,
75+
inclusive, to receive authentication token. If not set, defaults to
7376
``False``, which requests a token via the console.
7477
auth_local_webserver : deprecated
7578
Use the ``use_local_webserver`` parameter instead.
@@ -210,7 +213,9 @@ def get_user_credentials(
210213
Windows.
211214
use_local_webserver : bool, optional
212215
Use a local webserver for the user authentication
213-
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Defaults to
216+
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Binds a
217+
webserver to an open port on ``localhost`` between 8080 and 8089,
218+
inclusive, to receive authentication token. If not set, defaults to
214219
``False``, which requests a token via the console.
215220
auth_local_webserver : deprecated
216221
Use the ``use_local_webserver`` parameter instead.
@@ -256,7 +261,7 @@ def get_user_credentials(
256261

257262
try:
258263
if use_local_webserver:
259-
credentials = app_flow.run_local_server()
264+
credentials = _webserver.run_local_server(app_flow)
260265
else:
261266
credentials = app_flow.run_console()
262267
except oauthlib.oauth2.rfc6749.errors.OAuth2Error as exc:
@@ -310,7 +315,9 @@ def save_user_credentials(
310315
client's identity when using Google APIs.
311316
use_local_webserver : bool, optional
312317
Use a local webserver for the user authentication
313-
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Defaults to
318+
:class:`google_auth_oauthlib.flow.InstalledAppFlow`. Binds a
319+
webserver to an open port on ``localhost`` between 8080 and 8089,
320+
inclusive, to receive authentication token. If not set, defaults to
314321
``False``, which requests a token via the console.
315322
316323
Returns

pydata_google_auth/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,9 @@ class PyDataCredentialsError(ValueError):
22
"""
33
Raised when invalid credentials are provided, or tokens have expired.
44
"""
5+
6+
7+
class PyDataConnectionError(RuntimeError):
8+
"""
9+
Raised when unable to fetch credentials due to connection error.
10+
"""

tests/unit/test_webserver.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# -*- coding: utf-8 -*-
2+
3+
import socket
4+
5+
try:
6+
from unittest import mock
7+
except ImportError: # pragma: NO COVER
8+
import mock
9+
10+
import google_auth_oauthlib.flow
11+
import pytest
12+
13+
from pydata_google_auth import exceptions
14+
15+
16+
@pytest.fixture
17+
def module_under_test():
18+
from pydata_google_auth import _webserver
19+
20+
return _webserver
21+
22+
23+
def test_find_open_port_finds_start_port(monkeypatch, module_under_test):
24+
monkeypatch.setattr(socket, "socket", mock.create_autospec(socket.socket))
25+
port = module_under_test.find_open_port(9999)
26+
assert port == 9999
27+
28+
29+
def test_find_open_port_finds_stop_port(monkeypatch, module_under_test):
30+
socket_instance = mock.create_autospec(socket.socket, instance=True)
31+
32+
def mock_socket(family, type_):
33+
return socket_instance
34+
35+
monkeypatch.setattr(socket, "socket", mock_socket)
36+
socket_instance.listen.side_effect = [socket.error] * 99 + [None]
37+
port = module_under_test.find_open_port(9000, stop=9100)
38+
assert port == 9099
39+
40+
41+
def test_find_open_port_returns_none(monkeypatch, module_under_test):
42+
socket_instance = mock.create_autospec(socket.socket, instance=True)
43+
44+
def mock_socket(family, type_):
45+
return socket_instance
46+
47+
monkeypatch.setattr(socket, "socket", mock_socket)
48+
socket_instance.listen.side_effect = socket.error
49+
port = module_under_test.find_open_port(9000)
50+
assert port is None
51+
socket_instance.listen.assert_has_calls(mock.call(1) for _ in range(100))
52+
53+
54+
def test_run_local_server_calls_flow(monkeypatch, module_under_test):
55+
mock_flow = mock.create_autospec(
56+
google_auth_oauthlib.flow.InstalledAppFlow, instance=True
57+
)
58+
module_under_test.run_local_server(mock_flow)
59+
mock_flow.run_local_server.assert_called_once_with(host="localhost", port=8080)
60+
61+
62+
def test_run_local_server_raises_connectionerror(monkeypatch, module_under_test):
63+
def mock_find_open_port():
64+
return None
65+
66+
monkeypatch.setattr(module_under_test, "find_open_port", mock_find_open_port)
67+
mock_flow = mock.create_autospec(
68+
google_auth_oauthlib.flow.InstalledAppFlow, instance=True
69+
)
70+
71+
with pytest.raises(exceptions.PyDataConnectionError):
72+
module_under_test.run_local_server(mock_flow)
73+
74+
mock_flow.run_local_server.assert_not_called()

0 commit comments

Comments
 (0)