Skip to content

Commit 58c532c

Browse files
committed
Add ephemeral_flask_server testing utility
e.g. to simplify testing of pystac + pystac_client based logic (which uses a mix of urllib and requests) related to work under Open-EO/openeo-geopyspark-driver#1307
1 parent ae90ddc commit 58c532c

File tree

4 files changed

+117
-3
lines changed

4 files changed

+117
-3
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ and start a new "In Progress" section above it.
1919

2020
<!-- start-of-changelog -->
2121

22-
## In progress: 0.136.0
22+
## In progress: 0.137.0
23+
24+
- Add `ephemeral_flask_server` testing utility (`openeo_driver.testing`) for request mocking based on a Flask app. Allows to do request/response mocking independently from actual request library (`requests`, `urllib`, `urllib3`, etc.) through a well-documented API (Flask).
25+
26+
27+
## 0.136.0
2328

2429
- Start supporting custom `UdfRuntimes` implementation in `OpenEoBackendImplementation` ([#415](https://github.com/Open-EO/openeo-python-driver/issues/415))
2530
- Process graph parsing (dry-run) for very large graphs got faster. ([#426](https://github.com/Open-EO/openeo-python-driver/issues/426))

openeo_driver/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.136.0a4"
1+
__version__ = "0.137.0a1"

openeo_driver/testing.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,23 @@
88
import logging
99
import math
1010
import multiprocessing
11+
import queue
1112
import re
13+
import threading
14+
import typing
1215
import urllib.request
1316
from pathlib import Path
1417
from typing import Any, Callable, Collection, Dict, Optional, Pattern, Tuple, Union
1518
from unittest import mock
1619

1720
import attrs
21+
import flask
1822
import openeo
1923
import openeo.processes
2024
import pytest
2125
import shapely.geometry.base
2226
import shapely.wkt
27+
import werkzeug.serving
2328
from flask import Response
2429
from flask.testing import FlaskClient
2530
from openeo.utils.version import ComparableVersion
@@ -648,7 +653,7 @@ def test_my_function(caplog, monkeypatch)
648653

649654

650655
@contextlib.contextmanager
651-
def ephemeral_fileserver(path: Union[Path, str], host: str = "localhost", port: int = 0) -> str:
656+
def ephemeral_fileserver(path: Union[Path, str], host: str = "localhost", port: int = 0) -> typing.Iterator[str]:
652657
"""
653658
Context manager to run a short-lived (static) file HTTP server, serving files from a given local test data folder.
654659
@@ -696,6 +701,63 @@ def run(queue: multiprocessing.Queue):
696701
server_process.close()
697702

698703

704+
@contextlib.contextmanager
705+
def ephemeral_flask_server(app: flask.Flask) -> typing.Iterator[str]:
706+
"""
707+
Context manager to run a Flask app in a separate thread for testing purposes.
708+
709+
Usage example:
710+
711+
def test_hello():
712+
713+
app = flask.Flask(__name__)
714+
@app.route("/hello")
715+
def hello():
716+
return "Hello, World!"
717+
718+
with ephemeral_flask_server(app) as root_url:
719+
resp = requests.get(f"{root_url}/hello")
720+
assert resp.status_code == 200
721+
assert resp.text == "Hello, World!"
722+
"""
723+
724+
class FlaskServerThread(threading.Thread):
725+
def __init__(self, app: flask.Flask, root_url_queue: queue.Queue, *, host: str = "127.0.0.1", port: int = 0):
726+
super().__init__()
727+
self.daemon = True
728+
729+
self._server = werkzeug.serving.make_server(host, port, app)
730+
self._ctx = app.app_context()
731+
self._ctx.push()
732+
self._root_url_queue = root_url_queue
733+
734+
def run(self):
735+
root_url = f"http://{self._server.server_address[0]}:{self._server.server_port}"
736+
self._root_url_queue.put(root_url)
737+
_log.debug(f"FlaskServerThread: start serving at {root_url=}")
738+
self._server.serve_forever()
739+
740+
def shutdown(self):
741+
self._ctx.pop()
742+
self._server.shutdown()
743+
_log.debug("FlaskServerThread: is shut down")
744+
745+
# Use queue to communicate the root URL back to the main thread
746+
root_url_queue = queue.Queue()
747+
_log.debug(f"ephemeral_flask_server: starting server thread with {app=}")
748+
server_thread = FlaskServerThread(app=app, root_url_queue=root_url_queue)
749+
750+
server_thread.start()
751+
root_url = root_url_queue.get(timeout=2)
752+
_log.debug(f"ephemeral_flask_server: {server_thread=} is serving at {root_url=}")
753+
754+
try:
755+
yield root_url
756+
finally:
757+
_log.debug(f"ephemeral_flask_server: shutting down {server_thread=}")
758+
server_thread.shutdown()
759+
760+
699761
def config_overrides(config_getter: ConfigGetter = _backend_config_getter, **kwargs):
700762
"""
701763
*Only to be used in unit tests*

tests/test_testing.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import numpy
1111
import pytest
1212
import requests
13+
import requests.exceptions
1314

1415
from openeo_driver.config import get_backend_config
1516
from openeo_driver.testing import (
@@ -25,6 +26,7 @@
2526
caplog_with_custom_formatter,
2627
config_overrides,
2728
ephemeral_fileserver,
29+
ephemeral_flask_server,
2830
preprocess_check_and_replace,
2931
)
3032

@@ -258,6 +260,51 @@ def test_ephemeral_fileserver_failure(tmp_path, caplog):
258260
assert "terminated with exitcode" in caplog.text
259261

260262

263+
def test_ephemeral_flask_server_basic():
264+
app = flask.Flask(__name__)
265+
266+
@app.route("/hello")
267+
def hello():
268+
return "Hello, World!"
269+
270+
with ephemeral_flask_server(app) as root_url:
271+
resp = requests.get(f"{root_url}/hello")
272+
assert resp.status_code == 200
273+
assert resp.text == "Hello, World!"
274+
275+
276+
def test_ephemeral_flask_server_shutdown():
277+
app = flask.Flask(__name__)
278+
279+
@app.route("/hello")
280+
def hello():
281+
return "Hello, World!"
282+
283+
with ephemeral_flask_server(app) as root_url:
284+
resp = requests.get(f"{root_url}/hello")
285+
assert resp.text == "Hello, World!"
286+
287+
with pytest.raises(requests.exceptions.ConnectionError, match="Connection refused"):
288+
requests.get(f"{root_url}/hello")
289+
290+
291+
def test_ephemeral_flask_server_dynamic():
292+
app = flask.Flask(__name__)
293+
294+
@app.route("/hello/<name>")
295+
def hello(name):
296+
return flask.jsonify({"hello": name})
297+
298+
with ephemeral_flask_server(app) as root_url:
299+
resp = requests.get(f"{root_url}/hello/alice")
300+
assert resp.status_code == 200
301+
assert resp.json() == {"hello": "alice"}
302+
303+
resp = requests.get(f"{root_url}/hello/bob")
304+
assert resp.status_code == 200
305+
assert resp.json() == {"hello": "bob"}
306+
307+
261308
def test_approxify_basic():
262309
assert {"a": 1.2345} == approxify({"a": 1.2345})
263310
assert {"a": 1.2345} != approxify({"a": 1.23466666})

0 commit comments

Comments
 (0)