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

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 os.path.isabs(p): 

85 p = os.path.realpath(os.path.join(LOCAL_APP_DIR, p)) 

86 OPTIONS['BASE_DIR'] = p 

87 

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

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

90 

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 

100 

101 if cmd == 'start': 

102 docker_compose_stop() 

103 configure() 

104 docker_compose_start(with_exec=True) 

105 return 0 

106 

107 if cmd == 'stop': 

108 docker_compose_stop() 

109 return 0 

110 

111 if cmd == 'run': 

112 run() 

113 return 0 

114 

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

116 

117 

118## 

119 

120 

121def configure(): 

122 base = OPTIONS['BASE_DIR'] 

123 

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) 

129 

130 manifest_text = '{}' 

131 if OPTIONS['arg_manifest']: 

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

133 

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

140 

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

142 

143 

144def run(): 

145 base = OPTIONS['BASE_DIR'] 

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

147 

148 cmd = '' 

149 

150 if OPTIONS['arg_coverage']: 

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

152 else: 

153 cmd += 'python3' 

154 

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

156 

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

163 

164 docker_exec('c_gws', cmd) 

165 

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

170 

171 

172## 

173 

174 

175def make_docker_compose_yml(): 

176 base = OPTIONS['BASE_DIR'] 

177 

178 service_configs = {} 

179 

180 service_funcs = {} 

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

182 if k.startswith('service_'): 

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

184 

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

186 

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

188 srv = fn() 

189 

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

192 

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

200 

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

202 

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

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

205 

206 srv['environment'] = make_env() 

207 

208 service_configs[s] = srv 

209 

210 cfg = { 

211 'networks': { 

212 'default': { 

213 'name': 'gws_test_network' 

214 } 

215 }, 

216 'services': service_configs, 

217 } 

218 

219 return yaml.dump(cfg) 

220 

221 

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) 

232 

233 

234def make_pytest_ini(): 

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

236 

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) 

244 

245 

246def make_coverage_ini(): 

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

248 

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) 

256 

257 

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 } 

270 

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

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

273 if sec == 'environment': 

274 env[name] = v 

275 

276 return env 

277 

278 

279## 

280 

281_GWS_ENTRYPOINT = """ 

282#!/usr/bin/env bash 

283 

284groupadd --gid $GWS_GID g_$GWS_GID 

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

286 

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

288 

289sleep infinity 

290""" 

291 

292 

293def service_gws(): 

294 base = OPTIONS['BASE_DIR'] 

295 

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

297 

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 ) 

306 

307 

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 ) 

316 

317 

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' 

326 

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

335 

336_POSTGRESQL_ENTRYPOINT = """ 

337#!/usr/bin/env bash 

338 

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 

344 

345# invoke the original postgres entry point 

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

347""" 

348 

349 

350def service_postgres(): 

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

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

353 

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 

357 

358 base = OPTIONS['BASE_DIR'] 

359 

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

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

362 

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

364 

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 ) 

376 

377 

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 ) 

388 

389 

390## 

391 

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

402 

403 if with_exec: 

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

405 

406 cli.run(cmd) 

407 

408 

409def docker_compose_stop(): 

410 cmd = [ 

411 'docker', 

412 'compose', 

413 '--file', 

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

415 'down' 

416 ] 

417 

418 try: 

419 cli.run(cmd) 

420 except: 

421 pass 

422 

423 

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

428 

429 cli.run(f'docker exec --user {uid}:{gid} {opts} {container} {cmd}') 

430 

431 

432def read_file(path): 

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

434 return fp.read() 

435 

436 

437def write_file(path, s): 

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

439 fp.write(s) 

440 return path 

441 

442 

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 

448 

449 

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) 

458 

459 os.makedirs(path, exist_ok=True) 

460 if clear: 

461 _clear(path) 

462 

463 

464## 

465 

466 

467if __name__ == '__main__': 

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