Skip to content

Commit af91424

Browse files
committed
Basic coverage of migwsgi and addition of WSGI test suppport code.
This PR does the principle things required to allow exercising the central component responsible for glueing named MiG "functionality" files to WSGI. Includde is a new wsgisupp.py file that implements support code allowing exercising WSGI handlers in a readable way.
1 parent 4055f1d commit af91424

File tree

6 files changed

+409
-6
lines changed

6 files changed

+409
-6
lines changed

local-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ autopep8;python_version >= "3"
77
# NOTE: paramiko-3.0.0 dropped python2 and python3.6 support
88
paramiko;python_version >= "3.7"
99
paramiko<3;python_version < "3.7"
10+
werkzeug

mig/shared/compat.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@
4646
_TYPE_UNICODE = type(u"")
4747

4848

49+
if PY2:
50+
class SimpleNamespace(dict):
51+
"""Bare minimum SimpleNamespace for Python 2."""
52+
53+
def __getattribute__(self, name):
54+
if name == '__dict__':
55+
return dict(**self)
56+
57+
return self[name]
58+
else:
59+
from types import SimpleNamespace
60+
61+
4962
def _is_unicode(val):
5063
"""Return boolean indicating if the value is a unicode string.
5164

mig/wsgi-bin/migwsgi.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def object_type_info(object_type):
5353

5454

5555
def stub(configuration, client_id, import_path, backend, user_arguments_dict,
56-
environ):
56+
environ, _import_module):
5757
"""Run backend on behalf of client_id with supplied user_arguments_dict.
5858
I.e. import main from import_path and execute it with supplied arguments.
5959
"""
@@ -91,7 +91,7 @@ def stub(configuration, client_id, import_path, backend, user_arguments_dict,
9191

9292
# _logger.debug("import main from %r" % import_path)
9393
# NOTE: dynamic module loading to find corresponding main function
94-
module_handle = importlib.import_module(import_path)
94+
module_handle = _import_module(import_path)
9595
main = module_handle.main
9696
except Exception as err:
9797
_logger.error("%s could not import %r (%s): %s" %
@@ -187,7 +187,8 @@ def _flush_wsgi_errors():
187187
environ['wsgi.errors'].close()
188188

189189

190-
def application(environ, start_response):
190+
def application(environ, start_response, configuration=None,
191+
_import_module=importlib.import_module, _set_os_environ=True):
191192
"""MiG app called automatically by WSGI.
192193
193194
*environ* is a dictionary populated by the server with CGI-like variables
@@ -238,7 +239,8 @@ def application(environ, start_response):
238239
os_env_value))
239240

240241
# Assign updated environ to LOCAL os.environ for the rest of this session
241-
os.environ = environ
242+
if _set_os_environ:
243+
os.environ(environ)
242244

243245
# NOTE: enable to debug runtime environment to apache error log
244246
# print("DEBUG: python %s" %
@@ -250,7 +252,9 @@ def application(environ, start_response):
250252
if sys.version_info[0] < 3:
251253
sys.stdout = sys.stderr
252254

253-
configuration = get_configuration_object()
255+
if configuration is None:
256+
configuration = get_configuration_object()
257+
254258
_logger = configuration.logger
255259

256260
# NOTE: replace default wsgi errors to apache error log with our own logs
@@ -321,7 +325,8 @@ def application(environ, start_response):
321325
# _logger.debug("wsgi handling script: %s" % script_name)
322326
(output_objs, ret_val) = stub(configuration, client_id,
323327
module_path, backend,
324-
user_arguments_dict, environ)
328+
user_arguments_dict, environ,
329+
_import_module=_import_module)
325330
else:
326331
_logger.warning("wsgi handling refused script:%s" % script_name)
327332
(output_objs, ret_val) = reject_main(client_id,

tests/support/htmlsupp.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/python
2+
# -*- coding: utf-8 -*-
3+
#
4+
# --- BEGIN_HEADER ---
5+
#
6+
# htmlsupp - test support library for HTML
7+
# Copyright (C) 2003-2024 The MiG Project by the Science HPC Center at UCPH
8+
#
9+
# This file is part of MiG.
10+
#
11+
# MiG is free software: you can redistribute it and/or modify
12+
# it under the terms of the GNU General Public License as published by
13+
# the Free Software Foundation; either version 2 of the License, or
14+
# (at your option) any later version.
15+
#
16+
# MiG is distributed in the hope that it will be useful,
17+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
# GNU General Public License for more details.
20+
#
21+
# You should have received a copy of the GNU General Public License
22+
# along with this program; if not, write to the Free Software
23+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
24+
#
25+
# -- END_HEADER ---
26+
#
27+
28+
"""Test support library for HTML."""
29+
30+
31+
class HtmlAssertMixin:
32+
"""Custom assertions for HTML containing strings."""
33+
34+
def assertHtmlElement(self, value, tag_name):
35+
"""Check that an occurrence of the specifid tag within an HTML input
36+
string can be found. Returns the textual content of the first match.
37+
"""
38+
39+
self.assertIsValidHtmlDocument(value)
40+
41+
# TODO: this is a definitively stop-gap way of finding a tag within the HTML
42+
# and is used purely to keep this initial change to a reasonable size.
43+
44+
tag_open = ''.join(['<', tag_name, '>'])
45+
tag_open_index = value.index(tag_open)
46+
tag_open_index_after = tag_open_index + len(tag_open)
47+
48+
tag_close = ''.join(['</', tag_name, '>'])
49+
tag_close_index = value.index(tag_close, tag_open_index_after)
50+
51+
return value[tag_open_index_after:tag_close_index]
52+
53+
def assertHtmlElementTextContent(self, value, tag_name, expected_text, trim_newlines=True):
54+
"""Check there is an occurrence of a tag within an HTML input string
55+
and check the text it encloses equals exactly the expecatation.
56+
"""
57+
58+
self.assertIsValidHtmlDocument(value)
59+
60+
# TODO: this is a definitively stop-gap way of finding a tag within the HTML
61+
# and is used purely to keep this initial change to a reasonable size.
62+
63+
actual_text = self.assertHtmlElement(value, tag_name)
64+
if trim_newlines:
65+
actual_text = actual_text.strip('\n')
66+
self.assertEqual(actual_text, expected_text)
67+
68+
def assertIsValidHtmlDocument(self, value):
69+
"""Check that the input string contains a valid HTML document.
70+
"""
71+
72+
assert isinstance(value, type(u"")), "input string was not utf8"
73+
74+
error = None
75+
try:
76+
has_doctype = value.startswith("<!DOCTYPE html")
77+
assert has_doctype, "no valid document opener"
78+
end_html_tag_idx = value.rfind('</html>')
79+
maybe_document_end = value[end_html_tag_idx:].rstrip()
80+
assert maybe_document_end == '</html>', "no valid document closer"
81+
except Exception as exc:
82+
error = exc
83+
if error:
84+
raise AssertionError("failed to verify input string as HTML: %s", str(error))

tests/support/wsgisupp.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# --- BEGIN_HEADER ---
4+
#
5+
# htmlsupp - test support library for WSGI
6+
# Copyright (C) 2003-2024 The MiG Project by the Science HPC Center at UCPH
7+
#
8+
# This file is part of MiG.
9+
#
10+
# MiG is free software: you can redistribute it and/or modify
11+
# it under the terms of the GNU General Public License as published by
12+
# the Free Software Foundation; either version 2 of the License, or
13+
# (at your option) any later version.
14+
#
15+
# MiG is distributed in the hope that it will be useful,
16+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
17+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+
# GNU General Public License for more details.
19+
#
20+
# You should have received a copy of the GNU General Public License
21+
# along with this program; if not, write to the Free Software
22+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
23+
#
24+
# -- END_HEADER ---
25+
#
26+
27+
from collections import namedtuple
28+
import codecs
29+
from io import BytesIO
30+
from werkzeug.datastructures import MultiDict
31+
32+
from tests.support._env import PY2
33+
34+
if PY2:
35+
from urllib import urlencode
36+
from urlparse import urlparse
37+
else:
38+
from urllib.parse import urlencode, urlparse
39+
40+
"""Test support library for WSGI."""
41+
42+
43+
_PreparedWsgi = namedtuple('_PreparedWsgi', ['environ', 'start_response'])
44+
45+
46+
class FakeWsgiStartResponse:
47+
"""Glue object that conforms to the same interface as the start_response()
48+
in the WSGI specs but records the calls to it such that they can be
49+
inspected and, for our purposes, asserted against."""
50+
51+
def __init__(self):
52+
self.calls = []
53+
54+
def __call__(self, status, headers, exc=None):
55+
self.calls.append((status, headers, exc))
56+
57+
58+
def create_wsgi_environ(configuration, wsgi_url, method='GET', query=None, headers=None, form=None):
59+
parsed_url = urlparse(wsgi_url)
60+
61+
if query:
62+
method = 'GET'
63+
64+
request_query = urlencode(query)
65+
wsgi_input = ()
66+
elif form:
67+
method = 'POST'
68+
request_query = ''
69+
70+
body = urlencode(MultiDict(form)).encode('ascii')
71+
72+
headers = headers or {}
73+
if not 'Content-Type' in headers:
74+
headers['Content-Type'] = 'application/x-www-form-urlencoded'
75+
76+
headers['Content-Length'] = str(len(body))
77+
wsgi_input = BytesIO(body)
78+
else:
79+
request_query = parsed_url.query
80+
wsgi_input = ()
81+
82+
class _errors:
83+
def close():
84+
pass
85+
86+
environ = {}
87+
environ['wsgi.errors'] = _errors()
88+
environ['wsgi.input'] = wsgi_input
89+
environ['wsgi.url_scheme'] = parsed_url.scheme
90+
environ['wsgi.version'] = (1, 0)
91+
environ['MIG_CONF'] = configuration.config_file
92+
environ['HTTP_HOST'] = parsed_url.netloc
93+
environ['PATH_INFO'] = parsed_url.path
94+
environ['QUERY_STRING'] = request_query
95+
environ['REQUEST_METHOD'] = method
96+
environ['SCRIPT_URI'] = ''.join(('http://', environ['HTTP_HOST'], environ['PATH_INFO']))
97+
98+
if headers:
99+
for k, v in headers.items():
100+
header_key = k.replace('-', '_').upper()
101+
if header_key.startswith('CONTENT'):
102+
# Content-* headers must not be prefixed in WSGI
103+
pass
104+
else:
105+
header_key = "HTTP_%s" % (header_key),
106+
environ[header_key] = v
107+
108+
return environ
109+
110+
111+
def create_wsgi_start_response():
112+
return FakeWsgiStartResponse()
113+
114+
115+
def prepare_wsgi(configuration, url, **kwargs):
116+
return _PreparedWsgi(
117+
create_wsgi_environ(configuration, url, **kwargs),
118+
create_wsgi_start_response()
119+
)
120+
121+
122+
def _trigger_and_unpack_result(wsgi_result):
123+
chunks = list(wsgi_result)
124+
assert len(chunks) > 0, "invocation returned no output"
125+
complete_value = b''.join(chunks)
126+
decoded_value = codecs.decode(complete_value, 'utf8')
127+
return decoded_value
128+
129+
130+
class WsgiAssertMixin:
131+
"""Custom assertions for verifying server code executed under test."""
132+
133+
def assertWsgiResponse(self, wsgi_result, fake_wsgi, expected_status_code):
134+
assert isinstance(fake_wsgi, _PreparedWsgi)
135+
136+
content = _trigger_and_unpack_result(wsgi_result)
137+
138+
def called_once(fake):
139+
assert hasattr(fake, 'calls')
140+
return len(fake.calls) == 1
141+
142+
fake_start_response = fake_wsgi.start_response
143+
144+
try:
145+
self.assertTrue(called_once(fake_start_response))
146+
except AssertionError:
147+
if len(fake.calls) == 0:
148+
raise AssertionError("WSGI handler did not respond")
149+
else:
150+
raise AssertionError("WSGI handler responded more than once")
151+
152+
wsgi_call = fake_start_response.calls[0]
153+
154+
# check for expected HTTP status code
155+
wsgi_status = wsgi_call[0]
156+
actual_status_code = int(wsgi_status[0:3])
157+
self.assertEqual(actual_status_code, expected_status_code)
158+
159+
headers = dict(wsgi_call[1])
160+
161+
return content, headers

0 commit comments

Comments
 (0)