Skip to content

Commit 40cb471

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 299e789 commit 40cb471

File tree

5 files changed

+256
-13
lines changed

5 files changed

+256
-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: 14 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
@@ -50,7 +50,7 @@ def object_type_info(object_type):
5050

5151

5252
def stub(configuration, client_id, import_path, backend, user_arguments_dict,
53-
environ):
53+
environ, _import_module):
5454
"""Run backend on behalf of client_id with supplied user_arguments_dict.
5555
I.e. import main from import_path and execute it with supplied arguments.
5656
"""
@@ -88,7 +88,7 @@ def stub(configuration, client_id, import_path, backend, user_arguments_dict,
8888

8989
# _logger.debug("import main from %r" % import_path)
9090
# NOTE: dynamic module loading to find corresponding main function
91-
module_handle = importlib.import_module(import_path)
91+
module_handle = _import_module(import_path)
9292
main = module_handle.main
9393
except Exception as err:
9494
_logger.error("%s could not import %r (%s): %s" %
@@ -184,7 +184,8 @@ def _flush_wsgi_errors():
184184
environ['wsgi.errors'].close()
185185

186186

187-
def application(environ, start_response):
187+
def application(environ, start_response, configuration=None,
188+
_import_module=importlib.import_module, _set_os_environ=True):
188189
"""MiG app called automatically by WSGI.
189190
190191
*environ* is a dictionary populated by the server with CGI-like variables
@@ -235,14 +236,17 @@ def application(environ, start_response):
235236
os_env_value))
236237

237238
# Assign updated environ to LOCAL os.environ for the rest of this session
238-
os.environ = environ
239+
if _set_os_environ:
240+
os.environ(environ)
239241

240242
# NOTE: redirect stdout to stderr in python 2 only. It breaks logger in 3
241243
# and stdout redirection apparently is already handled there.
242244
if sys.version_info[0] < 3:
243245
sys.stdout = sys.stderr
244246

245-
configuration = get_configuration_object()
247+
if configuration is None:
248+
configuration = get_configuration_object()
249+
246250
_logger = configuration.logger
247251

248252
# NOTE: replace default wsgi errors to apache error log with our own logs
@@ -313,7 +317,8 @@ def application(environ, start_response):
313317
# _logger.debug("wsgi handling script: %s" % script_name)
314318
(output_objs, ret_val) = stub(configuration, client_id,
315319
module_path, backend,
316-
user_arguments_dict, environ)
320+
user_arguments_dict, environ,
321+
_import_module=_import_module)
317322
else:
318323
_logger.warning("wsgi handling refused script:%s" % script_name)
319324
(output_objs, ret_val) = reject_main(client_id,
@@ -410,12 +415,12 @@ def application(environ, start_response):
410415
_logger.info("WSGI %s yielding %d output parts (%db)" %
411416
(backend, chunk_parts, content_length))
412417
# _logger.debug("send chunked %r response to client" % backend)
413-
for i in xrange(chunk_parts):
418+
for i in list(range(chunk_parts)):
414419
# _logger.debug("WSGI %s yielding part %d / %d output parts" %
415420
# (backend, i+1, chunk_parts))
416421
# end index may be after end of content - but no problem
417422
part = output[i*download_block_size:(i+1)*download_block_size]
418-
yield part
423+
yield force_utf8(part)
419424
if chunk_parts > 1:
420425
_logger.info("WSGI %s finished yielding all %d output parts" %
421426
(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/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)