Skip to content

Commit d11e2ba

Browse files
Merge pull request #1089 from xcube-dev/forman-x-fix_importing_server_side_contribs
Allow server-side panel code on S3
2 parents f199c89 + b19cca8 commit d11e2ba

File tree

11 files changed

+250
-82
lines changed

11 files changed

+250
-82
lines changed

CHANGES.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@
3838
respectively. The functionality is provided by the
3939
`https://github.com/bcdev/chartlets` Python library.
4040
A working example can be found in `examples/serve/panels-demo`.
41-
41+
42+
* The xcube test helper module `test.s3test` has been enhanced to support
43+
testing the experimental _server-side panels_ described above:
44+
- added new decorator `@s3_test()` for individual tests with `timeout` arg;
45+
- added new context manager `s3_test_server()` with `timeout` arg to be used
46+
within tests function bodies;
47+
- `S3Test`, `@s3_test()`, and `s3_test_server()` now restore environment
48+
variables modified for the Moto S3 test server.
4249

4350
## Changes in 1.7.1
4451

test/s3test.py

+146-33
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# Permissions are hereby granted under the terms of the MIT License:
33
# https://opensource.org/licenses/MIT.
44

5+
import contextlib
6+
import functools
57
import os
68
import subprocess
79
import sys
@@ -10,6 +12,7 @@
1012
import urllib
1113
import urllib.error
1214
import urllib.request
15+
from typing import Callable
1316

1417
import moto.server
1518

@@ -18,48 +21,158 @@
1821
MOTOSERVER_PATH = moto.server.__file__
1922
MOTOSERVER_ARGS = [sys.executable, MOTOSERVER_PATH]
2023

24+
# Mocked AWS environment variables for Moto.
25+
MOTOSERVER_ENV = {
26+
"AWS_ACCESS_KEY_ID": "testing",
27+
"AWS_SECRET_ACCESS_KEY": "testing",
28+
"AWS_SECURITY_TOKEN": "testing",
29+
"AWS_SESSION_TOKEN": "testing",
30+
"AWS_DEFAULT_REGION": "us-east-1",
31+
"AWS_ENDPOINT_URL_S3": MOTO_SERVER_ENDPOINT_URL,
32+
}
33+
2134

2235
class S3Test(unittest.TestCase):
23-
_moto_server = None
36+
_stop_moto_server = None
2437

2538
@classmethod
2639
def setUpClass(cls) -> None:
2740
super().setUpClass()
28-
29-
"""Mocked AWS Credentials for moto."""
30-
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
31-
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
32-
os.environ["AWS_SECURITY_TOKEN"] = "testing"
33-
os.environ["AWS_SESSION_TOKEN"] = "testing"
34-
os.environ["AWS_DEFAULT_REGION"] = "us-east-1"
35-
36-
cls._moto_server = subprocess.Popen(MOTOSERVER_ARGS)
37-
t0 = time.perf_counter()
38-
running = False
39-
while not running and time.perf_counter() - t0 < 60.0:
40-
try:
41-
with urllib.request.urlopen(MOTO_SERVER_ENDPOINT_URL, timeout=1.0):
42-
running = True
43-
print(
44-
f"moto_server started after {round(1000 * (time.perf_counter() - t0))} ms"
45-
)
46-
47-
except urllib.error.URLError as e:
48-
pass
49-
if not running:
50-
raise Exception(
51-
f"Failed to start moto server after {round(1000 * (time.perf_counter() - t0))} ms"
52-
)
41+
cls._stop_moto_server = _start_moto_server()
5342

5443
def setUp(self) -> None:
55-
# see https://github.com/spulec/moto/issues/2288
56-
urllib.request.urlopen(
57-
urllib.request.Request(
58-
MOTO_SERVER_ENDPOINT_URL + "/moto-api/reset", method="POST"
59-
)
60-
)
44+
_reset_moto_server()
6145

6246
@classmethod
6347
def tearDownClass(cls) -> None:
64-
cls._moto_server.kill()
48+
cls._stop_moto_server()
6549
super().tearDownClass()
50+
51+
52+
def s3_test(timeout: float = 60.0, ping_timeout: float = 1.0):
53+
"""A decorator to run individual tests with a Moto S3 server.
54+
55+
The decorated tests receives the Moto Server's endpoint URL.
56+
57+
Args:
58+
timeout:
59+
Total time in seconds it may take to start the server.
60+
Raises if time is exceeded.
61+
ping_timeout:
62+
Timeout for individual ping requests trying to access the
63+
started moto server.
64+
The total number of pings is ``int(timeout / ping_timeout)``.
65+
"""
66+
67+
def decorator(test_func):
68+
@functools.wraps(test_func)
69+
def wrapper(*args, **kwargs):
70+
stop_moto_server = _start_moto_server(timeout, ping_timeout)
71+
try:
72+
return test_func(*args, endpoint_url=MOTO_SERVER_ENDPOINT_URL, **kwargs)
73+
finally:
74+
stop_moto_server()
75+
76+
return wrapper
77+
78+
return decorator
79+
80+
81+
@contextlib.contextmanager
82+
def s3_test_server(timeout: float = 60.0, ping_timeout: float = 1.0) -> str:
83+
"""A context manager that starts a Moto S3 server for testing.
84+
85+
Args:
86+
timeout:
87+
Total time in seconds it may take to start the server.
88+
Raises if time is exceeded.
89+
ping_timeout:
90+
Timeout for individual ping requests trying to access the
91+
started moto server.
92+
The total number of pings is ``int(timeout / ping_timeout)``.
93+
94+
Returns:
95+
The server's endpoint URL
96+
97+
Raises:
98+
Exception: If the server could not be started or
99+
if the service is not available after after
100+
*timeout* seconds.
101+
"""
102+
stop_moto_server = _start_moto_server(timeout=timeout, ping_timeout=ping_timeout)
103+
try:
104+
_reset_moto_server()
105+
yield MOTO_SERVER_ENDPOINT_URL
106+
finally:
107+
stop_moto_server()
108+
109+
110+
def _start_moto_server(
111+
timeout: float = 60.0, ping_timeout: float = 1.0
112+
) -> Callable[[], None]:
113+
"""Start a Moto S3 server for testing.
114+
115+
Args:
116+
timeout:
117+
Total time in seconds it may take to start the server.
118+
Raises if time is exceeded.
119+
ping_timeout:
120+
Timeout for individual ping requests trying to access the
121+
started moto server.
122+
The total number of pings is ``int(timeout / ping_timeout)``.
123+
124+
Returns:
125+
A function that stops the server and restores the environment.
126+
127+
Raises:
128+
Exception: If the server could not be started or
129+
if the service is not available after
130+
*timeout* seconds.
131+
"""
132+
133+
prev_env: dict[str, str | None] = {
134+
k: os.environ.get(k) for k, v in MOTOSERVER_ENV.items()
135+
}
136+
os.environ.update(MOTOSERVER_ENV)
137+
138+
moto_server = subprocess.Popen(MOTOSERVER_ARGS)
139+
t0 = time.perf_counter()
140+
running = False
141+
while not running and time.perf_counter() - t0 < timeout:
142+
try:
143+
with urllib.request.urlopen(MOTO_SERVER_ENDPOINT_URL, timeout=ping_timeout):
144+
running = True
145+
print(
146+
f"moto_server started after"
147+
f" {round(1000 * (time.perf_counter() - t0))} ms"
148+
)
149+
150+
except urllib.error.URLError:
151+
pass
152+
if not running:
153+
raise Exception(
154+
f"Failed to start moto server"
155+
f" after {round(1000 * (time.perf_counter() - t0))} ms"
156+
)
157+
158+
def stop_moto_server():
159+
try:
160+
moto_server.kill()
161+
finally:
162+
# Restore environment variables
163+
for k, v in prev_env.items():
164+
if v is None:
165+
del os.environ[k]
166+
else:
167+
os.environ[k] = v
168+
169+
return stop_moto_server
170+
171+
172+
def _reset_moto_server():
173+
# see https://github.com/spulec/moto/issues/2288
174+
urllib.request.urlopen(
175+
urllib.request.Request(
176+
MOTO_SERVER_ENDPOINT_URL + "/moto-api/reset", method="POST"
177+
)
178+
)

test/webapi/helpers.py

+22-4
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,27 @@ def get_server(
5252
Raise:
5353
AssertionError: if API context object can not be determined
5454
"""
55+
server_config = get_server_config(server_config)
56+
framework = framework or MockFramework()
57+
extension_registry = extension_registry or get_extension_registry()
58+
return Server(framework, server_config, extension_registry=extension_registry)
59+
60+
61+
def get_server_config(
62+
server_config: Optional[Union[str, Mapping[str, Any]]] = None
63+
) -> dict[str, Any]:
64+
"""Get a server configuration for testing.
65+
66+
The given ``server_config`` is normalized into a dictionary.
67+
If ``server_config`` is a path, the configuration is loaded and its
68+
``base_dir`` key is set to the parent directory of the configuration file.
69+
70+
Args:
71+
server_config: Optional path or directory. Defaults to "config.yml".
72+
73+
Returns:
74+
A configuration dictionary.
75+
"""
5576
server_config = server_config or "config.yml"
5677
if isinstance(server_config, str):
5778
config_path = server_config
@@ -67,10 +88,7 @@ def get_server(
6788
server_config["base_dir"] = base_dir
6889
else:
6990
assert isinstance(server_config, collections.abc.Mapping)
70-
71-
framework = framework or MockFramework()
72-
extension_registry = extension_registry or get_extension_registry()
73-
return Server(framework, server_config, extension_registry=extension_registry)
91+
return server_config
7492

7593

7694
def get_api_ctx(

test/webapi/res/config-panels.yml

+2-17
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
Viewer:
22
Augmentation:
3-
Path: ""
3+
Path: "viewer/extensions"
44
Extensions:
5-
- viewer_panels.ext
5+
- my_ext.ext
66

77
DataStores:
88
- Identifier: test
@@ -16,18 +16,3 @@ DataStores:
1616
ServiceProvider:
1717
ProviderName: "Brockmann Consult GmbH"
1818
ProviderSite: "https://www.brockmann-consult.de"
19-
ServiceContact:
20-
IndividualName: "Norman Fomferra"
21-
PositionName: "Senior Software Engineer"
22-
ContactInfo:
23-
Phone:
24-
Voice: "+49 4152 889 303"
25-
Facsimile: "+49 4152 889 330"
26-
Address:
27-
DeliveryPoint: "HZG / GITZ"
28-
City: "Geesthacht"
29-
AdministrativeArea: "Herzogtum Lauenburg"
30-
PostalCode: "21502"
31-
Country: "Germany"
32-
ElectronicMailAddress: "[email protected]"
33-

test/webapi/viewer/test_context.py

+34-11
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from typing import Optional, Union, Any
88
from collections.abc import Mapping
99

10+
import fsspec
11+
from chartlets import ExtensionContext
12+
13+
from test.s3test import s3_test
1014
from test.webapi.helpers import get_api_ctx
15+
from test.webapi.helpers import get_server_config
1116
from xcube.webapi.viewer.context import ViewerContext
1217
from xcube.webapi.viewer.contrib import Panel
1318

@@ -46,15 +51,33 @@ def test_config_path_ok(self):
4651
ctx2 = get_viewer_ctx(server_config=config)
4752
self.assertEqual(config_path, ctx2.config_path)
4853

49-
def test_panels(self):
54+
def test_panels_local(self):
5055
ctx = get_viewer_ctx("config-panels.yml")
51-
self.assertIsNotNone(ctx.ext_ctx)
52-
self.assertIsInstance(ctx.ext_ctx.extensions, list)
53-
self.assertEqual(1, len(ctx.ext_ctx.extensions))
54-
self.assertIsInstance(ctx.ext_ctx.contributions, dict)
55-
self.assertEqual(1, len(ctx.ext_ctx.contributions))
56-
self.assertIn("panels", ctx.ext_ctx.contributions)
57-
self.assertIsInstance(ctx.ext_ctx.contributions["panels"], list)
58-
self.assertEqual(2, len(ctx.ext_ctx.contributions["panels"]))
59-
self.assertIsInstance(ctx.ext_ctx.contributions["panels"][0], Panel)
60-
self.assertIsInstance(ctx.ext_ctx.contributions["panels"][1], Panel)
56+
self.assert_extensions_ok(ctx.ext_ctx)
57+
58+
@s3_test()
59+
def test_panels_s3(self, endpoint_url: str):
60+
server_config = get_server_config("config-panels.yml")
61+
bucket_name = "xcube-testing"
62+
base_dir = server_config["base_dir"]
63+
ext_path = server_config["Viewer"]["Augmentation"]["Path"]
64+
# Copy test extension to S3 bucket
65+
s3_fs: fsspec.AbstractFileSystem = fsspec.filesystem(
66+
"s3", endpoint_url=endpoint_url
67+
)
68+
s3_fs.put(f"{base_dir}/{ext_path}", f"{bucket_name}/{ext_path}", recursive=True)
69+
server_config["base_dir"] = f"s3://{bucket_name}"
70+
ctx = get_viewer_ctx(server_config)
71+
self.assert_extensions_ok(ctx.ext_ctx)
72+
73+
def assert_extensions_ok(self, ext_ctx: ExtensionContext | None):
74+
self.assertIsNotNone(ext_ctx)
75+
self.assertIsInstance(ext_ctx.extensions, list)
76+
self.assertEqual(1, len(ext_ctx.extensions))
77+
self.assertIsInstance(ext_ctx.contributions, dict)
78+
self.assertEqual(1, len(ext_ctx.contributions))
79+
self.assertIn("panels", ext_ctx.contributions)
80+
self.assertIsInstance(ext_ctx.contributions["panels"], list)
81+
self.assertEqual(2, len(ext_ctx.contributions["panels"]))
82+
self.assertIsInstance(ext_ctx.contributions["panels"][0], Panel)
83+
self.assertIsInstance(ext_ctx.contributions["panels"][1], Panel)

0 commit comments

Comments
 (0)