Skip to content

Commit 5b421f9

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. Included is a new wsgisupp.py file implementing the support bits to allow exercising WSGI related code in a readable way.
1 parent 4055f1d commit 5b421f9

File tree

6 files changed

+367
-7
lines changed

6 files changed

+367
-7
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=importlib.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/wsgisupp.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# --- BEGIN_HEADER ---
4+
#
5+
# wsgisupp - test support library for WSGI
6+
# Copyright (C) 2003-2025 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+
"""Test support library for WSGI."""
28+
29+
from collections import namedtuple
30+
import codecs
31+
from io import BytesIO
32+
from werkzeug.datastructures import MultiDict
33+
34+
from tests.support._env import PY2
35+
36+
if PY2:
37+
from urllib import urlencode
38+
from urlparse import urlparse
39+
else:
40+
from urllib.parse import urlencode, urlparse
41+
42+
43+
# named type representing the tuple that is passed to WSGI handlers
44+
_PreparedWsgi = namedtuple('_PreparedWsgi', ['environ', 'start_response'])
45+
46+
47+
class FakeWsgiStartResponse:
48+
"""Glue object that conforms to the same interface as the start_response()
49+
in the WSGI specs but records the calls to it such that they can be
50+
inspected and, for our purposes, asserted against."""
51+
52+
def __init__(self):
53+
self.calls = []
54+
55+
def __call__(self, status, headers, exc=None):
56+
self.calls.append((status, headers, exc))
57+
58+
59+
def create_wsgi_environ(configuration, wsgi_url, method='GET', query=None, headers=None, form=None):
60+
"""Populate the necessary variables that will constitute a valid WSGI
61+
environment given a URL to which we will make a requests under test and
62+
various other options that set up the nature of that request."""
63+
64+
parsed_url = urlparse(wsgi_url)
65+
66+
if query:
67+
method = 'GET'
68+
69+
request_query = urlencode(query)
70+
wsgi_input = ()
71+
elif form:
72+
method = 'POST'
73+
request_query = ''
74+
75+
body = urlencode(MultiDict(form)).encode('ascii')
76+
77+
headers = headers or {}
78+
if not 'Content-Type' in headers:
79+
headers['Content-Type'] = 'application/x-www-form-urlencoded'
80+
81+
headers['Content-Length'] = str(len(body))
82+
wsgi_input = BytesIO(body)
83+
else:
84+
request_query = parsed_url.query
85+
wsgi_input = ()
86+
87+
class _errors:
88+
def close():
89+
pass
90+
91+
environ = {}
92+
environ['wsgi.errors'] = _errors()
93+
environ['wsgi.input'] = wsgi_input
94+
environ['wsgi.url_scheme'] = parsed_url.scheme
95+
environ['wsgi.version'] = (1, 0)
96+
environ['MIG_CONF'] = configuration.config_file
97+
environ['HTTP_HOST'] = parsed_url.netloc
98+
environ['PATH_INFO'] = parsed_url.path
99+
environ['QUERY_STRING'] = request_query
100+
environ['REQUEST_METHOD'] = method
101+
environ['SCRIPT_URI'] = ''.join(
102+
('http://', environ['HTTP_HOST'], environ['PATH_INFO']))
103+
104+
if headers:
105+
for k, v in headers.items():
106+
header_key = k.replace('-', '_').upper()
107+
if header_key.startswith('CONTENT'):
108+
# Content-* headers must not be prefixed in WSGI
109+
pass
110+
else:
111+
header_key = "HTTP_%s" % (header_key),
112+
environ[header_key] = v
113+
114+
return environ
115+
116+
117+
def create_wsgi_start_response():
118+
return FakeWsgiStartResponse()
119+
120+
121+
def prepare_wsgi(configuration, url, **kwargs):
122+
return _PreparedWsgi(
123+
create_wsgi_environ(configuration, url, **kwargs),
124+
create_wsgi_start_response()
125+
)
126+
127+
128+
def _trigger_and_unpack_result(wsgi_result):
129+
chunks = list(wsgi_result)
130+
assert len(chunks) > 0, "invocation returned no output"
131+
complete_value = b''.join(chunks)
132+
decoded_value = codecs.decode(complete_value, 'utf8')
133+
return decoded_value
134+
135+
136+
class WsgiAssertMixin:
137+
"""Custom assertions for verifying server code executed under test."""
138+
139+
def assertWsgiResponse(self, wsgi_result, fake_wsgi, expected_status_code):
140+
assert isinstance(fake_wsgi, _PreparedWsgi)
141+
142+
content = _trigger_and_unpack_result(wsgi_result)
143+
144+
def called_once(fake):
145+
assert hasattr(fake, 'calls')
146+
return len(fake.calls) == 1
147+
148+
fake_start_response = fake_wsgi.start_response
149+
150+
try:
151+
self.assertTrue(called_once(fake_start_response))
152+
except AssertionError:
153+
if len(fake.calls) == 0:
154+
raise AssertionError("WSGI handler did not respond")
155+
else:
156+
raise AssertionError("WSGI handler responded more than once")
157+
158+
wsgi_call = fake_start_response.calls[0]
159+
160+
# check for expected HTTP status code
161+
wsgi_status = wsgi_call[0]
162+
actual_status_code = int(wsgi_status[0:3])
163+
self.assertEqual(actual_status_code, expected_status_code)
164+
165+
headers = dict(wsgi_call[1])
166+
167+
return content, headers

tests/test_mig_shared_url.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# --- BEGIN_HEADER ---
44
#
55
# test_mig_shared_url - unit test of the corresponding mig shared module
6-
# Copyright (C) 2003-2024 The MiG Project by the Science HPC Center at UCPH
6+
# Copyright (C) 2003-2025 The MiG Project by the Science HPC Center at UCPH
77
#
88
# This file is part of MiG.
99
#

0 commit comments

Comments
 (0)