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

35 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-06-28 13:34 +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 

9from typing import List, Tuple 

10 

11import click 

12import requests 

13 

14from mafw.devtools.documentation.builder import ( 

15 filter_versions_in_range, 

16 iter_local_mafw_docs_zips, 

17 normalize_registry_item, 

18 parse_mafw_docs_zip_filename, 

19 parse_version_tuple, 

20) 

21from mafw.devtools.gitlab import ( 

22 GitlabAPIConfiguration, 

23 build_gitlab_api_configuration, 

24 build_gitlab_auth_headers, 

25) 

26from mafw.devtools.gitlab.docs_registry import ( 

27 download_docs_zip_from_gitlab_generic_registry, 

28 list_mafw_docs_generic_packages, 

29 resolve_mafw_docs_package_ids_by_version, 

30 upload_docs_zip_to_gitlab_generic_registry, 

31) 

32from mafw.tools.click_extensions import AbbreviateGroup 

33 

34 

35@click.group(cls=AbbreviateGroup) 

36@click.option( 

37 '--gitlab-api-url', 

38 default=None, 

39 envvar='CI_API_V4_URL', 

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

41) 

42@click.option( 

43 '--gitlab-project-id', 

44 default=None, 

45 type=int, 

46 envvar='CI_PROJECT_ID', 

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

48) 

49@click.option( 

50 '--gitlab-token', 

51 default=None, 

52 envvar='CI_JOB_TOKEN', 

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

54) 

55@click.pass_context 

56def registry( 

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

58) -> None: 

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

60 ctx.obj = ctx.obj or {} 

61 ctx.obj['gitlab_api_url'] = gitlab_api_url 

62 ctx.obj['gitlab_project_id'] = gitlab_project_id 

63 ctx.obj['gitlab_token'] = gitlab_token 

64 

65 

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

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

68 obj = ctx.obj or {} 

69 try: 

70 return build_gitlab_api_configuration( 

71 obj.get('gitlab_api_url'), 

72 obj.get('gitlab_project_id'), 

73 obj.get('gitlab_token'), 

74 ) 

75 except ValueError as e: 

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

77 

78 

79def _registry_validate_selection( 

80 files: Tuple[str, ...], all_entries: bool, from_v: str | None, to_v: str | None 

81) -> None: 

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

83 modes = 0 

84 if files: 

85 modes += 1 

86 if all_entries: 

87 modes += 1 

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

89 modes += 1 

90 if modes != 1: 

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

92 

93 

94@registry.command() 

95@click.option( 

96 '--zip-filepath', 

97 default='.', 

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

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

100) 

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

102@click.option( 

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

104) 

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

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

107@click.pass_context 

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

109 ctx: click.Context, 

110 zip_filepath: Path, 

111 files: Tuple[str, ...], 

112 all_entries: bool, 

113 from_v: str | None, 

114 to_v: str | None, 

115) -> None: 

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

117 _registry_validate_selection(files, all_entries, from_v, to_v) 

118 api_config = _registry_build_api_config(ctx) 

119 

120 zip_filepath = Path(zip_filepath).resolve() 

121 candidates: List[Tuple[str, Path]] = [] 

122 

123 if files: 

124 for raw in files: 

125 fp = Path(raw) 

126 if not fp.is_absolute(): 

127 fp2 = zip_filepath / fp 

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

129 parsed = parse_mafw_docs_zip_filename(fp.name) 

130 if parsed is None: 

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

132 version, _ = parsed 

133 if not fp.exists(): 

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

135 candidates.append((version, fp)) 

136 else: 

137 candidates = iter_local_mafw_docs_zips(zip_filepath) 

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

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

140 try: 

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

142 except ValueError as e: 

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

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

145 

146 if not candidates: 

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

148 return 

149 

150 for version, fp in candidates: 

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

152 if uploaded: 

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

154 

155 

156@registry.command() 

157@click.option( 

158 '--zip-filepath', 

159 default='.', 

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

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

162) 

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

164@click.option( 

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

166) 

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

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

169@click.pass_context 

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

171 ctx: click.Context, 

172 zip_filepath: Path, 

173 items: Tuple[str, ...], 

174 all_entries: bool, 

175 from_v: str | None, 

176 to_v: str | None, 

177) -> None: 

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

179 _registry_validate_selection(items, all_entries, from_v, to_v) 

180 api_config = _registry_build_api_config(ctx) 

181 

182 zip_filepath = Path(zip_filepath).resolve() 

183 

184 targets: List[Tuple[str, str]] = [] 

185 if items: 

186 for it in items: 

187 try: 

188 targets.append(normalize_registry_item(it)) 

189 except ValueError as e: 

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

191 else: 

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

193 versions: List[str] = [] 

194 for p in pkgs: 

195 version = p.get('version') 

196 if isinstance(version, str): 

197 versions.append(version) 

198 try: 

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

200 except ValueError as e: 

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

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

203 

204 if not targets: 

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

206 return 

207 

208 for version, file_name in targets: 

209 dest = download_docs_zip_from_gitlab_generic_registry( 

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

211 ) 

212 if dest is None: 

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

214 else: 

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

216 

217 

218@registry.command() 

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

220@click.option( 

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

222) 

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

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

225@click.pass_context 

226def delete( 

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

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

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

230 _registry_validate_selection(items, all_entries, from_v, to_v) 

231 api_config = _registry_build_api_config(ctx) 

232 

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

234 

235 targets: List[str] = [] 

236 if items: 

237 for it in items: 

238 try: 

239 version, _ = normalize_registry_item(it) 

240 except ValueError as e: 

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

242 targets.append(version) 

243 else: 

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

245 try: 

246 targets = filter_versions_in_range(versions, from_v, to_v) 

247 except ValueError as e: 

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

249 

250 if not targets: 

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

252 return 

253 

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

255 headers = build_gitlab_auth_headers(api_config) 

256 

257 for version in targets: 

258 pkg_id = mapping.get(version) 

259 if pkg_id is None: 

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

261 continue 

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

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

264 if resp.status_code == 204: 

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

266 continue 

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

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

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

270 continue 

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

272 raise click.ClickException( 

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

274 )