Coverage for gws-app/gws/test/test.py: 0%
198 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
1"""Test configurator and invoker.
3This script runs on the host machine.
5Its purpose is to create a docker compose file, start the compose
6and invoke the test runner inside the GWS container (via ``gws test``).
7"""
9import os
10import sys
11import yaml
12import json
14LOCAL_APP_DIR = os.path.abspath(os.path.dirname(__file__) + '/../..')
15sys.path.insert(0, LOCAL_APP_DIR)
17import gws
18import gws.lib.cli as cli
19import gws.lib.inifile as inifile
21USAGE = """
22GWS test runner
23~~~~~~~~~~~~~~~
25 python3 test.py <command> <options> - <pytest options>
27Commands:
29 test.py go
30 - start the test environment, run tests and stop
32 test.py start
33 - start the compose test environment
35 test.py stop
36 - stop the compose test environment
38 test.py run
39 - run tests in a started environment
41Options:
42 --ini <path> - path to the local 'ini' file (can also be passed in the GWS_TEST_INI env var)
43 --manifest <manifest> - path to MANIFEST.json
45 -c, --coverage - produce a coverage report
46 -d, --detach - run docker compose in the background
47 -l, --local - mount the local copy of the application in the test container
48 -o, --only <regex> - only run filenames matching the pattern
49 -v, --verbose - enable debug logging
51Pytest options:
52 See https://docs.pytest.org/latest/reference.html#command-line-flags
54"""
56OPTIONS = {}
59def main(args):
60 cmd = args.get(1)
62 ini_paths = [LOCAL_APP_DIR + '/test.ini']
63 custom_ini = args.get('ini') or gws.env.GWS_TEST_INI
64 if custom_ini:
65 ini_paths.append(custom_ini)
66 cli.info(f'using configs: {ini_paths}')
67 OPTIONS.update(inifile.from_paths(*ini_paths))
69 OPTIONS.update(dict(
70 arg_ini=custom_ini,
71 arg_pytest=args.get('_rest'),
73 arg_coverage=args.get('c') or args.get('coverage'),
74 arg_detach=args.get('d') or args.get('detach'),
75 arg_local=args.get('l') or args.get('local'),
76 arg_manifest=args.get('manifest'),
77 arg_only=args.get('o') or args.get('only'),
78 arg_verbose=args.get('v') or args.get('verbose'),
79 ))
81 OPTIONS['LOCAL_APP_DIR'] = LOCAL_APP_DIR
83 p = OPTIONS.get('runner.base_dir') or gws.env.GWS_TEST_DIR
84 if not p:
85 raise ValueError('GWS_TEST_DIR not set')
86 if not os.path.isabs(p):
87 p = os.path.realpath(os.path.join(LOCAL_APP_DIR, p))
88 OPTIONS['BASE_DIR'] = p
90 OPTIONS['runner.uid'] = int(OPTIONS.get('runner.uid') or os.getuid())
91 OPTIONS['runner.gid'] = int(OPTIONS.get('runner.gid') or os.getgid())
93 if cmd == 'go':
94 OPTIONS['arg_coverage'] = True
95 OPTIONS['arg_detach'] = True
96 docker_compose_stop()
97 configure()
98 docker_compose_start()
99 run()
100 docker_compose_stop()
101 return 0
103 if cmd == 'start':
104 docker_compose_stop()
105 configure()
106 docker_compose_start(with_exec=True)
107 return 0
109 if cmd == 'stop':
110 docker_compose_stop()
111 return 0
113 if cmd == 'run':
114 run()
115 return 0
117 cli.fatal('invalid arguments, try test.py -h for help')
120##
123def configure():
124 base = OPTIONS['BASE_DIR']
126 ensure_dir(f'{base}/config', clear=True)
127 ensure_dir(f'{base}/data')
128 ensure_dir(f'{base}/gws-var')
129 ensure_dir(f'{base}/pytest_cache')
131 manifest_text = '{}'
132 if OPTIONS['arg_manifest']:
133 manifest_text = read_file(OPTIONS['arg_manifest'])
135 write_file(f'{base}/config/MANIFEST.json', manifest_text)
136 write_file(f'{base}/config/docker-compose.yml', make_docker_compose_yml())
137 write_file(f'{base}/config/pg_service.conf', make_pg_service_conf())
138 write_file(f'{base}/config/pytest.ini', make_pytest_ini())
139 write_file(f'{base}/config/coverage.ini', make_coverage_ini())
140 write_file(f'{base}/config/OPTIONS.json', json.dumps(OPTIONS, indent=4))
142 cli.info(f'tests configured in {base!r}')
145def run():
146 base = OPTIONS['BASE_DIR']
147 coverage_ini = f'{base}/config/coverage.ini'
149 cmd = ''
151 if OPTIONS['arg_coverage']:
152 cmd += f'coverage run --rcfile={coverage_ini}'
153 else:
154 cmd += 'python3'
156 cmd += f' /gws-app/gws/test/container_runner.py --base {base}'
158 if OPTIONS['arg_only']:
159 cmd += f' --only "' + OPTIONS['arg_only'] + '"'
160 if OPTIONS['arg_verbose']:
161 cmd += ' --verbose '
162 if OPTIONS['arg_pytest']:
163 cmd += ' - ' + ' '.join(OPTIONS['arg_pytest'])
165 docker_exec('c_gws', cmd)
167 if OPTIONS['arg_coverage']:
168 ensure_dir(f'{base}/coverage', clear=True)
169 docker_exec('c_gws', f'coverage html --rcfile={coverage_ini}')
170 docker_exec('c_gws', f'coverage report --rcfile={coverage_ini} --sort=cover > {base}/coverage/report.txt')
173##
176def make_docker_compose_yml():
177 base = OPTIONS['BASE_DIR']
179 service_configs = {}
181 service_funcs = {}
182 for k, v in globals().items():
183 if k.startswith('service_'):
184 service_funcs[k.split('_')[1]] = v
186 OPTIONS['runner.services'] = list(service_funcs)
188 for s, fn in service_funcs.items():
189 srv = fn()
191 srv.setdefault('image', OPTIONS.get(f'service.{s}.image'))
192 srv.setdefault('extra_hosts', []).append(f"{OPTIONS.get('runner.docker_host_name')}:host-gateway")
194 std_vols = [
195 f'{base}:{base}',
196 f'{base}/data:/data',
197 f'{base}/gws-var:/gws-var',
198 ]
199 if OPTIONS['arg_local']:
200 std_vols.append(f'{LOCAL_APP_DIR}:/gws-app')
202 srv.setdefault('volumes', []).extend(std_vols)
204 srv.setdefault('tmpfs', []).append('/tmp')
205 srv.setdefault('stop_grace_period', '1s')
207 srv['environment'] = make_env()
209 service_configs[s] = srv
211 cfg = {
212 'networks': {
213 'default': {
214 'name': 'gws_test_network'
215 }
216 },
217 'services': service_configs,
218 }
220 return yaml.dump(cfg)
223def make_pg_service_conf():
224 name = OPTIONS.get('service.postgres.name')
225 ini = {
226 f'{name}.host': OPTIONS.get('service.postgres.host'),
227 f'{name}.port': OPTIONS.get('service.postgres.port'),
228 f'{name}.user': OPTIONS.get('service.postgres.user'),
229 f'{name}.password': OPTIONS.get('service.postgres.password'),
230 f'{name}.dbname': OPTIONS.get('service.postgres.database'),
231 }
232 return inifile.to_string(ini)
235def make_pytest_ini():
236 # https://docs.pytest.org/en/7.1.x/reference/reference.html#ini-OPTIONS-ref
238 base = OPTIONS['BASE_DIR']
239 ini = {}
240 for k, v in OPTIONS.items():
241 if k.startswith('pytest.'):
242 ini[k] = v
243 ini['pytest.cache_dir'] = f'{base}/pytest_cache'
244 return inifile.to_string(ini)
247def make_coverage_ini():
248 # https://coverage.readthedocs.io/en/7.5.3/config.html
250 base = OPTIONS['BASE_DIR']
251 ini = {
252 'run.source': '/gws-app/gws',
253 'run.data_file': f'{base}/coverage.data',
254 'html.directory': f'{base}/coverage'
255 }
256 return inifile.to_string(ini)
259def make_env():
260 env = {
261 'PYTHONPATH': '/gws-app',
262 'PYTHONPYCACHEPREFIX': '/tmp',
263 'PYTHONDONTWRITEBYTECODE': '1',
264 'GWS_UID': OPTIONS.get('runner.uid'),
265 'GWS_GID': OPTIONS.get('runner.gid'),
266 'GWS_TIMEZONE': OPTIONS.get('service.gws.time_zone', 'Etc/UTC'),
267 'POSTGRES_DB': OPTIONS.get('service.postgres.database'),
268 'POSTGRES_PASSWORD': OPTIONS.get('service.postgres.password'),
269 'POSTGRES_USER': OPTIONS.get('service.postgres.user'),
270 }
272 for k, v in OPTIONS.items():
273 sec, _, name = k.partition('.')
274 if sec == 'environment':
275 env[name] = v
277 return env
280##
282_GWS_ENTRYPOINT = """
283#!/usr/bin/env bash
285groupadd --gid $GWS_GID g_$GWS_GID
286useradd --create-home --uid $GWS_UID --gid $GWS_GID u_$GWS_UID
288ln -fs /usr/share/zoneinfo/$GWS_TIMEZONE /etc/localtime
290sleep infinity
291"""
294def service_gws():
295 base = OPTIONS['BASE_DIR']
297 ep = write_exec(f'{base}/config/gws_entrypoint', _GWS_ENTRYPOINT)
299 return dict(
300 container_name='c_gws',
301 entrypoint=ep,
302 ports=[
303 f"{OPTIONS.get('service.gws.http_expose_port')}:80",
304 f"{OPTIONS.get('service.gws.mpx_expose_port')}:5000",
305 ],
306 )
309def service_qgis():
310 return dict(
311 container_name='c_qgis',
312 command=f'/bin/sh /qgis-start.sh',
313 ports=[
314 f"{OPTIONS.get('service.qgis.expose_port')}:80",
315 ],
316 )
319_POSTGRESQL_ENTRYPOINT = """
320#!/usr/bin/env bash
322# delete existing and create our own postgres user
323groupdel -f postgres
324userdel -f postgres
325groupadd --gid $GWS_GID postgres
326useradd --create-home --uid $GWS_UID --gid $GWS_GID postgres
328# invoke the original postgres entry point
329docker-entrypoint.sh postgres --config_file=/etc/postgresql/postgresql.conf
330"""
333def service_postgres():
334 # https://github.com/docker-library/docs/blob/master/postgres/README.md
335 # https://github.com/postgis/docker-postgis
337 # the entrypoint business is because
338 # - 'postgres' uid should match host uid (or whatever is configured in test.ini)
339 # - we need a custom config file
341 base = OPTIONS['BASE_DIR']
342 tz = OPTIONS.get('service.gws.time_zone', 'Etc/UTC')
344 conf = f"""
345 listen_addresses = '*'
346 max_wal_size = 1GB
347 min_wal_size = 80MB
348 log_timezone = '{tz}'
349 timezone = '{tz}'
350 datestyle = 'iso, mdy'
351 default_text_search_config = 'pg_catalog.english'
353 logging_collector = 0
354 log_line_prefix = '%t %c %a %r '
355 log_statement = 'all'
356 log_connections = 1
357 log_disconnections = 1
358 log_duration = 1
359 log_hostname = 0
360 """
363 ep = write_exec(f'{base}/config/postgres_entrypoint', _POSTGRESQL_ENTRYPOINT)
364 cf = write_file(f'{base}/config/postgresql.conf', dedent(conf))
366 ensure_dir(f'{base}/postgres')
368 return dict(
369 container_name='c_postgres',
370 entrypoint=ep,
371 ports=[
372 f"{OPTIONS.get('service.postgres.expose_port')}:5432",
373 ],
374 volumes=[
375 f"{base}/postgres:/var/lib/postgresql/data",
376 f"{cf}:/etc/postgresql/postgresql.conf",
377 ]
378 )
381def service_mockserver():
382 return dict(
383 # NB use the gws image
384 container_name='c_mockserver',
385 image=OPTIONS.get('service.gws.image'),
386 command=f'python3 /gws-app/gws/test/mockserver.py',
387 ports=[
388 f"{OPTIONS.get('service.mockserver.expose_port')}:80",
389 ],
390 )
393##
395def docker_compose_start(with_exec=False):
396 cmd = [
397 'docker',
398 'compose',
399 '--file',
400 OPTIONS['BASE_DIR'] + '/config/docker-compose.yml',
401 'up'
402 ]
403 if OPTIONS['arg_detach']:
404 cmd.append('--detach')
406 if with_exec:
407 return os.execvp('docker', cmd)
409 cli.run(cmd)
412def docker_compose_stop():
413 cmd = [
414 'docker',
415 'compose',
416 '--file',
417 OPTIONS['BASE_DIR'] + '/config/docker-compose.yml',
418 'down'
419 ]
421 try:
422 cli.run(cmd)
423 except:
424 pass
427def docker_exec(container, cmd):
428 opts = OPTIONS.get('runner.docker_exec_options', '')
429 uid = OPTIONS.get('runner.uid')
430 gid = OPTIONS.get('runner.gid')
432 cli.run(f'''
433 docker exec
434 --user {uid}:{gid}
435 --env PYTHONPYCACHEPREFIX=/tmp
436 --env PYTHONDONTWRITEBYTECODE=1
437 {opts}
438 {container}
439 {cmd}
440 ''')
443def read_file(path):
444 with open(path, 'rt', encoding='utf8') as fp:
445 return fp.read()
448def write_file(path, s):
449 with open(path, 'wt', encoding='utf8') as fp:
450 fp.write(s)
451 return path
454def write_exec(path, s):
455 with open(path, 'wt', encoding='utf8') as fp:
456 fp.write(s.strip() + '\n')
457 os.chmod(path, 0o777)
458 return path
461def ensure_dir(path, clear=False):
462 def _clear(d):
463 for de in os.scandir(d):
464 if de.is_dir():
465 _clear(de.path)
466 os.rmdir(de.path)
467 else:
468 os.unlink(de.path)
470 os.makedirs(path, exist_ok=True)
471 if clear:
472 _clear(path)
474def dedent(text):
475 lines = text.split('\n')
476 ind = 100_000
477 for ln in lines:
478 n = len(ln.lstrip())
479 if n > 0:
480 ind = min(ind, len(ln) - n)
481 return '\n'.join(ln[ind:] for ln in lines)
484##
487if __name__ == '__main__':
488 cli.main('test', main, USAGE)