Skip to content

Commit 584464f

Browse files
committed
Basic coverage of migwsgi.
This PR does the principle things required to allow exercising the central component responsible for glueing named MiG "functionality" files to WSGI and have the result execute to completion under Python 3. Included is a small tactical change to allow the structural force recursive functions to iterate tuples preserving their type such that subsequence output behaves correctly under Py3.
1 parent 1638670 commit 584464f

File tree

6 files changed

+267
-13
lines changed

6 files changed

+267
-13
lines changed

mig/shared/base.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -542,8 +542,9 @@ def force_utf8_rec(input_obj, highlight=''):
542542
if isinstance(input_obj, dict):
543543
return {force_utf8_rec(i, highlight): force_utf8_rec(j, highlight) for (i, j) in
544544
input_obj.items()}
545-
elif isinstance(input_obj, list):
546-
return [force_utf8_rec(i, highlight) for i in input_obj]
545+
elif isinstance(input_obj, (list, tuple)):
546+
thetype = type(input_obj)
547+
return thetype(force_utf8_rec(i, highlight) for i in input_obj)
547548
elif is_unicode(input_obj):
548549
return force_utf8(input_obj, highlight)
549550
else:
@@ -570,8 +571,9 @@ def force_unicode_rec(input_obj, highlight=''):
570571
if isinstance(input_obj, dict):
571572
return {force_unicode_rec(i, highlight): force_unicode_rec(j, highlight) for (i, j) in
572573
input_obj.items()}
573-
elif isinstance(input_obj, list):
574-
return [force_unicode_rec(i, highlight) for i in input_obj]
574+
elif isinstance(input_obj, (list, tuple)):
575+
thetype = type(input_obj)
576+
return thetype(force_utf8_rec(i, highlight) for i in input_obj)
575577
elif not is_unicode(input_obj):
576578
return force_unicode(input_obj, highlight)
577579
else:

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: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from mig.shared import returnvalues
3535
from mig.shared.bailout import bailout_helper, crash_helper, compact_string
3636
from mig.shared.base import requested_backend, allow_script, \
37-
is_default_str_coding, force_default_str_coding_rec
37+
is_default_str_coding, force_default_str_coding_rec, force_utf8
3838
from mig.shared.defaults import download_block_size, default_fs_coding
3939
from mig.shared.conf import get_configuration_object
4040
from mig.shared.objecttypes import get_object_type_info
@@ -43,14 +43,20 @@
4343
from mig.shared.scriptinput import fieldstorage_to_dict
4444

4545

46+
def _import_backend(backend):
47+
import_path = 'mig.shared.functionality.%s' % backend
48+
module_handle = importlib.import_module(import_path)
49+
return module_handle.main
50+
51+
4652
def object_type_info(object_type):
4753
"""Lookup object type"""
4854

4955
return get_object_type_info(object_type)
5056

5157

5258
def stub(configuration, client_id, import_path, backend, user_arguments_dict,
53-
environ):
59+
environ, _import_module):
5460
"""Run backend on behalf of client_id with supplied user_arguments_dict.
5561
I.e. import main from import_path and execute it with supplied arguments.
5662
"""
@@ -88,7 +94,7 @@ def stub(configuration, client_id, import_path, backend, user_arguments_dict,
8894

8995
# _logger.debug("import main from %r" % import_path)
9096
# NOTE: dynamic module loading to find corresponding main function
91-
module_handle = importlib.import_module(import_path)
97+
module_handle = _import_module(import_path)
9298
main = module_handle.main
9399
except Exception as err:
94100
_logger.error("%s could not import %r (%s): %s" %
@@ -184,7 +190,8 @@ def _flush_wsgi_errors():
184190
environ['wsgi.errors'].close()
185191

186192

187-
def application(environ, start_response):
193+
def application(environ, start_response, configuration=None,
194+
_import_module=importlib.import_module, _set_os_environ=True):
188195
"""MiG app called automatically by WSGI.
189196
190197
*environ* is a dictionary populated by the server with CGI-like variables
@@ -235,14 +242,17 @@ def application(environ, start_response):
235242
os_env_value))
236243

237244
# Assign updated environ to LOCAL os.environ for the rest of this session
238-
os.environ = environ
245+
if _set_os_environ:
246+
os.environ(environ)
239247

240248
# NOTE: redirect stdout to stderr in python 2 only. It breaks logger in 3
241249
# and stdout redirection apparently is already handled there.
242250
if sys.version_info[0] < 3:
243251
sys.stdout = sys.stderr
244252

245-
configuration = get_configuration_object()
253+
if configuration is None:
254+
configuration = get_configuration_object(_config_file, _skip_log)
255+
246256
_logger = configuration.logger
247257

248258
# NOTE: replace default wsgi errors to apache error log with our own logs
@@ -313,7 +323,8 @@ def application(environ, start_response):
313323
# _logger.debug("wsgi handling script: %s" % script_name)
314324
(output_objs, ret_val) = stub(configuration, client_id,
315325
module_path, backend,
316-
user_arguments_dict, environ)
326+
user_arguments_dict, environ,
327+
_import_module=_import_module)
317328
else:
318329
_logger.warning("wsgi handling refused script:%s" % script_name)
319330
(output_objs, ret_val) = reject_main(client_id,
@@ -410,12 +421,12 @@ def application(environ, start_response):
410421
_logger.info("WSGI %s yielding %d output parts (%db)" %
411422
(backend, chunk_parts, content_length))
412423
# _logger.debug("send chunked %r response to client" % backend)
413-
for i in xrange(chunk_parts):
424+
for i in list(range(chunk_parts)):
414425
# _logger.debug("WSGI %s yielding part %d / %d output parts" %
415426
# (backend, i+1, chunk_parts))
416427
# end index may be after end of content - but no problem
417428
part = output[i*download_block_size:(i+1)*download_block_size]
418-
yield part
429+
yield force_utf8(part)
419430
if chunk_parts > 1:
420431
_logger.info("WSGI %s finished yielding all %d output parts" %
421432
(backend, chunk_parts))

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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ def create_wsgi_environ(configuration, wsgi_url, method='GET', query=None, heade
7979
request_query = parsed_url.query
8080
wsgi_input = ()
8181

82+
class _errors:
83+
def close():
84+
pass
85+
8286
environ = {}
87+
environ['wsgi.errors'] = _errors()
8388
environ['wsgi.input'] = wsgi_input
8489
environ['wsgi.url_scheme'] = parsed_url.scheme
8590
environ['wsgi.version'] = (1, 0)

tests/test_mig_wsgi-bin.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# --- BEGIN_HEADER ---
4+
#
5+
# Copyright (C) 2003-2024 The MiG Project by the Science HPC Center at UCPH
6+
#
7+
# This file is part of MiG.
8+
#
9+
# MiG is free software: you can redistribute it and/or modify
10+
# it under the terms of the GNU General Public License as published by
11+
# the Free Software Foundation; either version 2 of the License, or
12+
# (at your option) any later version.
13+
#
14+
# MiG is distributed in the hope that it will be useful,
15+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
# GNU General Public License for more details.
18+
#
19+
# You should have received a copy of the GNU General Public License
20+
# along with this program; if not, write to the Free Software
21+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
22+
# USA.
23+
#
24+
# --- END_HEADER ---
25+
#
26+
27+
"""Unit tests for the MiG WSGI glue."""
28+
29+
import codecs
30+
from configparser import ConfigParser
31+
import importlib
32+
import os
33+
import stat
34+
import sys
35+
36+
from tests.support import PY2, MIG_BASE, MigTestCase, testmain, is_path_within
37+
from tests.support.htmlsupp import HtmlAssertMixin
38+
from tests.support.wsgisupp import prepare_wsgi, WsgiAssertMixin
39+
40+
from mig.shared.base import client_id_dir, client_dir_id, get_short_id, \
41+
invisible_path, allow_script, brief_list
42+
from mig.shared.compat import SimpleNamespace
43+
import mig.shared.returnvalues as returnvalues
44+
45+
# workaround for files within non-module directories
46+
47+
48+
def _import_forcibly(module_name, relative_module_dir=None):
49+
module_path = os.path.join(MIG_BASE, 'mig')
50+
if relative_module_dir is not None:
51+
module_path = os.path.join(module_path, relative_module_dir)
52+
sys.path.append(module_path)
53+
mod = importlib.import_module(module_name)
54+
sys.path.pop(-1) # do not leave the forced module path
55+
return mod
56+
57+
58+
migwsgi = _import_forcibly('migwsgi', relative_module_dir='wsgi-bin')
59+
60+
61+
def noop(*args):
62+
pass
63+
64+
65+
class FakeBackend:
66+
def __init__(self):
67+
self.output_objects = [
68+
{ 'object_type': 'start' },
69+
{ 'object_type': 'title', 'text': 'ERROR' },
70+
]
71+
self.return_value = returnvalues.ERROR
72+
73+
def main(self, client_id, user_arguments_dict):
74+
return self.output_objects, self.return_value
75+
76+
def set_response(self, output_objects, returnvalue):
77+
self.output_objects = output_objects
78+
self.return_value = returnvalue
79+
80+
def to_import_module(self):
81+
def _import_module(module_path):
82+
return self
83+
return _import_module
84+
85+
86+
class MigWsgibin(MigTestCase, HtmlAssertMixin, WsgiAssertMixin):
87+
88+
def _provide_configuration(self):
89+
return 'testconfig'
90+
91+
def before_each(self):
92+
self.fake_backend = FakeBackend()
93+
self.fake_wsgi = prepare_wsgi(self.configuration, 'http://localhost/')
94+
95+
self.application_args = (
96+
self.fake_wsgi.environ,
97+
self.fake_wsgi.start_response,
98+
)
99+
self.application_kwargs = dict(
100+
configuration=self.configuration,
101+
_import_module=self.fake_backend.to_import_module(),
102+
_set_os_environ=False,
103+
)
104+
105+
def test_return_value_ok_returns_status_200(self):
106+
wsgi_result = migwsgi.application(
107+
*self.application_args,
108+
**self.application_kwargs
109+
)
110+
111+
self.assertWsgiResponse(wsgi_result, self.fake_wsgi, 200)
112+
113+
def test_return_value_ok_returns_valid_html_page(self):
114+
wsgi_result = migwsgi.application(
115+
*self.application_args,
116+
**self.application_kwargs
117+
)
118+
119+
output, _ = self.assertWsgiResponse(wsgi_result, self.fake_wsgi, 200)
120+
self.assertIsValidHtmlDocument(output)
121+
122+
def test_return_value_ok_returns_expected_title(self):
123+
output_objects = [
124+
{ 'object_type': 'title', 'text': 'TEST' }
125+
]
126+
self.fake_backend.set_response(output_objects, returnvalues.OK)
127+
128+
wsgi_result = migwsgi.application(
129+
*self.application_args,
130+
**self.application_kwargs
131+
)
132+
133+
output, _ = self.assertWsgiResponse(wsgi_result, self.fake_wsgi, 200)
134+
self.assertHtmlElementTextContent(
135+
output, 'title', 'TEST', trim_newlines=True)
136+
137+
138+
if __name__ == '__main__':
139+
testmain()

0 commit comments

Comments
 (0)