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
« 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."""
6from __future__ import annotations
8from pathlib import Path
9from typing import List, Tuple
11import click
12import requests
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
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
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
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.')
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)
120 zip_filepath = Path(zip_filepath).resolve()
121 candidates: List[Tuple[str, Path]] = []
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]
146 if not candidates:
147 print('ℹ️ No matching zip files found.')
148 return
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}')
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)
182 zip_filepath = Path(zip_filepath).resolve()
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]
204 if not targets:
205 print('ℹ️ No matching registry entries found.')
206 return
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}')
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)
233 mapping = resolve_mafw_docs_package_ids_by_version(api_config, package_name='mafw-docs')
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
250 if not targets:
251 print('ℹ️ No matching registry entries found.')
252 return
254 base_url = api_config.api_url.rstrip('/')
255 headers = build_gitlab_auth_headers(api_config)
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 )