Loading tbc_check/checker.py +82 −45 Original line number Diff line number Diff line Loading @@ -4,7 +4,7 @@ import re from enum import Enum from logging import Logger from pathlib import Path from typing import Optional, Union from typing import Any, Optional, Union import yaml from pydantic import BaseModel Loading Loading @@ -62,22 +62,37 @@ class TbcVarType(str, Enum): class GlInput(BaseModel): # name: str """GitLab input from the 'specs' section.""" description: Optional[str] = None """Input description.""" type: GlInputType = GlInputType.string """Input type.""" options: Optional[list[str]] = None """Input options (allowed values).""" default: Optional[Union[str, bool, int]] = None """Input default.""" class TbcVar(BaseModel): """to-be-continuous variable from the Kicker declaration.""" name: str """Variable name.""" description: Optional[str] = None """Variable name.""" type: TbcVarType = TbcVarType.text """Variable type.""" values: Optional[list[str]] = None """Variable allowed values (for an enumerated type).""" default: Optional[str] = None """Variable default value.""" advanced: bool = False """Whether the variable is advanced.""" secret: bool = False """Whether the variable is a secret.""" mandatory: bool = False """Whether the variable is mandatory.""" def input_name(self, var_prefix: str) -> str: return ( Loading Loading @@ -111,13 +126,21 @@ class TbcVar(BaseModel): class DocVar(BaseModel): """Variable delcaration from the documentation (README).""" lock: bool """Whether the variable show a lock icon (expected for secrets).""" var_name: str """The variable name.""" input_name: Optional[str] = None """The associated input name.""" description: str """The textual variable description.""" default_cell: str """The textual content of the default value column.""" def default(self, type: GlInputType) -> Optional[str]: """Extracts and converts the variable default value from the cell content.""" code_expr_match = re.match(r"`([^`]*)`", self.default_cell) explicit_default = code_expr_match.group(1) if code_expr_match else None Loading @@ -130,12 +153,12 @@ class DocVar(BaseModel): return explicit_default or "" def _get_var(tpl_desc: dict[str, any], var_name: str) -> Optional[TbcVar]: def _get_var(kicker: dict[str, any], var_name: str) -> Optional[TbcVar]: var = next( iter( [ TbcVar.parse_obj(var) for var in tpl_desc.get("variables", []) for var in kicker.get("variables", []) if var["name"] == var_name ] ), Loading @@ -145,7 +168,7 @@ def _get_var(tpl_desc: dict[str, any], var_name: str) -> Optional[TbcVar]: return var # look into feature variables for feat in tpl_desc.get("features", []): for feat in kicker.get("features", []): if feat.get("enable_with") == var_name: return TbcVar( name=feat.get("enable_with"), Loading Loading @@ -178,21 +201,26 @@ def _check_var( tbc_var: TbcVar, var_prefix: str, tpl_spec: dict[str, any], tpl_impl: dict[str, any], main_tpl_desc: Optional[dict[str, any]], tpl_body: dict[str, any], root_kicker: Optional[dict[str, any]], doc_vars: list[DocVar], ) -> int: """Check variable rules.""" has_no_input = ( tbc_var.secret or main_tpl_desc and _get_var(main_tpl_desc, tbc_var.name) or root_kicker and _get_var(root_kicker, tbc_var.name) or tbc_var.name.startswith("TBC_") ) expected_input_name = tbc_var.input_name(var_prefix) expected_gl_input = tbc_var.to_gl() # check variable is declared in doc err_count = 0 # check variable declaration from doc (warn only) # ----------------------------------- doc_var = next(filter(lambda dv: dv.var_name == tbc_var.name, doc_vars), None) if doc_var == None: if doc_var is None: print( f" {AnsiColors.YELLOW}⚠ <{tbc_var.name}>: not documented in README{AnsiColors.RESET}" ) Loading @@ -219,52 +247,51 @@ def _check_var( # retrieve declared input from template specs declared_input = tpl_spec["spec"]["inputs"].get(expected_input_name) # check cases where variable must not be declared as an input # ----------------------------------------------------------- if tbc_var.secret: # secrets should not be inputs if declared_input: print( f" {AnsiColors.RED}✕ <{tbc_var.name}> is a secret: must not be declared{AnsiColors.RESET}" ) return 1 else: return err_count + 1 print( f" {AnsiColors.HGRAY}✕ <{tbc_var.name}> is a secret: skip{AnsiColors.RESET}" ) return 0 if main_tpl_desc and _get_var(main_tpl_desc, tbc_var.name): return err_count if root_kicker and _get_var(root_kicker, tbc_var.name): # a variant is overriding a variable from the main template: skip if declared_input: print( f" {AnsiColors.RED}✕ <{tbc_var.name}> is an override: must not be declared{AnsiColors.RESET}" ) return 1 else: return err_count + 1 print( f" {AnsiColors.HGRAY}✕ <{tbc_var.name}> is an override: skip{AnsiColors.RESET}" ) return 0 return err_count if tbc_var.name.startswith("TBC_"): # global TBC variable: skip if declared_input: print( f" {AnsiColors.RED}✕ <{tbc_var.name}> is global TBC: must not be declared{AnsiColors.RESET}" ) return 1 else: return err_count + 1 print( f" {AnsiColors.HGRAY}✕ <{tbc_var.name}> is global TBC: skip{AnsiColors.RESET}" ) return 0 return err_count # check if mapped GitLab input is declared if not declared_input: print( f" {AnsiColors.RED}✕ <{tbc_var.name}/{expected_input_name}>: input not found{AnsiColors.RESET}" ) return 1 return err_count + 1 err_count = 0 # check actual input is as expected # check GitLab input is as expected # --------------------------------- actual_gl_input = GlInput.parse_obj(declared_input) if actual_gl_input.type != expected_gl_input.type: Loading @@ -291,7 +318,8 @@ def _check_var( err_count += 1 # check variable is initialized from input actual_variable_value = tpl_impl["variables"].get(tbc_var.name) # ---------------------------------------- actual_variable_value = tpl_body["variables"].get(tbc_var.name) expected_variable_value = f"$[[ inputs.{expected_input_name} ]]" if not actual_variable_value: print( Loading @@ -304,6 +332,7 @@ def _check_var( ) err_count += 1 # print an OK message if no error was found if err_count == 0: print( f" {AnsiColors.GREEN}✓{AnsiColors.RESET} <{tbc_var.name}/{expected_input_name}>: OK" Loading @@ -313,33 +342,41 @@ def _check_var( def _check_tpl( tpl_desc, main_tpl_desc, project_dir: Path, var_prefix: str, doc_vars: list[DocVar] kicker: dict[str, any], root_kicker: Optional[dict[str, any]], project_dir: Path, var_prefix: str, doc_vars: list[DocVar], ) -> int: tpl_path = project_dir / tpl_desc["template_path"] """Check a template (either main or variant).""" tpl_path = project_dir / kicker["template_path"] if not tpl_path.exists(): print(f"{AnsiColors.RED}ERROR: Template file ({tpl_path}) not found: abort") exit(1) # load template with open(tpl_path, "r") as reader: all_tpl = list(yaml.load_all(reader, Loader=yaml.BaseLoader)) tpl_spec = all_tpl[0] tpl_impl = all_tpl[-1] tpl_parts: list[dict[str, Any]] = list( yaml.load_all(reader, Loader=yaml.BaseLoader) ) tpl_spec: dict[str, Any] = tpl_parts[0] tpl_body: dict[str, Any] = tpl_parts[-1] inputs: dict[str, dict[str, any]] = dict(tpl_spec["spec"]["inputs"]) err_count = 0 # check main template variables for var in tpl_desc.get("variables", []): for var in kicker.get("variables", []): tbc_var = TbcVar.parse_obj(var) err_count += _check_var( tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars ) input_name = tbc_var.input_name(var_prefix) if input_name in inputs: del inputs[input_name] # check feature variables for feat in tpl_desc.get("features", []): for feat in kicker.get("features", []): if feat.get("enable_with"): tbc_var = TbcVar( name=feat.get("enable_with"), Loading @@ -347,7 +384,7 @@ def _check_tpl( type=TbcVarType.boolean, ) err_count += _check_var( tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars ) input_name = tbc_var.input_name(var_prefix) if input_name in inputs: Loading @@ -359,7 +396,7 @@ def _check_tpl( type=TbcVarType.boolean, ) err_count += _check_var( tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars ) input_name = tbc_var.input_name(var_prefix) if input_name in inputs: Loading @@ -367,7 +404,7 @@ def _check_tpl( for var in feat.get("variables", []): tbc_var = TbcVar.parse_obj(var) err_count += _check_var( tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars ) input_name = tbc_var.input_name(var_prefix) if input_name in inputs: Loading Loading
tbc_check/checker.py +82 −45 Original line number Diff line number Diff line Loading @@ -4,7 +4,7 @@ import re from enum import Enum from logging import Logger from pathlib import Path from typing import Optional, Union from typing import Any, Optional, Union import yaml from pydantic import BaseModel Loading Loading @@ -62,22 +62,37 @@ class TbcVarType(str, Enum): class GlInput(BaseModel): # name: str """GitLab input from the 'specs' section.""" description: Optional[str] = None """Input description.""" type: GlInputType = GlInputType.string """Input type.""" options: Optional[list[str]] = None """Input options (allowed values).""" default: Optional[Union[str, bool, int]] = None """Input default.""" class TbcVar(BaseModel): """to-be-continuous variable from the Kicker declaration.""" name: str """Variable name.""" description: Optional[str] = None """Variable name.""" type: TbcVarType = TbcVarType.text """Variable type.""" values: Optional[list[str]] = None """Variable allowed values (for an enumerated type).""" default: Optional[str] = None """Variable default value.""" advanced: bool = False """Whether the variable is advanced.""" secret: bool = False """Whether the variable is a secret.""" mandatory: bool = False """Whether the variable is mandatory.""" def input_name(self, var_prefix: str) -> str: return ( Loading Loading @@ -111,13 +126,21 @@ class TbcVar(BaseModel): class DocVar(BaseModel): """Variable delcaration from the documentation (README).""" lock: bool """Whether the variable show a lock icon (expected for secrets).""" var_name: str """The variable name.""" input_name: Optional[str] = None """The associated input name.""" description: str """The textual variable description.""" default_cell: str """The textual content of the default value column.""" def default(self, type: GlInputType) -> Optional[str]: """Extracts and converts the variable default value from the cell content.""" code_expr_match = re.match(r"`([^`]*)`", self.default_cell) explicit_default = code_expr_match.group(1) if code_expr_match else None Loading @@ -130,12 +153,12 @@ class DocVar(BaseModel): return explicit_default or "" def _get_var(tpl_desc: dict[str, any], var_name: str) -> Optional[TbcVar]: def _get_var(kicker: dict[str, any], var_name: str) -> Optional[TbcVar]: var = next( iter( [ TbcVar.parse_obj(var) for var in tpl_desc.get("variables", []) for var in kicker.get("variables", []) if var["name"] == var_name ] ), Loading @@ -145,7 +168,7 @@ def _get_var(tpl_desc: dict[str, any], var_name: str) -> Optional[TbcVar]: return var # look into feature variables for feat in tpl_desc.get("features", []): for feat in kicker.get("features", []): if feat.get("enable_with") == var_name: return TbcVar( name=feat.get("enable_with"), Loading Loading @@ -178,21 +201,26 @@ def _check_var( tbc_var: TbcVar, var_prefix: str, tpl_spec: dict[str, any], tpl_impl: dict[str, any], main_tpl_desc: Optional[dict[str, any]], tpl_body: dict[str, any], root_kicker: Optional[dict[str, any]], doc_vars: list[DocVar], ) -> int: """Check variable rules.""" has_no_input = ( tbc_var.secret or main_tpl_desc and _get_var(main_tpl_desc, tbc_var.name) or root_kicker and _get_var(root_kicker, tbc_var.name) or tbc_var.name.startswith("TBC_") ) expected_input_name = tbc_var.input_name(var_prefix) expected_gl_input = tbc_var.to_gl() # check variable is declared in doc err_count = 0 # check variable declaration from doc (warn only) # ----------------------------------- doc_var = next(filter(lambda dv: dv.var_name == tbc_var.name, doc_vars), None) if doc_var == None: if doc_var is None: print( f" {AnsiColors.YELLOW}⚠ <{tbc_var.name}>: not documented in README{AnsiColors.RESET}" ) Loading @@ -219,52 +247,51 @@ def _check_var( # retrieve declared input from template specs declared_input = tpl_spec["spec"]["inputs"].get(expected_input_name) # check cases where variable must not be declared as an input # ----------------------------------------------------------- if tbc_var.secret: # secrets should not be inputs if declared_input: print( f" {AnsiColors.RED}✕ <{tbc_var.name}> is a secret: must not be declared{AnsiColors.RESET}" ) return 1 else: return err_count + 1 print( f" {AnsiColors.HGRAY}✕ <{tbc_var.name}> is a secret: skip{AnsiColors.RESET}" ) return 0 if main_tpl_desc and _get_var(main_tpl_desc, tbc_var.name): return err_count if root_kicker and _get_var(root_kicker, tbc_var.name): # a variant is overriding a variable from the main template: skip if declared_input: print( f" {AnsiColors.RED}✕ <{tbc_var.name}> is an override: must not be declared{AnsiColors.RESET}" ) return 1 else: return err_count + 1 print( f" {AnsiColors.HGRAY}✕ <{tbc_var.name}> is an override: skip{AnsiColors.RESET}" ) return 0 return err_count if tbc_var.name.startswith("TBC_"): # global TBC variable: skip if declared_input: print( f" {AnsiColors.RED}✕ <{tbc_var.name}> is global TBC: must not be declared{AnsiColors.RESET}" ) return 1 else: return err_count + 1 print( f" {AnsiColors.HGRAY}✕ <{tbc_var.name}> is global TBC: skip{AnsiColors.RESET}" ) return 0 return err_count # check if mapped GitLab input is declared if not declared_input: print( f" {AnsiColors.RED}✕ <{tbc_var.name}/{expected_input_name}>: input not found{AnsiColors.RESET}" ) return 1 return err_count + 1 err_count = 0 # check actual input is as expected # check GitLab input is as expected # --------------------------------- actual_gl_input = GlInput.parse_obj(declared_input) if actual_gl_input.type != expected_gl_input.type: Loading @@ -291,7 +318,8 @@ def _check_var( err_count += 1 # check variable is initialized from input actual_variable_value = tpl_impl["variables"].get(tbc_var.name) # ---------------------------------------- actual_variable_value = tpl_body["variables"].get(tbc_var.name) expected_variable_value = f"$[[ inputs.{expected_input_name} ]]" if not actual_variable_value: print( Loading @@ -304,6 +332,7 @@ def _check_var( ) err_count += 1 # print an OK message if no error was found if err_count == 0: print( f" {AnsiColors.GREEN}✓{AnsiColors.RESET} <{tbc_var.name}/{expected_input_name}>: OK" Loading @@ -313,33 +342,41 @@ def _check_var( def _check_tpl( tpl_desc, main_tpl_desc, project_dir: Path, var_prefix: str, doc_vars: list[DocVar] kicker: dict[str, any], root_kicker: Optional[dict[str, any]], project_dir: Path, var_prefix: str, doc_vars: list[DocVar], ) -> int: tpl_path = project_dir / tpl_desc["template_path"] """Check a template (either main or variant).""" tpl_path = project_dir / kicker["template_path"] if not tpl_path.exists(): print(f"{AnsiColors.RED}ERROR: Template file ({tpl_path}) not found: abort") exit(1) # load template with open(tpl_path, "r") as reader: all_tpl = list(yaml.load_all(reader, Loader=yaml.BaseLoader)) tpl_spec = all_tpl[0] tpl_impl = all_tpl[-1] tpl_parts: list[dict[str, Any]] = list( yaml.load_all(reader, Loader=yaml.BaseLoader) ) tpl_spec: dict[str, Any] = tpl_parts[0] tpl_body: dict[str, Any] = tpl_parts[-1] inputs: dict[str, dict[str, any]] = dict(tpl_spec["spec"]["inputs"]) err_count = 0 # check main template variables for var in tpl_desc.get("variables", []): for var in kicker.get("variables", []): tbc_var = TbcVar.parse_obj(var) err_count += _check_var( tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars ) input_name = tbc_var.input_name(var_prefix) if input_name in inputs: del inputs[input_name] # check feature variables for feat in tpl_desc.get("features", []): for feat in kicker.get("features", []): if feat.get("enable_with"): tbc_var = TbcVar( name=feat.get("enable_with"), Loading @@ -347,7 +384,7 @@ def _check_tpl( type=TbcVarType.boolean, ) err_count += _check_var( tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars ) input_name = tbc_var.input_name(var_prefix) if input_name in inputs: Loading @@ -359,7 +396,7 @@ def _check_tpl( type=TbcVarType.boolean, ) err_count += _check_var( tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars ) input_name = tbc_var.input_name(var_prefix) if input_name in inputs: Loading @@ -367,7 +404,7 @@ def _check_tpl( for var in feat.get("variables", []): tbc_var = TbcVar.parse_obj(var) err_count += _check_var( tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars ) input_name = tbc_var.input_name(var_prefix) if input_name in inputs: Loading