Coverage for src/mafw/devtools/cli/documentation/registry.py: 100%

34 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-26 09:13 +0000

1# Copyright 2025–2026 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4"""CLI commands for interacting with the GitLab documentation registry.""" 

5 

6from __future__ import annotations 

7 

8from pathlib import Path 

9 

10import click 

11import requests 

12 

13from mafw.devtools.documentation.builder import ( 

14 filter_versions_in_range, 

15 iter_local_mafw_docs_zips, 

16 normalize_registry_item, 

17 parse_mafw_docs_zip_filename, 

18 parse_version_tuple, 

19) 

20from mafw.devtools.gitlab import ( 

21 GitlabAPIConfiguration, 

22 build_gitlab_api_configuration, 

23 build_gitlab_auth_headers, 

24) 

25from mafw.devtools.gitlab.docs_registry import ( 

26 download_docs_zip_from_gitlab_generic_registry, 

27 list_mafw_docs_generic_packages, 

28 resolve_mafw_docs_package_ids_by_version, 

29 upload_docs_zip_to_gitlab_generic_registry, 

30) 

31from mafw.tools.click_extensions import AbbreviateGroup 

32 

33 

34@click.group(cls=AbbreviateGroup) 

35@click.option( 

36 '--gitlab-api-url', 

37 default=None, 

38 envvar='CI_API_V4_URL', 

39 help='GitLab API v4 base URL (env: CI_API_V4_URL).', 

40) 

41@click.option( 

42 '--gitlab-project-id', 

43 default=None, 

44 type=int, 

45 envvar='CI_PROJECT_ID', 

46 help='GitLab project numeric id (env: CI_PROJECT_ID).', 

47) 

48@click.option( 

49 '--gitlab-token', 

50 default=None, 

51 envvar='CI_JOB_TOKEN', 

52 help='GitLab token value (env: CI_JOB_TOKEN).', 

53) 

54@click.pass_context 

55def registry( 

56 ctx: click.Context, gitlab_api_url: str | None, gitlab_project_id: int | None, gitlab_token: str | None 

57) -> None: 

58 """Interact with the GitLab Generic Package Registry for mafw-docs packages.""" 

59 ctx.obj = ctx.obj or {} 

60 ctx.obj['gitlab_api_url'] = gitlab_api_url 

61 ctx.obj['gitlab_project_id'] = gitlab_project_id 

62 ctx.obj['gitlab_token'] = gitlab_token 

63 

64 

65def _registry_build_api_config(ctx: click.Context) -> GitlabAPIConfiguration: 

66 """Build the GitLab API configuration from values stored in the Click context.""" 

67 obj = ctx.obj or {} 

68 try: 

69 return build_gitlab_api_configuration( 

70 obj.get('gitlab_api_url'), 

71 obj.get('gitlab_project_id'), 

72 obj.get('gitlab_token'), 

73 ) 

74 except ValueError as e: 

75 raise click.ClickException(str(e)) from e 

76 

77 

78def _registry_validate_selection( 

79 files: tuple[str, ...], all_entries: bool, from_v: str | None, to_v: str | None 

80) -> None: 

81 """Validate that exactly one selection mode is used for registry commands.""" 

82 modes = 0 

83 if files: 

84 modes += 1 

85 if all_entries: 

86 modes += 1 

87 if from_v is not None or to_v is not None: 

88 modes += 1 

89 if modes != 1: 

90 raise click.ClickException('Choose exactly one selection mode: -f/--file, --all, or --from/--to.') 

91 

92 

93@registry.command() 

94@click.option( 

95 '--zip-filepath', 

96 default='.', 

97 type=click.Path(file_okay=False, dir_okay=True, path_type=Path), 

98 help='Directory containing zip files to upload. (.)', 

99) 

100@click.option('-f', '--file', 'files', multiple=True, help='Zip file(s) to upload (repeatable).') 

101@click.option( 

102 '--all', 'all_entries', is_flag=True, default=False, help='Upload all matching zip files from --zip-filepath.' 

103) 

104@click.option('--from', 'from_v', default=None, help='Upload versions from this tag (inclusive).') 

105@click.option('--to', 'to_v', default=None, help='Upload versions up to this tag (inclusive).') 

106@click.pass_context 

107def upload( # pragma: no cover — thin CLI delegation to tested docs_registry 

108 ctx: click.Context, 

109 zip_filepath: Path, 

110 files: tuple[str, ...], 

111 all_entries: bool, 

112 from_v: str | None, 

113 to_v: str | None, 

114) -> None: 

115 """Upload local documentation zip files to the registry.""" 

116 _registry_validate_selection(files, all_entries, from_v, to_v) 

117 api_config = _registry_build_api_config(ctx) 

118 

119 zip_filepath = Path(zip_filepath).resolve() 

120 candidates: list[tuple[str, Path]] = [] 

121 

122 if files: 

123 for raw in files: 

124 fp = Path(raw) 

125 if not fp.is_absolute(): 

126 fp2 = zip_filepath / fp 

127 fp = fp2 if fp2.exists() else fp 

128 parsed = parse_mafw_docs_zip_filename(fp.name) 

129 if parsed is None: 

130 raise click.ClickException(f'File does not match mafw-docs-vX.Y.Z.zip: {fp.name}') 

131 version, _ = parsed 

132 if not fp.exists(): 

133 raise click.ClickException(f'File not found: {fp}') 

134 candidates.append((version, fp)) 

135 else: 

136 candidates = iter_local_mafw_docs_zips(zip_filepath) 

137 if from_v is not None or to_v is not None: 

138 versions = [v for v, _ in candidates] 

139 try: 

140 allowed = set(filter_versions_in_range(versions, from_v, to_v)) 

141 except ValueError as e: 

142 raise click.ClickException(str(e)) from e 

143 candidates = [(v, p) for v, p in candidates if v in allowed] 

144 

145 if not candidates: 

146 print('ℹ️ No matching zip files found.') 

147 return 

148 

149 for version, fp in candidates: 

150 uploaded = upload_docs_zip_to_gitlab_generic_registry(api_config, version, fp, package_name='mafw-docs') 

151 if uploaded: 

152 print(f'☁️ Uploaded {fp.name} -> mafw-docs/{version}/{fp.name}') 

153 

154 

155@registry.command() 

156@click.option( 

157 '--zip-filepath', 

158 default='.', 

159 type=click.Path(file_okay=False, dir_okay=True, path_type=Path), 

160 help='Directory where downloaded zip files are stored. (.)', 

161) 

162@click.option('-f', '--file', 'items', multiple=True, help='Version(s) or zip file name(s) to download (repeatable).') 

163@click.option( 

164 '--all', 'all_entries', is_flag=True, default=False, help='Download all mafw-docs versions from the registry.' 

165) 

166@click.option('--from', 'from_v', default=None, help='Download versions from this tag (inclusive).') 

167@click.option('--to', 'to_v', default=None, help='Download versions up to this tag (inclusive).') 

168@click.pass_context 

169def download( # pragma: no cover — thin CLI delegation to tested docs_registry 

170 ctx: click.Context, 

171 zip_filepath: Path, 

172 items: tuple[str, ...], 

173 all_entries: bool, 

174 from_v: str | None, 

175 to_v: str | None, 

176) -> None: 

177 """Download documentation zip files from the registry.""" 

178 _registry_validate_selection(items, all_entries, from_v, to_v) 

179 api_config = _registry_build_api_config(ctx) 

180 

181 zip_filepath = Path(zip_filepath).resolve() 

182 

183 targets: list[tuple[str, str]] = [] 

184 if items: 

185 for it in items: 

186 try: 

187 targets.append(normalize_registry_item(it)) 

188 except ValueError as e: 

189 raise click.ClickException(str(e)) from e 

190 else: 

191 pkgs = list_mafw_docs_generic_packages(api_config, package_name='mafw-docs') 

192 versions: list[str] = [] 

193 for p in pkgs: 

194 version = p.get('version') 

195 if isinstance(version, str): 

196 versions.append(version) 

197 try: 

198 versions = filter_versions_in_range(sorted(set(versions), key=parse_version_tuple), from_v, to_v) 

199 except ValueError as e: 

200 raise click.ClickException(str(e)) from e 

201 targets = [(v, f'mafw-docs-{v}.zip') for v in versions] 

202 

203 if not targets: 

204 print('ℹ️ No matching registry entries found.') 

205 return 

206 

207 for version, file_name in targets: 

208 dest = download_docs_zip_from_gitlab_generic_registry( 

209 api_config, version, zip_filepath, package_name='mafw-docs', file_name=file_name 

210 ) 

211 if dest is None: 

212 print(f'ℹ️ Not found: mafw-docs/{version}/{file_name}') 

213 else: 

214 print(f'⬇️ Downloaded: {dest}') 

215 

216 

217@registry.command() 

218@click.option('-f', '--file', 'items', multiple=True, help='Version(s) or zip file name(s) to delete (repeatable).') 

219@click.option( 

220 '--all', 'all_entries', is_flag=True, default=False, help='Delete all mafw-docs versions from the registry.' 

221) 

222@click.option('--from', 'from_v', default=None, help='Delete versions from this tag (inclusive).') 

223@click.option('--to', 'to_v', default=None, help='Delete versions up to this tag (inclusive).') 

224@click.pass_context 

225def delete( 

226 ctx: click.Context, items: tuple[str, ...], all_entries: bool, from_v: str | None, to_v: str | None 

227) -> None: # pragma: no cover — thin CLI delegation to tested docs_registry 

228 """Delete mafw-docs package versions from the registry.""" 

229 _registry_validate_selection(items, all_entries, from_v, to_v) 

230 api_config = _registry_build_api_config(ctx) 

231 

232 mapping = resolve_mafw_docs_package_ids_by_version(api_config, package_name='mafw-docs') 

233 

234 targets: list[str] = [] 

235 if items: 

236 for it in items: 

237 try: 

238 version, _ = normalize_registry_item(it) 

239 except ValueError as e: 

240 raise click.ClickException(str(e)) from e 

241 targets.append(version) 

242 else: 

243 versions = sorted(mapping.keys(), key=parse_version_tuple) 

244 try: 

245 targets = filter_versions_in_range(versions, from_v, to_v) 

246 except ValueError as e: 

247 raise click.ClickException(str(e)) from e 

248 

249 if not targets: 

250 print('ℹ️ No matching registry entries found.') 

251 return 

252 

253 base_url = api_config.api_url.rstrip('/') 

254 headers = build_gitlab_auth_headers(api_config) 

255 

256 for version in targets: 

257 pkg_id = mapping.get(version) 

258 if pkg_id is None: 

259 print(f'ℹ️ Not found: mafw-docs/{version}') 

260 continue 

261 url = f'{base_url}/projects/{api_config.project_id}/packages/{pkg_id}' 

262 resp = requests.delete(url, headers=headers, timeout=60.0) 

263 if resp.status_code == 204: 

264 print(f'🗑️ Deleted package: mafw-docs/{version} (id={pkg_id})') 

265 continue 

266 if resp.status_code in (403, 404): 

267 body_preview = (resp.text or '')[:200].replace('\n', ' ') 

268 print(f'⚠️ Could not delete mafw-docs/{version} (id={pkg_id}): {resp.status_code} {body_preview}') 

269 continue 

270 body_preview = (resp.text or '')[:500].replace('\n', ' ') 

271 raise click.ClickException( 

272 f'GitLab delete failed for mafw-docs/{version} (id={pkg_id}): {resp.status_code} {body_preview}' 

273 )