-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
7 changed files
with
192 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |