From 03ca42307fe15fae1933fe8aa7df0ea30455de07 Mon Sep 17 00:00:00 2001 From: Uri Shaked Date: Fri, 3 Oct 2025 18:25:42 +0300 Subject: [PATCH] feat(tt_tools): --check-docs now prints all errors Previously, it only printed the first error it encountered, causing more iterations when fixing info.yaml and info.md errors --- project.py | 19 ----- project_checks.py | 69 ++++++++++++++++++ project_info.py | 167 ++++++++++++++++++++++++++----------------- test_project_info.py | 98 +++++++++++++++++++++++++ tt_tool.py | 9 +-- 5 files changed, 272 insertions(+), 90 deletions(-) create mode 100644 project_checks.py diff --git a/project.py b/project.py index 3dc55d3..6e6b43a 100644 --- a/project.py +++ b/project.py @@ -668,25 +668,6 @@ class Project: logging.info(f"Bitstream created successfully: {build_dir}/{base_name}.bin") - # doc check - # makes sure that the basic info is present - def check_docs(self): - info_md = os.path.join(self.local_dir, "docs/info.md") - if not os.path.exists(info_md): - logging.error("Missing docs/info.md file") - exit(1) - - with open(info_md) as fh: - info_md_content = fh.read() - - if "# How it works\n\nExplain how your project works" in info_md_content: - logging.error("Missing 'How it works' section in docs/info.md") - exit(1) - - if "# How to test\n\nExplain how to use your project" in info_md_content: - logging.error("Missing 'How to test' section in docs/info.md") - exit(1) - # use pandoc to create a single page PDF preview def create_pdf(self): template_args = copy.deepcopy(self.info.__dict__) diff --git a/project_checks.py b/project_checks.py new file mode 100644 index 0000000..05e4bd7 --- /dev/null +++ b/project_checks.py @@ -0,0 +1,69 @@ +import os +import sys + +from project_info import ProjectInfo, ProjectYamlError + + +def check_info_md(project_dir: str) -> list[str]: + info_md = os.path.join(project_dir, "docs/info.md") + if not os.path.exists(info_md): + return ["Missing docs/info.md file"] + + with open(info_md) as fh: + info_md_content = fh.read() + + errors = [] + if "# How it works\n\nExplain how your project works" in info_md_content: + errors += ["Missing 'How it works' section in docs/info.md"] + + if "# How to test\n\nExplain how to use your project" in info_md_content: + errors += ["Missing 'How to test' section in docs/info.md"] + + return errors + + +def check_info_yaml(project_dir: str, pdk: str) -> list[str]: + import yaml + + info_yaml = os.path.join(project_dir, "info.yaml") + if not os.path.exists(info_yaml): + return ["Missing info.yaml file"] + + with open(info_yaml) as fh: + try: + yaml_data = yaml.safe_load(fh) + except yaml.YAMLError as e: + return [f"Error parsing info.yaml: {e}"] + + tile_sizes_yaml = f"tech/{pdk}/tile_sizes.yaml" + tt_tools_dir = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(tt_tools_dir, tile_sizes_yaml), "r") as f: + tile_sizes = yaml.safe_load(f) + + try: + _ = ProjectInfo(yaml_data, tile_sizes) + except Exception as e: + if isinstance(e, ProjectYamlError): + return e.errors + + return [] # No errors + + +def check_project_docs(project_dir: str, pdk: str) -> None: + info_md_errors = check_info_md(project_dir) + info_yaml_errors = check_info_yaml(project_dir, pdk) + errors = info_md_errors + info_yaml_errors + if errors: + if info_yaml_errors: + print("## Errors in info.yaml") + for error in info_yaml_errors: + print(f"- {error}") + print() + if info_md_errors: + print("## Errors in docs/info.md") + for error in info_md_errors: + print(f"- {error}") + print() + sys.exit(1) + else: + print("Documentation check passed successfully.") diff --git a/project_info.py b/project_info.py index 89735ec..6a3cf2b 100644 --- a/project_info.py +++ b/project_info.py @@ -4,26 +4,35 @@ YAML_VERSION = 6 class ProjectYamlError(Exception): - pass + def __init__(self, errors: List[str]): + if isinstance(errors, str): + errors = [errors] + self.errors = errors + super().__init__(", ".join(errors)) class PinoutSection: - def __init__(self, yaml_data: Dict[str, Any]): + def __init__(self, yaml_data: Dict[str, Any], errors: List[str]): self.__nonEmptyPins = 0 yaml_data = yaml_data.copy() - self.ui = self._pins(yaml_data, "ui", 8) - self.uo = self._pins(yaml_data, "uo", 8) - self.uio = self._pins(yaml_data, "uio", 8) - self.ua = self._pins(yaml_data, "ua", 6, True) + self.ui = self._pins(yaml_data, "ui", 8, errors) + self.uo = self._pins(yaml_data, "uo", 8, errors) + self.uio = self._pins(yaml_data, "uio", 8, errors) + self.ua = self._pins(yaml_data, "ua", 6, errors, True) if self.__nonEmptyPins == 0: - raise ProjectYamlError("Please fill in the 'pinout' section") + errors.append("Please fill in the 'pinout' section") if len(yaml_data) > 0: - raise ProjectYamlError( + errors.append( f"Invalid keys {list(yaml_data.keys())} in 'pinout' section. Please remove them." ) def _pins( - self, yaml_data: Dict[str, Any], name: str, count: int, optional: bool = False + self, + yaml_data: Dict[str, Any], + name: str, + count: int, + errors: List[str], + optional: bool = False, ) -> List[str]: result: List[str] = [] for i in range(count): @@ -32,7 +41,9 @@ class PinoutSection: if pin is None: if optional and i == 0: break - raise ProjectYamlError(f"Missing '{name}[{i}]' in 'pinout' section") + errors.append(f"Missing '{name}[{i}]' in 'pinout' section") + # Continue to collect more errors + continue if pin != "": self.__nonEmptyPins += 1 result.append(pin) @@ -45,113 +56,135 @@ class ProjectInfo: source_files: List[str] def __init__(self, yaml_data: Dict[str, Any], tile_sizes: Dict[str, str]): + errors: List[str] = [] + # Validate Version yaml_version = yaml_data.get("yaml_version") if yaml_version is None: - raise ProjectYamlError("Missing 'yaml_version'") - if yaml_version != YAML_VERSION: - raise ProjectYamlError( + errors.append("Missing 'yaml_version'") + elif yaml_version != YAML_VERSION: + errors.append( f"Unsupported YAML version: {yaml_data['yaml_version']}, expected {YAML_VERSION}" ) # Read "project" Section project_section: Optional[Dict[str, Any]] = yaml_data.get("project") if project_section is None: - raise ProjectYamlError("Missing 'project' section") + errors.append("Missing 'project' section") + # Can't continue without project section + raise ProjectYamlError(errors) + # Validate all required fields in project section title = project_section.get("title") if title is None: - raise ProjectYamlError("Missing key 'title' in 'project' section") - if title == "": - raise ProjectYamlError("Project title cannot be empty") - self.title: str = title + errors.append("Missing key 'title' in 'project' section") + elif title == "": + errors.append("Project title cannot be empty") + else: + self.title: str = title author = project_section.get("author") if author is None: - raise ProjectYamlError("Missing key 'author' in 'project' section") - if author == "": - raise ProjectYamlError("Project author cannot be empty") - self.author: str = author + errors.append("Missing key 'author' in 'project' section") + elif author == "": + errors.append("Project author cannot be empty") + else: + self.author: str = author description = project_section.get("description") if description is None: - raise ProjectYamlError("Missing key 'description' in 'project' section") - if description == "": - raise ProjectYamlError("Project description cannot be empty") - self.description: str = description + errors.append("Missing key 'description' in 'project' section") + elif description == "": + errors.append("Project description cannot be empty") + else: + self.description: str = description tiles = project_section.get("tiles") if tiles is None: - raise ProjectYamlError("Missing key 'tiles' in 'project' section") - if tiles not in tile_sizes.keys(): - raise ProjectYamlError( - f"Invalid value for 'tiles' in 'project' section: {tiles}" - ) - self.tiles: str = tiles + errors.append("Missing key 'tiles' in 'project' section") + elif tiles not in tile_sizes.keys(): + errors.append(f"Invalid value for 'tiles' in 'project' section: {tiles}") + else: + self.tiles: str = tiles analog_pins = project_section.get("analog_pins", 0) if not isinstance(analog_pins, int): - raise ProjectYamlError( + errors.append( "Invalid value for 'analog_pins' in 'project' section, must be an integer" ) - if analog_pins < 0 or analog_pins > 6: - raise ProjectYamlError( + analog_pins = 0 # Set default for further validation + elif analog_pins < 0 or analog_pins > 6: + errors.append( "Invalid value for 'analog_pins' in 'project' section, must be between 0 and 6" ) + analog_pins = 0 # Set default for further validation self.analog_pins: int = analog_pins self.is_analog: bool = self.analog_pins > 0 - self.uses_3v3: bool = project_section.get("uses_3v3", False) - if self.uses_3v3 and not self.is_analog: - raise ProjectYamlError( - "Projects with 3v3 power need at least one analog pin" - ) + uses_3v3: bool = project_section.get("uses_3v3", False) + self.uses_3v3: bool = uses_3v3 + if uses_3v3 and not self.is_analog: + errors.append("Projects with 3v3 power need at least one analog pin") language = project_section.get("language") if language is None: - raise ProjectYamlError("Missing key 'language' in 'project' section") - if language == "": - raise ProjectYamlError("Project language cannot be empty") - self.language: str = language + errors.append("Missing key 'language' in 'project' section") + language = "" # Set default for further validation + elif language == "": + errors.append("Project language cannot be empty") + else: + self.language: str = language + # Language-specific validation if language == "Wokwi": wokwi_id = project_section.get("wokwi_id") if wokwi_id is None: - raise ProjectYamlError("Missing key 'wokwi_id' in 'project' section") - if wokwi_id == "" or wokwi_id == "0": - raise ProjectYamlError("Please provide a valid Wokwi project ID") - self.wokwi_id: Optional[int] = wokwi_id - self.top_module = f"tt_um_wokwi_{wokwi_id}" - self.source_files = [f"tt_um_wokwi_{self.wokwi_id}.v", f"cells.v"] - else: + errors.append("Missing key 'wokwi_id' in 'project' section") + elif wokwi_id == "" or wokwi_id == "0": + errors.append("Please provide a valid Wokwi project ID") + else: + self.wokwi_id: Optional[int] = wokwi_id + self.top_module = f"tt_um_wokwi_{wokwi_id}" + self.source_files = [f"tt_um_wokwi_{self.wokwi_id}.v", f"cells.v"] + elif language != "": + # Only validate these if language is not empty (error already added above) top_module = project_section.get("top_module") if top_module is None: - raise ProjectYamlError("Missing key 'top_module' in 'project' section") - if not top_module.startswith("tt_um_"): - raise ProjectYamlError( + errors.append("Missing key 'top_module' in 'project' section") + elif not top_module.startswith("tt_um_"): + errors.append( "Top module must start with 'tt_um_' (e.g. tt_um_my_project)" ) - self.top_module = top_module + else: + self.top_module = top_module if "source_files" not in project_section: - raise ProjectYamlError( - "Missing key 'source_files' in 'project' section" - ) - if len(project_section["source_files"]) == 0: - raise ProjectYamlError("No source files specified") - self.source_files = project_section["source_files"] + errors.append("Missing key 'source_files' in 'project' section") + elif len(project_section["source_files"]) == 0: + errors.append("No source files specified") + else: + self.source_files = project_section["source_files"] + # Validate clock_hz if "clock_hz" not in project_section: - raise ProjectYamlError("Missing key 'clock_hz' in 'project' section") - if not isinstance(project_section["clock_hz"], int): - raise ProjectYamlError( + errors.append("Missing key 'clock_hz' in 'project' section") + elif not isinstance(project_section["clock_hz"], int): + errors.append( "Invalid value for 'clock_hz' in 'project' section, must be an integer" ) - self.clock_hz: int = project_section["clock_hz"] + else: + self.clock_hz: int = project_section["clock_hz"] + # Validate pinout section if "pinout" not in yaml_data: - raise ProjectYamlError("Missing 'pinout' section") - self.pinout = PinoutSection(yaml_data["pinout"]) + errors.append("Missing 'pinout' section") + else: + self.pinout = PinoutSection(yaml_data["pinout"], errors) + # Optional fields self.discord: Optional[str] = project_section.get("discord") self.doc_link: Optional[str] = project_section.get("doc_link") + + # Raise all errors if any were collected + if errors: + raise ProjectYamlError(errors) diff --git a/test_project_info.py b/test_project_info.py index cdc9ea6..e867c2b 100644 --- a/test_project_info.py +++ b/test_project_info.py @@ -422,6 +422,104 @@ class TestProjectInfoValidCombinations: assert project_info.pinout.uio[0] == "" +class TestMultipleErrors: + """Test that multiple errors are collected and reported together""" + + def test_multiple_project_errors(self, valid_yaml_data, tile_sizes): + """Test that multiple errors in project section are collected""" + del valid_yaml_data["project"]["title"] + del valid_yaml_data["project"]["author"] + valid_yaml_data["project"]["description"] = "" + + with pytest.raises(ProjectYamlError) as exc_info: + ProjectInfo(valid_yaml_data, tile_sizes) + + assert sorted(exc_info.value.errors) == sorted( + [ + "Missing key 'title' in 'project' section", + "Missing key 'author' in 'project' section", + "Project description cannot be empty", + ] + ) + + def test_multiple_pinout_errors(self, valid_yaml_data, tile_sizes): + """Test that multiple errors in pinout section are collected""" + del valid_yaml_data["pinout"]["ui[0]"] + del valid_yaml_data["pinout"]["uo[3]"] + del valid_yaml_data["pinout"]["uio[7]"] + + with pytest.raises(ProjectYamlError) as exc_info: + ProjectInfo(valid_yaml_data, tile_sizes) + + assert sorted(exc_info.value.errors) == sorted( + [ + "Missing 'ui[0]' in 'pinout' section", + "Missing 'uo[3]' in 'pinout' section", + "Missing 'uio[7]' in 'pinout' section", + ] + ) + + def test_errors_across_sections(self, valid_yaml_data, tile_sizes): + """Test that errors across different sections are all collected""" + del valid_yaml_data["yaml_version"] + del valid_yaml_data["project"]["title"] + valid_yaml_data["project"]["analog_pins"] = -1 + del valid_yaml_data["pinout"]["ui[0]"] + + with pytest.raises(ProjectYamlError) as exc_info: + ProjectInfo(valid_yaml_data, tile_sizes) + + assert sorted(exc_info.value.errors) == sorted( + [ + "Missing 'yaml_version'", + "Missing key 'title' in 'project' section", + "Invalid value for 'analog_pins' in 'project' section, must be between 0 and 6", + "Missing 'ui[0]' in 'pinout' section", + ] + ) + + def test_comprehensive_validation_errors(self, valid_yaml_data, tile_sizes): + """Test a comprehensive set of validation errors""" + valid_yaml_data["yaml_version"] = 999 + valid_yaml_data["project"]["title"] = "" + valid_yaml_data["project"]["author"] = "" + valid_yaml_data["project"]["tiles"] = "invalid" + valid_yaml_data["project"]["analog_pins"] = 10 + del valid_yaml_data["project"]["clock_hz"] + del valid_yaml_data["pinout"]["uo[0]"] + valid_yaml_data["pinout"]["invalid_key"] = "value" + + with pytest.raises(ProjectYamlError) as exc_info: + ProjectInfo(valid_yaml_data, tile_sizes) + + assert sorted(exc_info.value.errors) == sorted( + [ + "Unsupported YAML version: 999, expected 6", + "Project title cannot be empty", + "Project author cannot be empty", + "Invalid value for 'tiles' in 'project' section: invalid", + "Invalid value for 'analog_pins' in 'project' section, must be between 0 and 6", + "Missing key 'clock_hz' in 'project' section", + "Missing 'uo[0]' in 'pinout' section", + "Invalid keys ['invalid_key'] in 'pinout' section. Please remove them.", + ] + ) + + def test_error_message_formatting(self, valid_yaml_data, tile_sizes): + """Test that error message contains all errors separated by commas""" + del valid_yaml_data["project"]["title"] + del valid_yaml_data["project"]["author"] + + with pytest.raises(ProjectYamlError) as exc_info: + ProjectInfo(valid_yaml_data, tile_sizes) + + error_message = str(exc_info.value) + assert ( + "Missing key 'title' in 'project' section, Missing key 'author' in 'project' section" + == error_message + ) + + class TestProjectInfoIntegration: """Integration tests with real YAML files""" diff --git a/tt_tool.py b/tt_tool.py index 02a3e52..03c7901 100755 --- a/tt_tool.py +++ b/tt_tool.py @@ -4,6 +4,7 @@ import logging import sys from project import Project +from project_checks import check_project_docs from tech import TechName if __name__ == "__main__": @@ -167,13 +168,13 @@ if __name__ == "__main__": log.addHandler(ch) pdk: TechName = "ihp-sg13g2" if args.ihp else "gf180mcuD" if args.gf else "sky130A" + + if args.check_docs: + check_project_docs(args.project_dir, pdk) + project = Project(0, "unknown", args.project_dir, pdk, is_user_project=True) project.post_clone_setup() - # handle the options - if args.check_docs: - project.check_docs() - if args.print_cell_summary or args.print_cell_category: project.summarize( print_cell_category=args.print_cell_category,