Coverage for gws-app/gws/test/test.py: 0%

198 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-16 22:59 +0200

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 -v, --verbose - enable debug logging 

50  

51Pytest options: 

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

53 

54""" 

55 

56OPTIONS = {} 

57 

58 

59def main(args): 

60 cmd = args.get(1) 

61 

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

68 

69 OPTIONS.update(dict( 

70 arg_ini=custom_ini, 

71 arg_pytest=args.get('_rest'), 

72 

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

80 

81 OPTIONS['LOCAL_APP_DIR'] = LOCAL_APP_DIR 

82 

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 

89 

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

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

92 

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 

102 

103 if cmd == 'start': 

104 docker_compose_stop() 

105 configure() 

106 docker_compose_start(with_exec=True) 

107 return 0 

108 

109 if cmd == 'stop': 

110 docker_compose_stop() 

111 return 0 

112 

113 if cmd == 'run': 

114 run() 

115 return 0 

116 

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

118 

119 

120## 

121 

122 

123def configure(): 

124 base = OPTIONS['BASE_DIR'] 

125 

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

130 

131 manifest_text = '{}' 

132 if OPTIONS['arg_manifest']: 

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

134 

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

141 

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

143 

144 

145def run(): 

146 base = OPTIONS['BASE_DIR'] 

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

148 

149 cmd = '' 

150 

151 if OPTIONS['arg_coverage']: 

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

153 else: 

154 cmd += 'python3' 

155 

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

157 

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

164 

165 docker_exec('c_gws', cmd) 

166 

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

171 

172 

173## 

174 

175 

176def make_docker_compose_yml(): 

177 base = OPTIONS['BASE_DIR'] 

178 

179 service_configs = {} 

180 

181 service_funcs = {} 

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

183 if k.startswith('service_'): 

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

185 

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

187 

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

189 srv = fn() 

190 

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

193 

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

201 

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

203 

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

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

206 

207 srv['environment'] = make_env() 

208 

209 service_configs[s] = srv 

210 

211 cfg = { 

212 'networks': { 

213 'default': { 

214 'name': 'gws_test_network' 

215 } 

216 }, 

217 'services': service_configs, 

218 } 

219 

220 return yaml.dump(cfg) 

221 

222 

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) 

233 

234 

235def make_pytest_ini(): 

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

237 

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) 

245 

246 

247def make_coverage_ini(): 

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

249 

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) 

257 

258 

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 } 

271 

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

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

274 if sec == 'environment': 

275 env[name] = v 

276 

277 return env 

278 

279 

280## 

281 

282_GWS_ENTRYPOINT = """ 

283#!/usr/bin/env bash 

284 

285groupadd --gid $GWS_GID g_$GWS_GID 

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

287 

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

289 

290sleep infinity 

291""" 

292 

293 

294def service_gws(): 

295 base = OPTIONS['BASE_DIR'] 

296 

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

298 

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 ) 

307 

308 

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 ) 

317 

318 

319_POSTGRESQL_ENTRYPOINT = """ 

320#!/usr/bin/env bash 

321 

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 

327 

328# invoke the original postgres entry point 

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

330""" 

331 

332 

333def service_postgres(): 

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

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

336 

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 

340 

341 base = OPTIONS['BASE_DIR'] 

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

343 

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' 

352 

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

361 

362 

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

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

365 

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

367 

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 ) 

379 

380 

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 ) 

391 

392 

393## 

394 

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

405 

406 if with_exec: 

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

408 

409 cli.run(cmd) 

410 

411 

412def docker_compose_stop(): 

413 cmd = [ 

414 'docker', 

415 'compose', 

416 '--file', 

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

418 'down' 

419 ] 

420 

421 try: 

422 cli.run(cmd) 

423 except: 

424 pass 

425 

426 

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

431 

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

441 

442 

443def read_file(path): 

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

445 return fp.read() 

446 

447 

448def write_file(path, s): 

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

450 fp.write(s) 

451 return path 

452 

453 

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 

459 

460 

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) 

469 

470 os.makedirs(path, exist_ok=True) 

471 if clear: 

472 _clear(path) 

473 

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) 

482 

483 

484## 

485 

486 

487if __name__ == '__main__': 

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