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
« 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."""
6from __future__ import annotations
8from pathlib import Path
10import click
11import requests
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
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
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
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.')
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)
119 zip_filepath = Path(zip_filepath).resolve()
120 candidates: list[tuple[str, Path]] = []
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]
145 if not candidates:
146 print('ℹ️ No matching zip files found.')
147 return
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}')
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)
181 zip_filepath = Path(zip_filepath).resolve()
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]
203 if not targets:
204 print('ℹ️ No matching registry entries found.')
205 return
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}')
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)
232 mapping = resolve_mafw_docs_package_ids_by_version(api_config, package_name='mafw-docs')
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
249 if not targets:
250 print('ℹ️ No matching registry entries found.')
251 return
253 base_url = api_config.api_url.rstrip('/')
254 headers = build_gitlab_auth_headers(api_config)
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 )