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

1"""Test configurator and invoker. 

2 

3This script runs on the host machine. 

4 

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""" 

8 

9import os 

10import sys 

11import yaml 

12import json 

13 

14LOCAL_APP_DIR = os.path.abspath(os.path.dirname(__file__) + '/../..') 

15sys.path.insert(0, LOCAL_APP_DIR) 

16 

17import gws 

18import gws.lib.cli as cli 

19import gws.lib.inifile as inifile 

20 

21USAGE = """ 

22GWS test runner 

23~~~~~~~~~~~~~~~ 

24 

25 python3 test.py <command> <options> - <pytest options> 

26 

27Commands: 

28 

29 test.py go 

30 - start the test environment, run tests and stop 

31 

32 test.py start 

33 - start the compose test environment 

34 

35 test.py stop 

36 - stop the compose test environment 

37  

38 test.py run 

39 - run tests in a started environment 

40  

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 

44  

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 

51  

52Pytest options: 

53 See https://docs.pytest.org/en/latest/reference/reference.html#command-line-flags 

54 

55""" 

56 

57OPTIONS = {} 

58 

59 

60def main(args): 

61 cmd = args.get(1) 

62 

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)) 

69 

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 ) 

83 

84 OPTIONS['LOCAL_APP_DIR'] = LOCAL_APP_DIR 

85 

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 

92 

93 OPTIONS['runner.uid'] = int(OPTIONS.get('runner.uid') or os.getuid()) 

94 OPTIONS['runner.gid'] = int(OPTIONS.get('runner.gid') or os.getgid()) 

95 

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 

105 

106 if cmd == 'start': 

107 docker_compose_stop() 

108 configure() 

109 docker_compose_start(with_exec=True) 

110 return 0 

111 

112 if cmd == 'stop': 

113 docker_compose_stop() 

114 return 0 

115 

116 if cmd == 'run': 

117 run() 

118 return 0 

119 

120 cli.fatal('invalid arguments, try test.py -h for help') 

121 

122 

123## 

124 

125 

126def configure(): 

127 base = OPTIONS['BASE_DIR'] 

128 

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') 

133 

134 manifest_text = '{}' 

135 if OPTIONS['arg_manifest']: 

136 manifest_text = read_file(OPTIONS['arg_manifest']) 

137 

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)) 

144 

145 cli.info(f'tests configured in {base!r}') 

146 

147 

148def run(): 

149 base = OPTIONS['BASE_DIR'] 

150 coverage_ini = f'{base}/config/coverage.ini' 

151 

152 cmd = '' 

153 

154 if OPTIONS['arg_coverage']: 

155 cmd += f'coverage run --rcfile={coverage_ini}' 

156 else: 

157 cmd += 'python3' 

158 

159 cmd += f' /gws-app/gws/test/container_runner.py --base {base}' 

160 

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']) 

169 

170 docker_exec('c_gws', cmd) 

171 

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') 

176 

177 

178## 

179 

180 

181def make_docker_compose_yml(): 

182 base = OPTIONS['BASE_DIR'] 

183 

184 service_configs = {} 

185 

186 service_funcs = {} 

187 for k, v in globals().items(): 

188 if k.startswith('service_'): 

189 service_funcs[k.split('_')[1]] = v 

190 

191 OPTIONS['runner.services'] = list(service_funcs) 

192 

193 std_env = make_std_env() 

194 

195 for s, fn in service_funcs.items(): 

196 srv = fn() 

197 

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') 

200 

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') 

208 

209 srv.setdefault('volumes', []).extend(std_vols) 

210 

211 srv.setdefault('tmpfs', []).append('/tmp') 

212 srv.setdefault('stop_grace_period', '1s') 

213 

214 std_env.update(srv.get('environment', {})) 

215 service_configs[s] = srv 

216 

217 for srv in service_configs.values(): 

218 srv['environment'] = std_env 

219 

220 cfg = { 

221 'networks': {'default': {'name': 'gws_test_network'}}, 

222 'services': service_configs, 

223 } 

224 

225 return yaml.dump(cfg) 

226 

227 

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) 

238 

239 

240def make_pytest_ini(): 

241 # https://docs.pytest.org/en/7.1.x/reference/reference.html#ini-OPTIONS-ref 

242 

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) 

250 

251 

252def make_coverage_ini(): 

253 # https://coverage.readthedocs.io/en/7.5.3/config.html 

254 

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) 

262 

263 

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 } 

273 

274 for k, v in OPTIONS.items(): 

275 sec, _, name = k.partition('.') 

276 if sec == 'environment': 

277 env[name] = v 

278 

279 return env 

280 

281 

282## 

283 

284_GWS_ENTRYPOINT = """ 

285#!/usr/bin/env bash 

286 

287groupadd --gid $GWS_GID g_$GWS_GID 

288useradd --create-home --uid $GWS_UID --gid $GWS_GID u_$GWS_UID 

289 

290ln -fs /usr/share/zoneinfo/$GWS_TIMEZONE /etc/localtime 

291 

292sleep infinity 

293""" 

294 

295 

296def service_gws(): 

297 base = OPTIONS['BASE_DIR'] 

298 

299 ep = write_exec(f'{base}/config/gws_entrypoint', _GWS_ENTRYPOINT) 

300 

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 ) 

309 

310 

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 ) 

319 

320 

321_POSTGRESQL_ENTRYPOINT = """ 

322#!/usr/bin/env bash 

323 

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 

329 

330# invoke the original postgres entry point 

331docker-entrypoint.sh postgres --config_file=/etc/postgresql/postgresql.conf 

332""" 

333 

334 

335def service_postgres(): 

336 # https://github.com/docker-library/docs/blob/master/postgres/README.md 

337 # https://github.com/postgis/docker-postgis 

338 

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 

342 

343 base = OPTIONS['BASE_DIR'] 

344 tz = OPTIONS.get('service.gws.time_zone', 'Etc/UTC') 

345 

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' 

354 

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 """ 

363 

364 ep = write_exec(f'{base}/config/postgres_entrypoint', _POSTGRESQL_ENTRYPOINT) 

365 cf = write_file(f'{base}/config/postgresql.conf', dedent(conf)) 

366 

367 ensure_dir(f'{base}/postgres') 

368 

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 ) 

385 

386 

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 ) 

397 

398 

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') 

405 

406 copy_file(f'{src}/test.ldif', f'{dst}/custom/test.ldif') 

407 

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) 

413 

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 ) 

430 

431 

432## 

433 

434 

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') 

445 

446 if with_exec: 

447 return os.execvp('docker', cmd) 

448 

449 cli.run(cmd) 

450 

451 

452def docker_compose_stop(): 

453 cmd = [ 

454 'docker', 

455 'compose', 

456 '--file', 

457 OPTIONS['BASE_DIR'] + '/config/docker-compose.yml', 

458 'down', 

459 ] 

460 

461 try: 

462 cli.run(cmd) 

463 except: 

464 pass 

465 

466 

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') 

471 

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 """) 

481 

482 

483def read_file(path): 

484 with open(path, 'rt', encoding='utf8') as fp: 

485 return fp.read() 

486 

487 

488def write_file(path, s): 

489 with open(path, 'wt', encoding='utf8') as fp: 

490 fp.write(s) 

491 return path 

492 

493 

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) 

499 

500 

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 

506 

507 

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) 

516 

517 os.makedirs(path, exist_ok=True) 

518 if clear: 

519 _clear(path) 

520 

521 

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) 

530 

531 

532## 

533 

534 

535if __name__ == '__main__': 

536 cli.main('test', main, USAGE)