diff --git a/chipflow_lib/__init__.py b/chipflow_lib/__init__.py index 9993d08d..a364b9f3 100644 --- a/chipflow_lib/__init__.py +++ b/chipflow_lib/__init__.py @@ -1,8 +1,9 @@ import importlib.metadata -import jsonschema import os import sys import tomli +from pathlib import Path +from pydantic import ValidationError __version__ = importlib.metadata.version("chipflow_lib") @@ -21,7 +22,7 @@ def _get_cls_by_reference(reference, context): return getattr(module_obj, class_ref) except AttributeError as e: raise ChipFlowError(f"Module `{module_ref}` referenced by {context} does not define " - f"`{class_ref}`") from e + f"`{class_ref}`") from e def _ensure_chipflow_root(): @@ -32,114 +33,31 @@ def _ensure_chipflow_root(): return os.environ["CHIPFLOW_ROOT"] -# TODO: convert to pydantic, one truth of source for the schema -config_schema = { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://chipflow.io/meta/chipflow.toml.schema.json", - "title": "chipflow.toml", - "type": "object", - "required": [ - "chipflow" - ], - "properties": { - "chipflow": { - "type": "object", - "required": [ - "steps", - "silicon" - ], - "additionalProperties": False, - "properties": { - "project_name": { - "type": "string", - }, - "top": { - "type": "object", - }, - "steps": { - "type": "object", - }, - "clocks": { - "type": "object", - "patternPropertues": { - ".+": {"type": "string"} - }, - }, - "resets": { - "type": "object", - "patternPropertues": { - ".+": {"type": "string"} - }, - }, - "silicon": { - "type": "object", - "required": [ - "process", - "package", - ], - "additionalProperties": False, - "properties": { - "process": { - "type": "string", - "enum": ["sky130", "gf180", "customer1", "gf130bcd", "ihp_sg13g2"] - }, - "package": { - "enum": ["caravel", "cf20", "pga144"] - }, - "pads": {"$ref": "#/$defs/pin"}, - "power": {"$ref": "#/$defs/pin"}, - "debug": { - "type": "object", - "properties": { - "heartbeat": {"type": "boolean"} - } - } - }, - }, - }, - }, - }, - "$defs": { - "pin": { - "type": "object", - "additionalProperties": False, - "minProperties": 1, - "patternProperties": { - ".+": { - "type": "object", - "required": [ - "type", - "loc", - ], - "additionalProperties": False, - "properties": { - "type": { - "enum": ["io", "i", "o", "oe", "clock", "reset", "power", "ground"] - }, - "loc": { - "type": "string", - "pattern": "^[NSWE]?[0-9]+$" - }, - } - } - } - } - } -} - - def _parse_config(): + """Parse the chipflow.toml configuration file.""" chipflow_root = _ensure_chipflow_root() - config_file = f"{chipflow_root}/chipflow.toml" + config_file = Path(chipflow_root) / "chipflow.toml" return _parse_config_file(config_file) def _parse_config_file(config_file): + """Parse a specific chipflow.toml configuration file.""" + from .config_models import Config + with open(config_file, "rb") as f: config_dict = tomli.load(f) try: - jsonschema.validate(config_dict, config_schema) - return config_dict - except jsonschema.ValidationError as e: - raise ChipFlowError(f"Syntax error in `chipflow.toml` at `{'.'.join(e.path)}`: {e.message}") + # Validate with Pydantic + Config.model_validate(config_dict) # Just validate the config_dict + return config_dict # Return the original dict for backward compatibility + except ValidationError as e: + # Format Pydantic validation errors in a user-friendly way + error_messages = [] + for error in e.errors(): + location = ".".join(str(loc) for loc in error["loc"]) + message = error["msg"] + error_messages.append(f"Error at '{location}': {message}") + + error_str = "\n".join(error_messages) + raise ChipFlowError(f"Validation error in chipflow.toml:\n{error_str}") diff --git a/docs/chipflow-toml-guide.rst b/docs/chipflow-toml-guide.rst index 7113588b..255569a1 100644 --- a/docs/chipflow-toml-guide.rst +++ b/docs/chipflow-toml-guide.rst @@ -57,15 +57,12 @@ You probably won't need to change these if you're starting from an example repos .. code-block:: TOML [chipflow.silicon] - processes = [ - "ihp_sg13g2", - "gf130bcd" - ] + process = "ihp_sg13g2" package = "pga144" -The ``silicon`` section sets the Foundry ``processes`` (i.e. PDKs) that we are targeting for manufacturing, and the physical ``package`` we want to place our design inside. -You'll choose the ``processes`` and ``package`` based in the requirements of your design. +The ``silicon`` section sets the Foundry ``process`` (i.e. PDK) that we are targeting for manufacturing, and the physical ``package`` we want to place our design inside. +You'll choose the ``process`` and ``package`` based in the requirements of your design. Available processes ------------------- diff --git a/tests/test_config_models.py b/tests/test_config_models.py new file mode 100644 index 00000000..4afdf3ae --- /dev/null +++ b/tests/test_config_models.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: BSD-2-Clause +import os +import unittest + +from chipflow_lib.config_models import Config, PadConfig +from chipflow_lib.platforms.utils import Process + + +class ConfigModelsTestCase(unittest.TestCase): + def setUp(self): + os.environ["CHIPFLOW_ROOT"] = os.path.dirname(os.path.dirname(__file__)) + + # Create a valid config dict directly to test the model + self.valid_config_dict = { + "chipflow": { + "project_name": "test-chip", + "steps": { + "silicon": "chipflow_lib.steps.silicon:SiliconStep" + }, + "top": {}, + "silicon": { + "process": "sky130", + "package": "cf20", + "pads": { + "sys_clk": {"type": "clock", "loc": "114"} + }, + "power": { + "vdd": {"type": "power", "loc": "1"} + } + } + } + } + + def test_config_validation(self): + """Test that the Config model validates a known-good config.""" + config = Config.model_validate(self.valid_config_dict) + self.assertEqual(config.chipflow.project_name, "test-chip") + self.assertEqual(config.chipflow.silicon.package, "cf20") + self.assertEqual(config.chipflow.silicon.process, Process.SKY130) + + def test_pad_config(self): + """Test validation of pad configuration.""" + pad = PadConfig(type="clock", loc="114") + self.assertEqual(pad.type, "clock") + self.assertEqual(pad.loc, "114") + + # Test validation of loc format + with self.assertRaises(ValueError): + PadConfig(type="clock", loc="invalid-format") + + def test_nested_structure(self): + """Test the nested structure of the Config model.""" + config = Config.model_validate(self.valid_config_dict) + + # Test silicon configuration + silicon = config.chipflow.silicon + self.assertEqual(silicon.package, "cf20") + + # Test pads + self.assertEqual(len(silicon.pads), 1) + pad = silicon.pads["sys_clk"] + self.assertEqual(pad.type, "clock") + self.assertEqual(pad.loc, "114") + + # Test power + self.assertEqual(len(silicon.power), 1) + power = silicon.power["vdd"] + self.assertEqual(power.type, "power") + self.assertEqual(power.loc, "1") diff --git a/tests/test_init.py b/tests/test_init.py index 1fb841c4..7d7a4beb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -121,7 +121,7 @@ def test_parse_config_file_invalid_schema(self): with self.assertRaises(ChipFlowError) as cm: _parse_config_file(config_path) - self.assertIn("Syntax error in `chipflow.toml`", str(cm.exception)) + self.assertIn("Validation error in chipflow.toml", str(cm.exception)) @mock.patch("chipflow_lib._ensure_chipflow_root") @mock.patch("chipflow_lib._parse_config_file") @@ -133,6 +133,9 @@ def test_parse_config(self, mock_parse_config_file, mock_ensure_chipflow_root): config = _parse_config() mock_ensure_chipflow_root.assert_called_once() - # We're expecting a string, not a Path - mock_parse_config_file.assert_called_once_with("/mock/chipflow/root/chipflow.toml") - self.assertEqual(config, {"chipflow": {"test": "value"}}) \ No newline at end of file + # Accept either string or Path object + self.assertEqual(mock_parse_config_file.call_args[0][0].as_posix() + if hasattr(mock_parse_config_file.call_args[0][0], 'as_posix') + else mock_parse_config_file.call_args[0][0], + "/mock/chipflow/root/chipflow.toml") + self.assertEqual(config, {"chipflow": {"test": "value"}}) diff --git a/tests/test_parse_config.py b/tests/test_parse_config.py new file mode 100644 index 00000000..1af4349d --- /dev/null +++ b/tests/test_parse_config.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: BSD-2-Clause +import os +import unittest +from pathlib import Path + +from chipflow_lib import _parse_config_file +from chipflow_lib.config_models import Config + + +class ParseConfigTestCase(unittest.TestCase): + def setUp(self): + os.environ["CHIPFLOW_ROOT"] = os.path.dirname(os.path.dirname(__file__)) + current_dir = os.path.dirname(__file__) + self.example_config = Path(os.environ["CHIPFLOW_ROOT"]) / "docs" / "example-chipflow.toml" + self.mock_config = Path(current_dir) / "fixtures" / "mock.toml" + + def test_example_config_parsing(self): + """Test that the example chipflow.toml can be parsed with our Pydantic models.""" + if self.example_config.exists(): + config_dict = _parse_config_file(self.example_config) + self.assertIn("chipflow", config_dict) + self.assertIn("silicon", config_dict["chipflow"]) + + # Validate using Pydantic model + config = Config.model_validate(config_dict) + self.assertEqual(config.chipflow.project_name, "test-chip") + self.assertEqual(config.chipflow.silicon.package, "pga144") + self.assertEqual(str(config.chipflow.silicon.process), "GF130BCD") + + def test_mock_config_parsing(self): + """Test that the mock chipflow.toml can be parsed with our Pydantic models.""" + if self.mock_config.exists(): + config_dict = _parse_config_file(self.mock_config) + self.assertIn("chipflow", config_dict) + self.assertIn("silicon", config_dict["chipflow"]) + + # Validate using Pydantic model + config = Config.model_validate(config_dict) + self.assertEqual(config.chipflow.project_name, "proj-name") + self.assertEqual(config.chipflow.silicon.package, "pga144") + + # Check that our model correctly handles the legacy format + self.assertIn("sys_clk", config.chipflow.silicon.pads) + self.assertEqual(config.chipflow.silicon.pads["sys_clk"].type, "clock") + + # Check power pins (should be auto-assigned type='power') + self.assertIn("vss", config.chipflow.silicon.power) + self.assertEqual(config.chipflow.silicon.power["vss"].type, "power") + + +if __name__ == "__main__": + unittest.main()