Coverage for gws-app / gws / test / test.py: 0%
221 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-03 10:12 +0100
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-03 10:12 +0100
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 -k, --keyword <expr> - only run tests matching the pytest expression
50 -v, --verbose - enable debug logging
52Pytest options:
53 See https://docs.pytest.org/en/latest/reference/reference.html#command-line-flags
55"""
57OPTIONS = {}
60def main(args):
61 cmd = args.get(1)
63 ini_paths = [LOCAL_APP_DIR + '/test.ini']
64 custom_ini = args.get('ini') or gws.env.GWS_TEST_INI
65 if custom_ini:
66 ini_paths.append(custom_ini)
67 cli.info(f'using configs: {ini_paths}')
68 OPTIONS.update(inifile.from_paths(*ini_paths))
70 OPTIONS.update(
71 dict(
72 arg_ini=custom_ini,
73 arg_pytest=args.get('_rest'),
74 arg_coverage=args.get('c') or args.get('coverage'),
75 arg_detach=args.get('d') or args.get('detach'),
76 arg_local=args.get('l') or args.get('local'),
77 arg_manifest=args.get('manifest'),
78 arg_only=args.get('o') or args.get('only'),
79 arg_keyword=args.get('k') or args.get('keyword'),
80 arg_verbose=args.get('v') or args.get('verbose'),
81 )
82 )
84 OPTIONS['LOCAL_APP_DIR'] = LOCAL_APP_DIR
86 p = OPTIONS.get('runner.base_dir') or gws.env.GWS_TEST_DIR
87 if not p:
88 raise ValueError('GWS_TEST_DIR not set')
89 if not os.path.isabs(p):
90 p = os.path.realpath(os.path.join(LOCAL_APP_DIR, p))
91 OPTIONS['BASE_DIR'] = p
93 OPTIONS['runner.uid'] = int(OPTIONS.get('runner.uid') or os.getuid())
94 OPTIONS['runner.gid'] = int(OPTIONS.get('runner.gid') or os.getgid())
96 if cmd == 'go':
97 OPTIONS['arg_coverage'] = True
98 OPTIONS['arg_detach'] = True
99 docker_compose_stop()
100 configure()
101 docker_compose_start()
102 run()
103 docker_compose_stop()
104 return 0
106 if cmd == 'start':
107 docker_compose_stop()
108 configure()
109 docker_compose_start(with_exec=True)
110 return 0
112 if cmd == 'stop':
113 docker_compose_stop()
114 return 0
116 if cmd == 'run':
117 run()
118 return 0
120 cli.fatal('invalid arguments, try test.py -h for help')
123##
126def configure():
127 base = OPTIONS['BASE_DIR']
129 ensure_dir(f'{base}/config', clear=True)
130 ensure_dir(f'{base}/data')
131 ensure_dir(f'{base}/gws-var')
132 ensure_dir(f'{base}/pytest_cache')
134 manifest_text = '{}'
135 if OPTIONS['arg_manifest']:
136 manifest_text = read_file(OPTIONS['arg_manifest'])
138 write_file(f'{base}/config/MANIFEST.json', manifest_text)
139 write_file(f'{base}/config/docker-compose.yml', make_docker_compose_yml())
140 write_file(f'{base}/config/pg_service.conf', make_pg_service_conf())
141 write_file(f'{base}/config/pytest.ini', make_pytest_ini())
142 write_file(f'{base}/config/coverage.ini', make_coverage_ini())
143 write_file(f'{base}/config/OPTIONS.json', json.dumps(OPTIONS, indent=4))
145 cli.info(f'tests configured in {base!r}')
148def run():
149 base = OPTIONS['BASE_DIR']
150 coverage_ini = f'{base}/config/coverage.ini'
152 cmd = ''
154 if OPTIONS['arg_coverage']:
155 cmd += f'coverage run --rcfile={coverage_ini}'
156 else:
157 cmd += 'python3'
159 cmd += f' /gws-app/gws/test/container_runner.py --base {base}'
161 if OPTIONS['arg_only']:
162 cmd += f' --only "{OPTIONS["arg_only"]}"'
163 if OPTIONS['arg_keyword']:
164 cmd += f' --keyword "{OPTIONS["arg_keyword"]}"'
165 if OPTIONS['arg_verbose']:
166 cmd += ' --verbose '
167 if OPTIONS['arg_pytest']:
168 cmd += ' - ' + ' '.join(OPTIONS['arg_pytest'])
170 docker_exec('c_gws', cmd)
172 if OPTIONS['arg_coverage']:
173 ensure_dir(f'{base}/coverage', clear=True)
174 docker_exec('c_gws', f'coverage html --rcfile={coverage_ini}')
175 docker_exec('c_gws', f'coverage report --rcfile={coverage_ini} --sort=cover > {base}/coverage/report.txt')
178##
181def make_docker_compose_yml():
182 base = OPTIONS['BASE_DIR']
184 service_configs = {}
186 service_funcs = {}
187 for k, v in globals().items():
188 if k.startswith('service_'):
189 service_funcs[k.split('_')[1]] = v
191 OPTIONS['runner.services'] = list(service_funcs)
193 std_env = make_std_env()
195 for s, fn in service_funcs.items():
196 srv = fn()
198 srv.setdefault('image', OPTIONS.get(f'service.{s}.image'))
199 srv.setdefault('extra_hosts', []).append(f'{OPTIONS.get("runner.docker_host_name")}:host-gateway')
201 std_vols = [
202 f'{base}:{base}',
203 f'{base}/data:/data',
204 f'{base}/gws-var:/gws-var',
205 ]
206 if OPTIONS['arg_local']:
207 std_vols.append(f'{LOCAL_APP_DIR}:/gws-app')
209 srv.setdefault('volumes', []).extend(std_vols)
211 srv.setdefault('tmpfs', []).append('/tmp')
212 srv.setdefault('stop_grace_period', '1s')
214 std_env.update(srv.get('environment', {}))
215 service_configs[s] = srv
217 for srv in service_configs.values():
218 srv['environment'] = std_env
220 cfg = {
221 'networks': {'default': {'name': 'gws_test_network'}},
222 'services': service_configs,
223 }
225 return yaml.dump(cfg)
228def make_pg_service_conf():
229 name = OPTIONS.get('service.postgres.name')
230 ini = {
231 f'{name}.host': OPTIONS.get('service.postgres.host'),
232 f'{name}.port': OPTIONS.get('service.postgres.port'),
233 f'{name}.user': OPTIONS.get('service.postgres.user'),
234 f'{name}.password': OPTIONS.get('service.postgres.password'),
235 f'{name}.dbname': OPTIONS.get('service.postgres.database'),
236 }
237 return inifile.to_string(ini)
240def make_pytest_ini():
241 # https://docs.pytest.org/en/7.1.x/reference/reference.html#ini-OPTIONS-ref
243 base = OPTIONS['BASE_DIR']
244 ini = {}
245 for k, v in OPTIONS.items():
246 if k.startswith('pytest.'):
247 ini[k] = v
248 ini['pytest.cache_dir'] = f'{base}/pytest_cache'
249 return inifile.to_string(ini)
252def make_coverage_ini():
253 # https://coverage.readthedocs.io/en/7.5.3/config.html
255 base = OPTIONS['BASE_DIR']
256 ini = {
257 'run.source': '/gws-app/gws',
258 'run.data_file': f'{base}/coverage.data',
259 'html.directory': f'{base}/coverage',
260 }
261 return inifile.to_string(ini)
264def make_std_env():
265 env = {
266 'PYTHONPATH': '/gws-app',
267 'PYTHONPYCACHEPREFIX': '/tmp',
268 'PYTHONDONTWRITEBYTECODE': '1',
269 'GWS_UID': OPTIONS.get('runner.uid'),
270 'GWS_GID': OPTIONS.get('runner.gid'),
271 'GWS_TIMEZONE': OPTIONS.get('service.gws.time_zone', 'Etc/UTC'),
272 }
274 for k, v in OPTIONS.items():
275 sec, _, name = k.partition('.')
276 if sec == 'environment':
277 env[name] = v
279 return env
282##
284_GWS_ENTRYPOINT = """
285#!/usr/bin/env bash
287groupadd --gid $GWS_GID g_$GWS_GID
288useradd --create-home --uid $GWS_UID --gid $GWS_GID u_$GWS_UID
290ln -fs /usr/share/zoneinfo/$GWS_TIMEZONE /etc/localtime
292sleep infinity
293"""
296def service_gws():
297 base = OPTIONS['BASE_DIR']
299 ep = write_exec(f'{base}/config/gws_entrypoint', _GWS_ENTRYPOINT)
301 return dict(
302 container_name='c_gws',
303 entrypoint=ep,
304 ports=[
305 f'{OPTIONS.get("service.gws.http_expose_port")}:80',
306 f'{OPTIONS.get("service.gws.mpx_expose_port")}:5000',
307 ],
308 )
311def service_qgis():
312 return dict(
313 container_name='c_qgis',
314 command=f'/bin/sh /qgis-start.sh',
315 ports=[
316 f'{OPTIONS.get("service.qgis.expose_port")}:80',
317 ],
318 )
321_POSTGRESQL_ENTRYPOINT = """
322#!/usr/bin/env bash
324# delete existing and create our own postgres user
325groupdel -f postgres
326userdel -f postgres
327groupadd --gid $GWS_GID postgres
328useradd --create-home --uid $GWS_UID --gid $GWS_GID postgres
330# invoke the original postgres entry point
331docker-entrypoint.sh postgres --config_file=/etc/postgresql/postgresql.conf
332"""
335def service_postgres():
336 # https://github.com/docker-library/docs/blob/master/postgres/README.md
337 # https://github.com/postgis/docker-postgis
339 # the entrypoint business is because
340 # - 'postgres' uid should match host uid (or whatever is configured in test.ini)
341 # - we need a custom config file
343 base = OPTIONS['BASE_DIR']
344 tz = OPTIONS.get('service.gws.time_zone', 'Etc/UTC')
346 conf = f"""
347 listen_addresses = '*'
348 max_wal_size = 1GB
349 min_wal_size = 80MB
350 log_timezone = '{tz}'
351 timezone = '{tz}'
352 datestyle = 'iso, mdy'
353 default_text_search_config = 'pg_catalog.english'
355 logging_collector = 0
356 log_line_prefix = '%t %c %a %r '
357 log_statement = 'all'
358 log_connections = 1
359 log_disconnections = 1
360 log_duration = 1
361 log_hostname = 0
362 """
364 ep = write_exec(f'{base}/config/postgres_entrypoint', _POSTGRESQL_ENTRYPOINT)
365 cf = write_file(f'{base}/config/postgresql.conf', dedent(conf))
367 ensure_dir(f'{base}/postgres')
369 return dict(
370 container_name='c_postgres',
371 entrypoint=ep,
372 ports=[
373 f'{OPTIONS.get("service.postgres.expose_port")}:5432',
374 ],
375 environment={
376 'POSTGRES_DB': OPTIONS.get('service.postgres.database'),
377 'POSTGRES_PASSWORD': OPTIONS.get('service.postgres.password'),
378 'POSTGRES_USER': OPTIONS.get('service.postgres.user'),
379 },
380 volumes=[
381 f'{base}/postgres:/var/lib/postgresql/data',
382 f'{cf}:/etc/postgresql/postgresql.conf',
383 ],
384 )
387def service_mockserver():
388 return dict(
389 # NB use the gws image
390 container_name='c_mockserver',
391 image=OPTIONS.get('service.gws.image'),
392 command=f'python3 /gws-app/gws/test/mockserver.py',
393 ports=[
394 f'{OPTIONS.get("service.mockserver.expose_port")}:80',
395 ],
396 )
399def service_ldap():
400 base = OPTIONS['BASE_DIR']
401 src = f'{LOCAL_APP_DIR}/gws/plugin/auth_provider/ldap/_test/'
402 dst = f'{base}/config/ldap'
403 ensure_dir(f'{dst}/custom')
404 ensure_dir(f'{dst}/certs')
406 copy_file(f'{src}/test.ldif', f'{dst}/custom/test.ldif')
408 with open(f'{src}/certs.yaml') as fp:
409 certs = yaml.safe_load(fp)
410 for name, content in certs.items():
411 write_file(f'{dst}/certs/{name}', content)
412 os.chmod(f'{dst}/certs/ldap.key', 0o600)
414 return dict(
415 # NB: no _ in the host name
416 container_name='cldap',
417 environment={
418 'LDAP_ORGANISATION': OPTIONS.get('service.ldap.organisation'),
419 'LDAP_DOMAIN': 'example.com',
420 'LDAP_ADMIN_PASSWORD': OPTIONS.get('service.ldap.password'),
421 'LDAP_CONFIG_PASSWORD': OPTIONS.get('service.ldap.password'),
422 'LDAP_TLS': 'true',
423 },
424 command='--copy-service --loglevel debug',
425 volumes=[
426 f'{dst}/custom:/container/service/slapd/assets/config/bootstrap/ldif/custom',
427 f'{dst}/certs:/container/service/slapd/assets/certs',
428 ],
429 )
432##
435def docker_compose_start(with_exec=False):
436 cmd = [
437 'docker',
438 'compose',
439 '--file',
440 OPTIONS['BASE_DIR'] + '/config/docker-compose.yml',
441 'up',
442 ]
443 if OPTIONS['arg_detach']:
444 cmd.append('--detach')
446 if with_exec:
447 return os.execvp('docker', cmd)
449 cli.run(cmd)
452def docker_compose_stop():
453 cmd = [
454 'docker',
455 'compose',
456 '--file',
457 OPTIONS['BASE_DIR'] + '/config/docker-compose.yml',
458 'down',
459 ]
461 try:
462 cli.run(cmd)
463 except:
464 pass
467def docker_exec(container, cmd):
468 opts = OPTIONS.get('runner.docker_exec_options', '')
469 uid = OPTIONS.get('runner.uid')
470 gid = OPTIONS.get('runner.gid')
472 cli.run(f"""
473 docker exec
474 --user {uid}:{gid}
475 --env PYTHONPYCACHEPREFIX=/tmp
476 --env PYTHONDONTWRITEBYTECODE=1
477 {opts}
478 {container}
479 {cmd}
480 """)
483def read_file(path):
484 with open(path, 'rt', encoding='utf8') as fp:
485 return fp.read()
488def write_file(path, s):
489 with open(path, 'wt', encoding='utf8') as fp:
490 fp.write(s)
491 return path
494def copy_file(src, dst):
495 with open(src, 'rb') as fp:
496 buf = fp.read()
497 with open(dst, 'wb') as fp:
498 fp.write(buf)
501def write_exec(path, s):
502 with open(path, 'wt', encoding='utf8') as fp:
503 fp.write(s.strip() + '\n')
504 os.chmod(path, 0o777)
505 return path
508def ensure_dir(path, clear=False):
509 def _clear(d):
510 for de in os.scandir(d):
511 if de.is_dir():
512 _clear(de.path)
513 os.rmdir(de.path)
514 else:
515 os.unlink(de.path)
517 os.makedirs(path, exist_ok=True)
518 if clear:
519 _clear(path)
522def dedent(text):
523 lines = text.split('\n')
524 ind = 100_000
525 for ln in lines:
526 n = len(ln.lstrip())
527 if n > 0:
528 ind = min(ind, len(ln) - n)
529 return '\n'.join(ln[ind:] for ln in lines)
532##
535if __name__ == '__main__':
536 cli.main('test', main, USAGE)