Coverage for gws-app/gws/test/test.py: 0%
188 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +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 os.path.isabs(p):
85 p = os.path.realpath(os.path.join(LOCAL_APP_DIR, p))
86 OPTIONS['BASE_DIR'] = p
88 OPTIONS['runner.uid'] = int(OPTIONS.get('runner.uid') or os.getuid())
89 OPTIONS['runner.gid'] = int(OPTIONS.get('runner.gid') or os.getgid())
91 if cmd == 'go':
92 OPTIONS['arg_coverage'] = True
93 OPTIONS['arg_detach'] = True
94 docker_compose_stop()
95 configure()
96 docker_compose_start()
97 run()
98 docker_compose_stop()
99 return 0
101 if cmd == 'start':
102 docker_compose_stop()
103 configure()
104 docker_compose_start(with_exec=True)
105 return 0
107 if cmd == 'stop':
108 docker_compose_stop()
109 return 0
111 if cmd == 'run':
112 run()
113 return 0
115 cli.fatal('invalid arguments, try test.py -h for help')
118##
121def configure():
122 base = OPTIONS['BASE_DIR']
124 ensure_dir(f'{base}/config', clear=True)
125 ensure_dir(f'{base}/data')
126 ensure_dir(f'{base}/gws-var')
127 ensure_dir(f'{base}/pytest_cache')
128 ensure_dir(f'{base}/tmp', clear=True)
130 manifest_text = '{}'
131 if OPTIONS['arg_manifest']:
132 manifest_text = read_file(OPTIONS['arg_manifest'])
134 write_file(f'{base}/config/MANIFEST.json', manifest_text)
135 write_file(f'{base}/config/docker-compose.yml', make_docker_compose_yml())
136 write_file(f'{base}/config/pg_service.conf', make_pg_service_conf())
137 write_file(f'{base}/config/pytest.ini', make_pytest_ini())
138 write_file(f'{base}/config/coverage.ini', make_coverage_ini())
139 write_file(f'{base}/config/OPTIONS.json', json.dumps(OPTIONS, indent=4))
141 cli.info(f'tests configured in {base!r}')
144def run():
145 base = OPTIONS['BASE_DIR']
146 coverage_ini = f'{base}/config/coverage.ini'
148 cmd = ''
150 if OPTIONS['arg_coverage']:
151 cmd += f'coverage run --rcfile={coverage_ini}'
152 else:
153 cmd += 'python3'
155 cmd += f' /gws-app/gws/test/container_runner.py --base {base}'
157 if OPTIONS['arg_only']:
158 cmd += f' --only ' + OPTIONS['arg_only']
159 if OPTIONS['arg_verbose']:
160 cmd += ' --verbose '
161 if OPTIONS['arg_pytest']:
162 cmd += ' - ' + ' '.join(OPTIONS['arg_pytest'])
164 docker_exec('c_gws', cmd)
166 if OPTIONS['arg_coverage']:
167 ensure_dir(f'{base}/coverage', clear=True)
168 docker_exec('c_gws', f'coverage html --rcfile={coverage_ini}')
169 docker_exec('c_gws', f'coverage report --rcfile={coverage_ini} --sort=cover > {base}/coverage/report.txt')
172##
175def make_docker_compose_yml():
176 base = OPTIONS['BASE_DIR']
178 service_configs = {}
180 service_funcs = {}
181 for k, v in globals().items():
182 if k.startswith('service_'):
183 service_funcs[k.split('_')[1]] = v
185 OPTIONS['runner.services'] = list(service_funcs)
187 for s, fn in service_funcs.items():
188 srv = fn()
190 srv.setdefault('image', OPTIONS.get(f'service.{s}.image'))
191 srv.setdefault('extra_hosts', []).append(f"{OPTIONS.get('runner.docker_host_name')}:host-gateway")
193 std_vols = [
194 f'{base}:{base}',
195 f'{base}/data:/data',
196 f'{base}/gws-var:/gws-var',
197 ]
198 if OPTIONS['arg_local']:
199 std_vols.append(f'{LOCAL_APP_DIR}:/gws-app')
201 srv.setdefault('volumes', []).extend(std_vols)
203 srv.setdefault('tmpfs', []).append('/tmp')
204 srv.setdefault('stop_grace_period', '1s')
206 srv['environment'] = make_env()
208 service_configs[s] = srv
210 cfg = {
211 'networks': {
212 'default': {
213 'name': 'gws_test_network'
214 }
215 },
216 'services': service_configs,
217 }
219 return yaml.dump(cfg)
222def make_pg_service_conf():
223 name = OPTIONS.get('service.postgres.name')
224 ini = {
225 f'{name}.host': OPTIONS.get('service.postgres.host'),
226 f'{name}.port': OPTIONS.get('service.postgres.port'),
227 f'{name}.user': OPTIONS.get('service.postgres.user'),
228 f'{name}.password': OPTIONS.get('service.postgres.password'),
229 f'{name}.dbname': OPTIONS.get('service.postgres.database'),
230 }
231 return inifile.to_string(ini)
234def make_pytest_ini():
235 # https://docs.pytest.org/en/7.1.x/reference/reference.html#ini-OPTIONS-ref
237 base = OPTIONS['BASE_DIR']
238 ini = {}
239 for k, v in OPTIONS.items():
240 if k.startswith('pytest.'):
241 ini[k] = v
242 ini['pytest.cache_dir'] = f'{base}/pytest_cache'
243 return inifile.to_string(ini)
246def make_coverage_ini():
247 # https://coverage.readthedocs.io/en/7.5.3/config.html
249 base = OPTIONS['BASE_DIR']
250 ini = {
251 'run.source': '/gws-app/gws',
252 'run.data_file': f'{base}/coverage.data',
253 'html.directory': f'{base}/coverage'
254 }
255 return inifile.to_string(ini)
258def make_env():
259 env = {
260 'PYTHONPATH': '/gws-app',
261 'PYTHONPYCACHEPREFIX': '/tmp',
262 'PYTHONDONTWRITEBYTECODE': '1',
263 'GWS_UID': OPTIONS.get('runner.uid'),
264 'GWS_GID': OPTIONS.get('runner.gid'),
265 'GWS_TIMEZONE': OPTIONS.get('service.gws.time_zone', 'UTC'),
266 'POSTGRES_DB': OPTIONS.get('service.postgres.database'),
267 'POSTGRES_PASSWORD': OPTIONS.get('service.postgres.password'),
268 'POSTGRES_USER': OPTIONS.get('service.postgres.user'),
269 }
271 for k, v in OPTIONS.items():
272 sec, _, name = k.partition('.')
273 if sec == 'environment':
274 env[name] = v
276 return env
279##
281_GWS_ENTRYPOINT = """
282#!/usr/bin/env bash
284groupadd --gid $GWS_GID g_$GWS_GID
285useradd --create-home --uid $GWS_UID --gid $GWS_GID u_$GWS_UID
287ln -fs /usr/share/zoneinfo/$GWS_TIMEZONE /etc/localtime
289sleep infinity
290"""
293def service_gws():
294 base = OPTIONS['BASE_DIR']
296 ep = write_exec(f'{base}/config/gws_entrypoint', _GWS_ENTRYPOINT)
298 return dict(
299 container_name='c_gws',
300 entrypoint=ep,
301 ports=[
302 f"{OPTIONS.get('service.gws.http_expose_port')}:80",
303 f"{OPTIONS.get('service.gws.mpx_expose_port')}:5000",
304 ],
305 )
308def service_qgis():
309 return dict(
310 container_name='c_qgis',
311 command=f'/bin/sh /qgis-start.sh',
312 ports=[
313 f"{OPTIONS.get('service.qgis.expose_port')}:80",
314 ],
315 )
318_POSTGRESQL_CONF = """
319listen_addresses = '*'
320max_wal_size = 1GB
321min_wal_size = 80MB
322log_timezone = 'Etc/UTC'
323datestyle = 'iso, mdy'
324timezone = 'Etc/UTC'
325default_text_search_config = 'pg_catalog.english'
327logging_collector = 0
328log_line_prefix = '%t %c %a %r '
329log_statement = 'all'
330log_connections = 1
331log_disconnections = 1
332log_duration = 1
333log_hostname = 0
334"""
336_POSTGRESQL_ENTRYPOINT = """
337#!/usr/bin/env bash
339# delete existing and create our own postgres user
340groupdel -f postgres
341userdel -f postgres
342groupadd --gid $GWS_GID postgres
343useradd --create-home --uid $GWS_UID --gid $GWS_GID postgres
345# invoke the original postgres entry point
346docker-entrypoint.sh postgres --config_file=/etc/postgresql/postgresql.conf
347"""
350def service_postgres():
351 # https://github.com/docker-library/docs/blob/master/postgres/README.md
352 # https://github.com/postgis/docker-postgis
354 # the entrypoint business is because
355 # - 'postgres' uid should match host uid (or whatever is configured in test.ini)
356 # - we need a custom config file
358 base = OPTIONS['BASE_DIR']
360 ep = write_exec(f'{base}/config/postgres_entrypoint', _POSTGRESQL_ENTRYPOINT)
361 cf = write_file(f'{base}/config/postgresql.conf', _POSTGRESQL_CONF)
363 ensure_dir(f'{base}/postgres')
365 return dict(
366 container_name='c_postgres',
367 entrypoint=ep,
368 ports=[
369 f"{OPTIONS.get('service.postgres.expose_port')}:5432",
370 ],
371 volumes=[
372 f"{base}/postgres:/var/lib/postgresql/data",
373 f"{cf}:/etc/postgresql/postgresql.conf",
374 ]
375 )
378def service_mockserver():
379 return dict(
380 # NB use the gws image
381 container_name='c_mockserver',
382 image=OPTIONS.get('service.gws.image'),
383 command=f'python3 /gws-app/gws/test/mockserver.py',
384 ports=[
385 f"{OPTIONS.get('service.mockserver.expose_port')}:80",
386 ],
387 )
390##
392def docker_compose_start(with_exec=False):
393 cmd = [
394 'docker',
395 'compose',
396 '--file',
397 OPTIONS['BASE_DIR'] + '/config/docker-compose.yml',
398 'up'
399 ]
400 if OPTIONS['arg_detach']:
401 cmd.append('--detach')
403 if with_exec:
404 return os.execvp('docker', cmd)
406 cli.run(cmd)
409def docker_compose_stop():
410 cmd = [
411 'docker',
412 'compose',
413 '--file',
414 OPTIONS['BASE_DIR'] + '/config/docker-compose.yml',
415 'down'
416 ]
418 try:
419 cli.run(cmd)
420 except:
421 pass
424def docker_exec(container, cmd):
425 opts = OPTIONS.get('runner.docker_exec_options', '')
426 uid = OPTIONS.get('runner.uid')
427 gid = OPTIONS.get('runner.gid')
429 cli.run(f'docker exec --user {uid}:{gid} {opts} {container} {cmd}')
432def read_file(path):
433 with open(path, 'rt', encoding='utf8') as fp:
434 return fp.read()
437def write_file(path, s):
438 with open(path, 'wt', encoding='utf8') as fp:
439 fp.write(s)
440 return path
443def write_exec(path, s):
444 with open(path, 'wt', encoding='utf8') as fp:
445 fp.write(s.strip() + '\n')
446 os.chmod(path, 0o777)
447 return path
450def ensure_dir(path, clear=False):
451 def _clear(d):
452 for de in os.scandir(d):
453 if de.is_dir():
454 _clear(de.path)
455 os.rmdir(de.path)
456 else:
457 os.unlink(de.path)
459 os.makedirs(path, exist_ok=True)
460 if clear:
461 _clear(path)
464##
467if __name__ == '__main__':
468 cli.main('test', main, USAGE)