Skip to content

Commit f3fb7f3

Browse files
committed
feat(cli): extend CLI command to describe requirement checks
1 parent 23164b7 commit f3fb7f3

1 file changed

Lines changed: 185 additions & 25 deletions

File tree

rocrate_validator/cli/commands/profiles.py

Lines changed: 185 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,24 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import re
1516
import sys
1617
from pathlib import Path
18+
from typing import Optional
1719

1820
from rich.align import Align
1921
from rich.markdown import Markdown
2022
from rich.padding import Padding
2123
from rich.panel import Panel
24+
from rich.syntax import Syntax
2225
from rich.table import Table
2326

2427
from rocrate_validator import services
2528
from rocrate_validator.cli.commands.errors import handle_error
2629
from rocrate_validator.cli.main import cli, click
2730
from rocrate_validator.constants import DEFAULT_PROFILE_IDENTIFIER
28-
from rocrate_validator.models import (LevelCollection, RequirementLevel,
31+
from rocrate_validator.models import (LevelCollection, Profile,
32+
RequirementCheck, RequirementLevel,
2933
Severity)
3034
from rocrate_validator.utils import log as logging
3135
from rocrate_validator.utils.io_helpers.colors import get_severity_color
@@ -158,11 +162,13 @@ def list_profiles(ctx, no_paging: bool = False): # , profiles_path: Path = DEFA
158162
'-v',
159163
'--verbose',
160164
is_flag=True,
161-
help="Show detailed list of requirements",
165+
help="Show detailed list of requirements (or, when a check identifier is given, "
166+
"show the source code of the check)",
162167
default=False,
163168
show_default=True
164169
)
165170
@click.argument("profile-identifier", type=click.STRING, default=DEFAULT_PROFILE_IDENTIFIER, required=True)
171+
@click.argument("check-identifier", type=click.STRING, required=False, default=None)
166172
@click.option(
167173
'--no-paging',
168174
is_flag=True,
@@ -174,11 +180,19 @@ def list_profiles(ctx, no_paging: bool = False): # , profiles_path: Path = DEFA
174180
@click.pass_context
175181
def describe_profile(ctx,
176182
profile_identifier: str = DEFAULT_PROFILE_IDENTIFIER,
183+
check_identifier: Optional[str] = None,
177184
profiles_path: Path = DEFAULT_PROFILES_PATH,
178185
extra_profiles_path: Path = None,
179186
verbose: bool = False, no_paging: bool = False):
180187
"""
181-
Show a profile
188+
Show a profile, or — when CHECK_IDENTIFIER is given — show a single requirement check.
189+
190+
\b
191+
The check identifier accepts either form:
192+
* relative: <requirement#>.<check#> (e.g. "1.2")
193+
* full: <profile>_<requirement#>.<check#> (e.g. "ro-crate-1.1_1.2")
194+
195+
With -v on a single check, the source code of the check is shown.
182196
"""
183197
# Get the console
184198
console = ctx.obj['console']
@@ -197,6 +211,14 @@ def describe_profile(ctx,
197211
profile = services.get_profile(profile_identifier, profiles_path=profiles_path,
198212
extra_profiles_path=extra_profiles_path)
199213

214+
# Single-check view
215+
if check_identifier:
216+
check = __resolve_check__(profile, check_identifier)
217+
with console.pager(pager=pager, styles=not console.no_color) if enable_pager else console:
218+
console.print(get_app_header_rule())
219+
__describe_check__(console, profile, check, verbose=verbose)
220+
return
221+
200222
# Set the subheader title
201223
subheader_title = f"[bold][cyan]Profile:[/cyan] [magenta italic]{profile.identifier}[/magenta italic][/bold]"
202224

@@ -237,6 +259,9 @@ def describe_profile(ctx,
237259
title_align="left", border_style="cyan"), (0, 1, 0, 1)))
238260
console.print(Padding(table, (1, 1)))
239261

262+
except click.ClickException:
263+
# Let click format usage errors natively (e.g., BadParameter from check resolution)
264+
raise
240265
except Exception as e:
241266
handle_error(e, console)
242267

@@ -313,28 +338,7 @@ def __verbose_describe_profile__(profile):
313338
color = get_severity_color(check.severity)
314339
level_info = f"[{color}]{check.severity.name}[/{color}]"
315340
levels_list.add(level_info)
316-
override = None
317-
# Uncomment the following lines to show the overridden checks
318-
# if check.overridden_by:
319-
# logger.debug("Check %s is overridden by: %s", check.identifier, check.overridden_by)
320-
# override = "[overridden by: "
321-
# for co in check.overridden_by:
322-
# severity_color = get_severity_color(co.severity)
323-
# override += f"[bold][magenta]{co.requirement.profile.identifier}[/magenta] "\
324-
# f"[{severity_color}]{co.relative_identifier}[/{severity_color}][/bold]"
325-
# if co != check.overridden_by[-1]:
326-
# override += ", "
327-
# override += "]"
328-
if check.overrides:
329-
logger.debug("Check %s overrides: %s", check.identifier, check.overrides)
330-
override = "[" + "overrides: "
331-
for co in check.overrides:
332-
severity_color = get_severity_color(co.severity)
333-
override += f"[bold][magenta]{co.requirement.profile.identifier}[/magenta] "
334-
f"[{severity_color}]{co.relative_identifier}[/{severity_color}][/bold]"
335-
if co != check.overrides[-1]:
336-
override += ", "
337-
override += "]"
341+
override = __format_overrides__(check.overrides, label="overrides") if check.overrides else None
338342

339343
description_table = Table(show_header=False, show_footer=False, show_lines=False, show_edge=False)
340344
if override:
@@ -367,3 +371,159 @@ def __verbose_describe_profile__(profile):
367371
for row in table_rows:
368372
table.add_row(*row)
369373
return table
374+
375+
376+
_CHECK_ID_RE = re.compile(r"^(?P<req>\d+)\.(?P<check>\d+)$")
377+
378+
379+
def __resolve_check__(profile: Profile, check_identifier: str) -> RequirementCheck:
380+
"""
381+
Resolve a check identifier to a RequirementCheck instance.
382+
Accepts either the relative form ``<req#>.<check#>`` or the full form
383+
``<profile>_<req#>.<check#>``.
384+
"""
385+
raw = check_identifier.strip()
386+
relative = raw
387+
prefix = f"{profile.identifier}_"
388+
if "_" in raw:
389+
if not raw.startswith(prefix):
390+
raise click.BadParameter(
391+
f"Check identifier '{raw}' does not belong to profile '{profile.identifier}'.",
392+
param_hint="CHECK_IDENTIFIER",
393+
)
394+
relative = raw[len(prefix):]
395+
396+
match = _CHECK_ID_RE.match(relative)
397+
if not match:
398+
raise click.BadParameter(
399+
f"Invalid check identifier '{check_identifier}'. "
400+
f"Expected '<requirement#>.<check#>' (e.g. '1.2') or "
401+
f"'<profile>_<requirement#>.<check#>' (e.g. '{profile.identifier}_1.2').",
402+
param_hint="CHECK_IDENTIFIER",
403+
)
404+
req_number = int(match.group("req"))
405+
check_number = int(match.group("check"))
406+
407+
requirement = next(
408+
(r for r in profile.requirements if not r.hidden and r.order_number == req_number),
409+
None,
410+
)
411+
if requirement is None:
412+
raise click.BadParameter(
413+
f"No requirement #{req_number} in profile '{profile.identifier}'. "
414+
f"Run `rocrate-validator profiles describe {profile.identifier}` to list requirements.",
415+
param_hint="CHECK_IDENTIFIER",
416+
)
417+
check = next(
418+
(c for c in requirement.get_checks() if c.order_number == check_number),
419+
None,
420+
)
421+
if check is None:
422+
raise click.BadParameter(
423+
f"No check #{check_number} in requirement #{req_number} of profile "
424+
f"'{profile.identifier}'. Run `rocrate-validator profiles describe "
425+
f"{profile.identifier} -v` to list checks.",
426+
param_hint="CHECK_IDENTIFIER",
427+
)
428+
return check
429+
430+
431+
def __format_overrides__(checks: list, label: str) -> str:
432+
"""
433+
Format an "overrides" / "overridden by" Rich-styled string for a list of checks.
434+
"""
435+
parts = []
436+
for co in checks:
437+
severity_color = get_severity_color(co.severity)
438+
parts.append(
439+
f"[bold][magenta]{co.requirement.profile.identifier}[/magenta] "
440+
f"[{severity_color}]{co.relative_identifier}[/{severity_color}][/bold]"
441+
)
442+
return f"[bold red]{label}:[/bold red] " + ", ".join(parts)
443+
444+
445+
def __describe_check__(console, profile: Profile, check: RequirementCheck, verbose: bool = False) -> None:
446+
"""
447+
Render a single requirement check.
448+
"""
449+
severity_color = get_severity_color(check.severity)
450+
requirement = check.requirement
451+
452+
header = (
453+
f"[bold cyan]Profile:[/bold cyan] "
454+
f"[italic magenta]{profile.identifier}[/italic magenta]\n"
455+
f"[bold cyan]Identifier:[/bold cyan] "
456+
f"[italic green]{check.identifier}[/italic green]\n"
457+
f"[bold cyan]Name:[/bold cyan] [italic]{check.name}[/italic]\n"
458+
f"[bold cyan]Severity:[/bold cyan] "
459+
f"[bold {severity_color}]{check.severity.name}[/bold {severity_color}]\n"
460+
f"[bold cyan]Requirement:[/bold cyan] "
461+
f"[italic]#{requirement.order_number}{requirement.name}[/italic]"
462+
)
463+
if requirement.path:
464+
header += (
465+
"\n[bold cyan]Source file:[/bold cyan] "
466+
f"[italic green]{shorten_path(requirement.path)}[/italic green]"
467+
)
468+
469+
title = f"[bold][cyan]Check:[/cyan] [magenta italic]{check.identifier}[/magenta italic][/bold]"
470+
console.print(Padding(
471+
Panel(header, title=title, padding=(1, 1, 1, 1), title_align="left", border_style="cyan"),
472+
(0, 1, 0, 1),
473+
))
474+
475+
description_panel = Panel(
476+
Markdown(check.description.strip()),
477+
title="[bold cyan]Description[/bold cyan]",
478+
title_align="left",
479+
border_style="bright_black",
480+
padding=(1, 1, 1, 1),
481+
)
482+
console.print(Padding(description_panel, (1, 1, 0, 1)))
483+
484+
if check.overrides:
485+
overrides_text = __format_overrides__(check.overrides, label="overrides")
486+
console.print(Padding(Panel(
487+
overrides_text,
488+
title="[bold cyan]Overrides[/bold cyan]",
489+
title_align="left",
490+
border_style="bright_black",
491+
padding=(1, 1, 1, 1),
492+
), (1, 1, 0, 1)))
493+
if check.overridden_by:
494+
overridden_text = __format_overrides__(check.overridden_by, label="overridden by")
495+
console.print(Padding(Panel(
496+
overridden_text,
497+
title="[bold cyan]Overridden by[/bold cyan]",
498+
title_align="left",
499+
border_style="bright_black",
500+
padding=(1, 1, 1, 1),
501+
), (1, 1, 0, 1)))
502+
503+
if verbose:
504+
snippet = check.get_source_snippet()
505+
if snippet is None:
506+
console.print(Padding(Panel(
507+
"[italic]Source code not available for this check kind.[/italic]",
508+
title="[bold cyan]Source[/bold cyan]",
509+
title_align="left",
510+
border_style="bright_black",
511+
padding=(1, 1, 1, 1),
512+
), (1, 1, 0, 1)))
513+
else:
514+
source_title = f"[bold cyan]Source ({snippet.language})[/bold cyan]"
515+
if snippet.source_path:
516+
source_title += f': [italic green]"{snippet.source_path.name}"[/italic green]'
517+
console.print(Padding(Panel(
518+
Syntax(
519+
snippet.code,
520+
snippet.language,
521+
theme="ansi_dark",
522+
line_numbers=False,
523+
word_wrap=True,
524+
),
525+
title=source_title,
526+
title_align="left",
527+
border_style="bright_black",
528+
padding=(1, 1, 1, 1),
529+
), (1, 1, 1, 1)))

0 commit comments

Comments
 (0)