1212# See the License for the specific language governing permissions and
1313# limitations under the License.
1414
15+ import re
1516import sys
1617from pathlib import Path
18+ from typing import Optional
1719
1820from rich .align import Align
1921from rich .markdown import Markdown
2022from rich .padding import Padding
2123from rich .panel import Panel
24+ from rich .syntax import Syntax
2225from rich .table import Table
2326
2427from rocrate_validator import services
2528from rocrate_validator .cli .commands .errors import handle_error
2629from rocrate_validator .cli .main import cli , click
2730from 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 )
3034from rocrate_validator .utils import log as logging
3135from 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
175181def 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