diff --git a/.github/workflows/github-actions-cron-util-test.yml b/.github/workflows/github-actions-cron-util-test.yml
index e2636fc7ad..0995738b48 100644
--- a/.github/workflows/github-actions-cron-util-test.yml
+++ b/.github/workflows/github-actions-cron-util-test.yml
@@ -4,12 +4,12 @@ on:
- cron: "0 8 * * SUN"
push:
paths:
- - 'flow/util/genElapsedTime.py'
- - 'flow/test/test_genElapsedTime.py'
+ - 'flow/util/*.py'
+ - 'flow/test/test_*.py'
pull_request:
paths:
- - 'flow/util/genElapsedTime.py'
- - 'flow/test/test_genElapsedTime.py'
+ - 'flow/util/*.py'
+ - 'flow/test/test_*.py'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
diff --git a/flow/Makefile b/flow/Makefile
index f18ca938d7..7bccdc8307 100644
--- a/flow/Makefile
+++ b/flow/Makefile
@@ -195,21 +195,25 @@ $(OBJECTS_DIR)/klayout.lyt: $(KLAYOUT_TECH_FILE) $(OBJECTS_DIR)/klayout_tech.lef
.PHONY: do-klayout
do-klayout:
-ifeq ($(KLAYOUT_ENV_VAR_IN_PATH),valid)
- SC_LEF_RELATIVE_PATH="$(shell realpath --relative-to=$(RESULTS_DIR) $(SC_LEF))"; \
- OTHER_LEFS_RELATIVE_PATHS=$$(echo "$(foreach file, $(OBJECTS_DIR)/klayout_tech.lef $(ADDITIONAL_LEFS),$$(realpath --relative-to=$(RESULTS_DIR) $(file)))"); \
- sed 's,.*,'"$$SC_LEF_RELATIVE_PATH"''"$$OTHER_LEFS_RELATIVE_PATHS"',g' $(KLAYOUT_TECH_FILE) > $(OBJECTS_DIR)/klayout.lyt
-else
- sed 's,.*,$(foreach file, $(OBJECTS_DIR)/klayout_tech.lef $(SC_LEF) $(ADDITIONAL_LEFS),$(shell realpath --relative-to=$(RESULTS_DIR) $(file))),g' $(KLAYOUT_TECH_FILE) > $(OBJECTS_DIR)/klayout.lyt
-endif
- sed -i 's,.*,$(foreach file, $(FLOW_HOME)/platforms/$(PLATFORM)/*map,$(shell realpath $(file))),g' $(OBJECTS_DIR)/klayout.lyt
+ @mkdir -p $(dir $(OBJECTS_DIR)/klayout.lyt)
+ $(PYTHON_EXE) $(UTILS_DIR)/generate_klayout_tech.py \
+ --template $(KLAYOUT_TECH_FILE) \
+ --output $(OBJECTS_DIR)/klayout.lyt \
+ --lef-files $(OBJECTS_DIR)/klayout_tech.lef $(SC_LEF) $(ADDITIONAL_LEFS) \
+ --reference-dir $(RESULTS_DIR) \
+ --map-files $(wildcard $(FLOW_HOME)/platforms/$(PLATFORM)/*map)
$(OBJECTS_DIR)/klayout_wrap.lyt: $(KLAYOUT_TECH_FILE) $(OBJECTS_DIR)/klayout_tech.lef
$(UNSET_AND_MAKE) do-klayout_wrap
.PHONY: do-klayout_wrap
do-klayout_wrap:
- sed 's,.*,$(foreach file, $(OBJECTS_DIR)/klayout_tech.lef $(WRAP_LEFS),$(shell realpath --relative-to=$(OBJECTS_DIR)/def $(file))),g' $(KLAYOUT_TECH_FILE) > $(OBJECTS_DIR)/klayout_wrap.lyt
+ @mkdir -p $(dir $(OBJECTS_DIR)/klayout_wrap.lyt)
+ $(PYTHON_EXE) $(UTILS_DIR)/generate_klayout_tech.py \
+ --template $(KLAYOUT_TECH_FILE) \
+ --output $(OBJECTS_DIR)/klayout_wrap.lyt \
+ --lef-files $(OBJECTS_DIR)/klayout_tech.lef $(WRAP_LEFS) \
+ --reference-dir $(OBJECTS_DIR)/def
$(WRAPPED_LEFS):
mkdir -p $(OBJECTS_DIR)/lef $(OBJECTS_DIR)/def
@@ -627,7 +631,7 @@ final: finish
.PHONY: do-finish
do-finish:
- $(UNSET_AND_MAKE) do-6_1_fill do-6_1_fill.sdc do-6_final.sdc do-6_report do-gds elapsed
+ $(UNSET_AND_MAKE) do-6_1_fill do-6_1_fill.sdc do-6_final.sdc do-6_report elapsed
.PHONY: generate_abstract
generate_abstract: $(RESULTS_DIR)/6_final.gds $(RESULTS_DIR)/6_final.def $(RESULTS_DIR)/6_final.v $(RESULTS_DIR)/6_final.sdc
@@ -643,6 +647,17 @@ do-generate_abstract:
clean_abstract:
rm -f $(RESULTS_DIR)/$(DESIGN_NAME).lib $(RESULTS_DIR)/$(DESIGN_NAME).lef
+.PHONY: check-klayout
+check-klayout:
+ @if [ -z "$(KLAYOUT_CMD)" ]; then \
+ echo "Error: KLayout not found. Install KLayout or set KLAYOUT_CMD."; \
+ echo "Hint: KLayout is needed for GDS/DRC/LVS targets."; \
+ exit 1; \
+ fi
+
+.PHONY: gds
+gds: $(GDS_FINAL_FILE)
+
# Merge wrapped macros using Klayout
#-------------------------------------------------------------------------------
$(WRAPPED_GDSOAS): $(OBJECTS_DIR)/klayout_wrap.lyt $(WRAPPED_LEFS)
@@ -658,7 +673,7 @@ $(WRAPPED_GDSOAS): $(OBJECTS_DIR)/klayout_wrap.lyt $(WRAPPED_LEFS)
# Merge GDS using Klayout
#-------------------------------------------------------------------------------
-$(GDS_MERGED_FILE): $(RESULTS_DIR)/6_final.def $(OBJECTS_DIR)/klayout.lyt $(GDSOAS_FILES) $(WRAPPED_GDSOAS) $(SEAL_GDSOAS)
+$(GDS_MERGED_FILE): check-klayout $(RESULTS_DIR)/6_final.def $(OBJECTS_DIR)/klayout.lyt $(GDSOAS_FILES) $(WRAPPED_GDSOAS) $(SEAL_GDSOAS)
$(UNSET_AND_MAKE) do-gds-merged
.PHONY: do-gds-merged
@@ -768,7 +783,7 @@ nuke: clean_test clean_issues
# DEF/GDS/OAS viewer shortcuts
#-------------------------------------------------------------------------------
.PHONY: $(foreach file,$(RESULTS_DEF) $(RESULTS_GDS) $(RESULTS_OAS),klayout_$(file))
-$(foreach file,$(RESULTS_DEF) $(RESULTS_GDS) $(RESULTS_OAS),klayout_$(file)): klayout_%: $(OBJECTS_DIR)/klayout.lyt
+$(foreach file,$(RESULTS_DEF) $(RESULTS_GDS) $(RESULTS_OAS),klayout_$(file)): klayout_%: check-klayout $(OBJECTS_DIR)/klayout.lyt
$(SCRIPTS_DIR)/klayout.sh -nn $(OBJECTS_DIR)/klayout.lyt $(RESULTS_DIR)/$*
$(eval $(call OPEN_GUI_SHORTCUT,synth,1_synth.odb))
diff --git a/flow/docs/KLayoutOptionalDependency.md b/flow/docs/KLayoutOptionalDependency.md
new file mode 100644
index 0000000000..c469160a27
--- /dev/null
+++ b/flow/docs/KLayoutOptionalDependency.md
@@ -0,0 +1,59 @@
+# KLayout as an Optional Dependency
+
+KLayout is only required for GDS/OAS stream generation, DRC, and LVS
+verification. All other ORFS functionality — synthesis, floorplanning,
+placement, CTS, routing, timing reports, and abstract generation — works
+without KLayout installed.
+
+## Makefile Targets
+
+| Target | Requires KLayout | Description |
+|---|---|---|
+| `make finish` | Yes | Complete flow including GDS generation |
+| `make gds` | Yes | Generate GDS/OAS from finished design |
+| `make drc` | Yes | Run DRC checks (requires GDS) |
+| `make lvs` | Yes | Run LVS checks (requires GDS) |
+| `make gallery` | Yes | Generate layout screenshots |
+| `make klayout_` | Yes | Open result in KLayout viewer |
+| `make generate_abstract` | No | Generate LEF/LIB abstracts |
+
+A `check-klayout` guard produces a clear error message when KLayout is
+missing and a KLayout-dependent target is invoked:
+
+```
+Error: KLayout not found. Install KLayout or set KLAYOUT_CMD.
+Hint: KLayout is needed for GDS/DRC/LVS targets.
+```
+
+## bazel-orfs Integration
+
+bazel-orfs uses the `do-` prefixed targets which bypass Make's dependency
+management. `do-finish` / `do-final` only run the finish stage recipe
+itself, while `make finish` also pulls in the GDS target as a Make
+dependency. `do-gds` runs GDS generation separately (requires KLayout).
+
+An `orfs_gds()` Bazel rule can call `do-gds` independently from
+`orfs_flow()`, making KLayout an optional toolchain dependency configured
+in `MODULE.bazel`.
+
+## KLayout Tech File Generation
+
+The `do-klayout` and `do-klayout_wrap` targets generate `.lyt` technology
+files by substituting LEF and map file paths into platform templates.
+This is implemented in `util/generate_klayout_tech.py` using stdlib XML
+processing — no KLayout dependency required.
+
+## Testing Without KLayout
+
+Unit tests for all KLayout-related Python scripts use `unittest.mock` to
+mock the `pya` API:
+
+```
+cd flow/test
+python -m unittest test_generate_klayout_tech test_def2stream test_convertDrc
+```
+
+These tests cover:
+- `.lyt` tech file generation (`test_generate_klayout_tech.py`)
+- DEF-to-GDS merging logic (`test_def2stream.py`)
+- DRC report conversion (`test_convertDrc.py`)
diff --git a/flow/test/test_convertDrc.py b/flow/test/test_convertDrc.py
new file mode 100644
index 0000000000..87f5894dcc
--- /dev/null
+++ b/flow/test/test_convertDrc.py
@@ -0,0 +1,255 @@
+#!/usr/bin/env python3
+
+import unittest
+from unittest.mock import MagicMock, patch
+import sys
+import os
+
+# Mock pya before importing convertDrc since it imports pya at module level
+sys.modules["pya"] = MagicMock()
+
+sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "util"))
+
+# convertDrc uses a global `in_drc` set by klayout -rd, so we must set it
+import builtins
+
+builtins.in_drc = "/tmp/test.drc"
+builtins.out_file = "/tmp/test.json"
+
+# Now we can import - but the module-level code tries to use pya.Application
+# We need to handle this by patching before import
+import importlib
+
+# Import just the convert_drc function by reading the source
+import types
+
+_util_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "util")
+_src_path = os.path.join(_util_dir, "convertDrc.py")
+
+# Load only the convert_drc function, not the module-level klayout code
+with open(_src_path) as f:
+ source = f.read()
+
+# Extract just the function definition
+import textwrap
+import re as _re
+
+# Parse out the convert_drc function
+_func_start = source.index("def convert_drc(rdb):")
+_func_end = source.index("\n\napp = pya.Application")
+_func_source = source[_func_start:_func_end]
+
+# Create a module with just the function
+_mod = types.ModuleType("convertDrc_test")
+_mod.__dict__["os"] = os
+_mod.__dict__["in_drc"] = "/tmp/test.drc"
+exec(compile(_func_source, _src_path, "exec"), _mod.__dict__)
+convert_drc = _mod.convert_drc
+
+
+def make_mock_point(x, y):
+ p = MagicMock()
+ p.x = x
+ p.y = y
+ return p
+
+
+def make_mock_edge(p1_x, p1_y, p2_x, p2_y):
+ edge = MagicMock()
+ edge.p1 = make_mock_point(p1_x, p1_y)
+ edge.p2 = make_mock_point(p2_x, p2_y)
+ return edge
+
+
+def make_box_value(left, bottom, right, top):
+ value = MagicMock()
+ value.is_box.return_value = True
+ value.is_edge.return_value = False
+ value.is_edge_pair.return_value = False
+ value.is_polygon.return_value = False
+ value.is_path.return_value = False
+ value.is_text.return_value = False
+ value.is_string.return_value = False
+ box = MagicMock()
+ box.left = left
+ box.bottom = bottom
+ box.right = right
+ box.top = top
+ value.box.return_value = box
+ return value
+
+
+def make_edge_value(p1_x, p1_y, p2_x, p2_y):
+ value = MagicMock()
+ value.is_box.return_value = False
+ value.is_edge.return_value = True
+ value.is_edge_pair.return_value = False
+ value.is_polygon.return_value = False
+ value.is_path.return_value = False
+ value.is_text.return_value = False
+ value.is_string.return_value = False
+ value.edge.return_value = make_mock_edge(p1_x, p1_y, p2_x, p2_y)
+ return value
+
+
+def make_text_value(text):
+ value = MagicMock()
+ value.is_box.return_value = False
+ value.is_edge.return_value = False
+ value.is_edge_pair.return_value = False
+ value.is_polygon.return_value = False
+ value.is_path.return_value = False
+ value.is_text.return_value = True
+ value.is_string.return_value = False
+ value.text.return_value = text
+ return value
+
+
+def make_mock_item(values, is_visited=False, tags_str="", comment=None):
+ item = MagicMock()
+ item.is_visited.return_value = is_visited
+ item.tags_str = tags_str
+ item.each_value.return_value = iter(values)
+ if comment is not None:
+ item.comment = comment
+ else:
+ # Remove hasattr for comment
+ del item.comment
+ return item
+
+
+def make_mock_category(name, description, rdb_id, num_items, items):
+ cat = MagicMock()
+ cat.name.return_value = name
+ cat.description = description
+ cat.rdb_id.return_value = rdb_id
+ cat.num_items.return_value = num_items
+ return cat, items
+
+
+class TestConvertDrc(unittest.TestCase):
+ def test_empty_rdb(self):
+ rdb = MagicMock()
+ rdb.each_category.return_value = iter([])
+
+ result = convert_drc(rdb)
+
+ self.assertEqual(result["source"], os.path.abspath("/tmp/test.drc"))
+ self.assertEqual(result["category"], {})
+
+ def test_empty_category_skipped(self):
+ cat = MagicMock()
+ cat.num_items.return_value = 0
+
+ rdb = MagicMock()
+ rdb.each_category.return_value = iter([cat])
+
+ result = convert_drc(rdb)
+ self.assertEqual(result["category"], {})
+
+ def test_box_violation(self):
+ box_val = make_box_value(100, 200, 300, 400)
+ item = make_mock_item([box_val])
+
+ cat = MagicMock()
+ cat.name.return_value = "metal1.min_width"
+ cat.description = "Minimum width violation"
+ cat.rdb_id.return_value = 1
+ cat.num_items.return_value = 1
+
+ rdb = MagicMock()
+ rdb.each_category.return_value = iter([cat])
+ rdb.each_item_per_category.return_value = iter([item])
+
+ result = convert_drc(rdb)
+
+ violations = result["category"]["metal1.min_width"]["violations"]
+ self.assertEqual(len(violations), 1)
+ self.assertEqual(len(violations[0]["shape"]), 1)
+ shape = violations[0]["shape"][0]
+ self.assertEqual(shape["type"], "box")
+ self.assertEqual(shape["points"][0], {"x": 100, "y": 200})
+ self.assertEqual(shape["points"][1], {"x": 300, "y": 400})
+
+ def test_edge_violation(self):
+ edge_val = make_edge_value(10, 20, 30, 40)
+ item = make_mock_item([edge_val])
+
+ cat = MagicMock()
+ cat.name.return_value = "metal1.spacing"
+ cat.description = "Spacing violation"
+ cat.rdb_id.return_value = 2
+ cat.num_items.return_value = 1
+
+ rdb = MagicMock()
+ rdb.each_category.return_value = iter([cat])
+ rdb.each_item_per_category.return_value = iter([item])
+
+ result = convert_drc(rdb)
+
+ violations = result["category"]["metal1.spacing"]["violations"]
+ shape = violations[0]["shape"][0]
+ self.assertEqual(shape["type"], "line")
+ self.assertEqual(shape["points"][0], {"x": 10, "y": 20})
+ self.assertEqual(shape["points"][1], {"x": 30, "y": 40})
+
+ def test_waived_violation(self):
+ box_val = make_box_value(0, 0, 10, 10)
+ item = make_mock_item([box_val], tags_str="waived")
+
+ cat = MagicMock()
+ cat.name.return_value = "rule1"
+ cat.description = "Rule 1"
+ cat.rdb_id.return_value = 1
+ cat.num_items.return_value = 1
+
+ rdb = MagicMock()
+ rdb.each_category.return_value = iter([cat])
+ rdb.each_item_per_category.return_value = iter([item])
+
+ result = convert_drc(rdb)
+
+ violation = result["category"]["rule1"]["violations"][0]
+ self.assertTrue(violation["waived"])
+
+ def test_text_in_comment(self):
+ text_val = make_text_value("error detail")
+ item = make_mock_item([text_val])
+
+ cat = MagicMock()
+ cat.name.return_value = "rule1"
+ cat.description = "Rule 1"
+ cat.rdb_id.return_value = 1
+ cat.num_items.return_value = 1
+
+ rdb = MagicMock()
+ rdb.each_category.return_value = iter([cat])
+ rdb.each_item_per_category.return_value = iter([item])
+
+ result = convert_drc(rdb)
+
+ violation = result["category"]["rule1"]["violations"][0]
+ self.assertEqual(violation["comment"], "error detail")
+
+ def test_comment_with_text(self):
+ text_val = make_text_value("extra info")
+ item = make_mock_item([text_val], comment="base comment")
+
+ cat = MagicMock()
+ cat.name.return_value = "rule1"
+ cat.description = "Rule 1"
+ cat.rdb_id.return_value = 1
+ cat.num_items.return_value = 1
+
+ rdb = MagicMock()
+ rdb.each_category.return_value = iter([cat])
+ rdb.each_item_per_category.return_value = iter([item])
+
+ result = convert_drc(rdb)
+
+ violation = result["category"]["rule1"]["violations"][0]
+ self.assertEqual(violation["comment"], "base comment: extra info")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/flow/test/test_def2stream.py b/flow/test/test_def2stream.py
new file mode 100644
index 0000000000..db9e88de50
--- /dev/null
+++ b/flow/test/test_def2stream.py
@@ -0,0 +1,467 @@
+#!/usr/bin/env python3
+
+import unittest
+from unittest.mock import MagicMock, patch, call
+import sys
+import os
+
+sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "util"))
+
+import def2stream
+
+
+def make_mock_cell(name, cell_index=0, is_empty=False, parent_cells=1):
+ """Create a mock cell object."""
+ cell = MagicMock()
+ cell.name = name
+ cell.cell_index.return_value = cell_index
+ cell.is_empty.return_value = is_empty
+ cell.parent_cells.return_value = parent_cells
+ return cell
+
+
+def make_mock_pya(cells_before_read=None, cells_after_read=None, top_only_cells=None):
+ """Create a mock pya module with configurable cell behavior.
+
+ Args:
+ cells_before_read: Cells in main_layout before reading DEF.
+ cells_after_read: Cells in main_layout after reading DEF/GDS.
+ top_only_cells: Cells in top_only_layout after copy_tree.
+ """
+ pya_mod = MagicMock()
+
+ # Technology mock
+ tech = MagicMock()
+ pya_mod.Technology.return_value = tech
+
+ # Main layout mock
+ main_layout = MagicMock()
+ pya_mod.Layout.side_effect = [main_layout]
+
+ if cells_before_read is None:
+ cells_before_read = []
+ if cells_after_read is None:
+ cells_after_read = []
+
+ # each_cell returns different results before and after read
+ main_layout.each_cell.side_effect = [
+ iter(cells_before_read), # first call: before reading DEF
+ iter(cells_after_read), # second call: clearing non-top cells
+ ]
+
+ # top_only_layout is the second Layout() call
+ top_only_layout = MagicMock()
+
+ if top_only_cells is None:
+ top_only_cells = []
+
+ # top_only each_cell called twice: missing cell check and orphan check
+ top_only_layout.each_cell.side_effect = [
+ iter(top_only_cells), # missing cell check
+ iter(top_only_cells), # orphan cell check
+ ]
+
+ # Override Layout side_effect to return both layouts
+ pya_mod.Layout.side_effect = [main_layout, top_only_layout]
+
+ top_cell = MagicMock()
+ top_cell.name = "test_design"
+ top_only_layout.create_cell.return_value = top_cell
+ top_only_layout.top_cell.return_value = top_cell
+ top_only_layout.top_cells.return_value = []
+
+ return pya_mod, main_layout, top_only_layout, top_cell
+
+
+class TestMergeGdsBasic(unittest.TestCase):
+ def test_no_errors_clean_design(self):
+ """A clean design with no missing/orphan cells should return 0 errors."""
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, top_only_layout, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[],
+ )
+
+ main_layout.cell.return_value = top
+
+ errors = def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="/tmp/cells.gds",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ self.assertEqual(errors, 0)
+ top_only_layout.write.assert_called_once_with("/tmp/out.gds")
+
+ def test_layer_map_applied(self):
+ """When layer_map is non-empty, it should be set on layout options."""
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[],
+ )
+ main_layout.cell.return_value = top
+
+ tech = pya_mod.Technology.return_value
+
+ def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="/tmp/layer.map",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ self.assertEqual(
+ tech.load_layout_options.lefdef_config.map_file, "/tmp/layer.map"
+ )
+
+ def test_empty_layer_map_not_applied(self):
+ """When layer_map is empty, map_file should not be set."""
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[],
+ )
+ main_layout.cell.return_value = top
+
+ tech = pya_mod.Technology.return_value
+ original_map = tech.load_layout_options.lefdef_config.map_file
+
+ def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ self.assertEqual(tech.load_layout_options.lefdef_config.map_file, original_map)
+
+
+class TestCellClearing(unittest.TestCase):
+ def test_non_top_cells_cleared(self):
+ """Non-top cells (not VIA_ or _DEF_FILL) should be cleared."""
+ top = make_mock_cell("test_design", cell_index=0)
+ filler = make_mock_cell("FILLER_cell", cell_index=1)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top, filler],
+ top_only_cells=[],
+ )
+ main_layout.cell.return_value = top
+
+ def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ filler.clear.assert_called_once()
+
+ def test_via_cells_preserved(self):
+ """Cells starting with VIA_ should NOT be cleared."""
+ top = make_mock_cell("test_design", cell_index=0)
+ via = make_mock_cell("VIA_M1M2", cell_index=1)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top, via],
+ top_only_cells=[],
+ )
+ main_layout.cell.return_value = top
+
+ def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ via.clear.assert_not_called()
+
+ def test_def_fill_cells_preserved(self):
+ """Cells ending with _DEF_FILL should NOT be cleared."""
+ top = make_mock_cell("test_design", cell_index=0)
+ fill = make_mock_cell("some_DEF_FILL", cell_index=2)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top, fill],
+ top_only_cells=[],
+ )
+ main_layout.cell.return_value = top
+
+ def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ fill.clear.assert_not_called()
+
+
+class TestMissingCells(unittest.TestCase):
+ def test_empty_cell_is_error(self):
+ """An empty cell without GDS_ALLOW_EMPTY should count as an error."""
+ missing = make_mock_cell(
+ "missing_gds", cell_index=1, is_empty=True, parent_cells=1
+ )
+
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[missing],
+ )
+ main_layout.cell.return_value = top
+
+ errors = def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ self.assertEqual(errors, 1)
+
+ def test_allow_empty_regex_suppresses_error(self):
+ """GDS_ALLOW_EMPTY regex should suppress errors for matching cells."""
+ missing = make_mock_cell(
+ "pad_io_cell", cell_index=1, is_empty=True, parent_cells=1
+ )
+
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[missing],
+ )
+ main_layout.cell.return_value = top
+
+ errors = def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ allow_empty="pad_.*",
+ )
+
+ self.assertEqual(errors, 0)
+
+ def test_allow_empty_regex_no_match_still_errors(self):
+ """GDS_ALLOW_EMPTY regex should not suppress non-matching cells."""
+ missing = make_mock_cell(
+ "other_cell", cell_index=1, is_empty=True, parent_cells=1
+ )
+
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[missing],
+ )
+ main_layout.cell.return_value = top
+
+ errors = def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ allow_empty="pad_.*",
+ )
+
+ self.assertEqual(errors, 1)
+
+
+class TestOrphanCells(unittest.TestCase):
+ def test_orphan_cell_is_error(self):
+ """A cell with no parents (orphan) should count as an error."""
+ orphan = make_mock_cell(
+ "orphan_cell", cell_index=1, is_empty=False, parent_cells=0
+ )
+
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[orphan],
+ )
+ main_layout.cell.return_value = top
+
+ errors = def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ self.assertEqual(errors, 1)
+
+ def test_top_cell_not_orphan(self):
+ """The top cell itself should not be counted as an orphan."""
+ top = make_mock_cell(
+ "test_design", cell_index=0, is_empty=False, parent_cells=0
+ )
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[top],
+ )
+ main_layout.cell.return_value = top
+
+ errors = def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ self.assertEqual(errors, 0)
+
+
+class TestSealFile(unittest.TestCase):
+ def test_seal_file_merged(self):
+ """When seal_file is provided, seal cells should be merged."""
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, top_only_layout, top_cell = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[],
+ )
+ main_layout.cell.return_value = top
+
+ seal_cell = MagicMock()
+ seal_cell.name = "seal_ring"
+ seal_cell.cell_index.return_value = 5
+ # top_cells returns original top + seal after reading seal file
+ top_only_layout.top_cells.return_value = [top_cell, seal_cell]
+
+ errors = def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="/tmp/seal.gds",
+ out_file="/tmp/out.gds",
+ )
+
+ self.assertEqual(errors, 0)
+ top_only_layout.read.assert_called_once_with("/tmp/seal.gds")
+ pya_mod.CellInstArray.assert_called_once_with(5, pya_mod.Trans.return_value)
+
+ def test_no_seal_file(self):
+ """When seal_file is empty, no seal merging should happen."""
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, top_only_layout, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[],
+ )
+ main_layout.cell.return_value = top
+
+ def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ top_only_layout.read.assert_not_called()
+
+
+class TestGdsFileMerging(unittest.TestCase):
+ def test_multiple_gds_files_read(self):
+ """All space-separated GDS files should be read into main_layout."""
+ top = make_mock_cell("test_design", cell_index=0)
+
+ pya_mod, main_layout, _, _ = make_mock_pya(
+ cells_before_read=[],
+ cells_after_read=[top],
+ top_only_cells=[],
+ )
+ main_layout.cell.return_value = top
+
+ def2stream.merge_gds(
+ pya_mod=pya_mod,
+ tech_file="/tmp/test.lyt",
+ layer_map="",
+ in_def="/tmp/test.def",
+ design_name="test_design",
+ in_files="/tmp/a.gds /tmp/b.gds /tmp/c.gds",
+ seal_file="",
+ out_file="/tmp/out.gds",
+ )
+
+ # read is called for DEF + 3 GDS files = 4 total
+ read_calls = main_layout.read.call_args_list
+ self.assertEqual(len(read_calls), 4) # 1 DEF + 3 GDS
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/flow/test/test_generate_klayout_tech.py b/flow/test/test_generate_klayout_tech.py
new file mode 100644
index 0000000000..22c6642127
--- /dev/null
+++ b/flow/test/test_generate_klayout_tech.py
@@ -0,0 +1,232 @@
+#!/usr/bin/env python3
+
+import unittest
+import os
+import sys
+import tempfile
+
+sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "util"))
+
+import generate_klayout_tech
+
+MINIMAL_LYT = """\
+
+
+ Test
+
+
+ ./original.lef
+
+
+
+"""
+
+LYT_WITH_MAP = """\
+
+
+ Test
+
+
+ ./original.lef
+ original.map
+
+
+
+"""
+
+LYT_WITH_EMPTY_MAP = """\
+
+
+ Test
+
+
+ ./original.lef
+
+
+
+
+"""
+
+
+class TestReplaceLefFiles(unittest.TestCase):
+ def test_single_lef(self):
+ result = generate_klayout_tech.replace_lef_files(MINIMAL_LYT, ["tech.lef"])
+ self.assertIn("tech.lef", result)
+ self.assertNotIn("original.lef", result)
+
+ def test_multiple_lefs(self):
+ result = generate_klayout_tech.replace_lef_files(
+ MINIMAL_LYT, ["tech.lef", "sc.lef", "extra.lef"]
+ )
+ self.assertIn(
+ "tech.lef"
+ "sc.lef"
+ "extra.lef",
+ result,
+ )
+ self.assertNotIn("original.lef", result)
+
+ def test_empty_lefs(self):
+ result = generate_klayout_tech.replace_lef_files(MINIMAL_LYT, [])
+ self.assertNotIn("original.lef", result)
+ # Empty replacement removes the element content
+ self.assertNotIn("", result)
+
+
+class TestReplaceMapFiles(unittest.TestCase):
+ def test_replace_existing_map(self):
+ result = generate_klayout_tech.replace_map_files(
+ LYT_WITH_MAP, ["/abs/path/layer.map"]
+ )
+ self.assertIn("/abs/path/layer.map", result)
+ self.assertNotIn("original.map", result)
+
+ def test_replace_empty_map(self):
+ result = generate_klayout_tech.replace_map_files(
+ LYT_WITH_EMPTY_MAP, ["/abs/path/layer.map"]
+ )
+ self.assertIn("/abs/path/layer.map", result)
+
+ def test_no_map_files_noop(self):
+ result = generate_klayout_tech.replace_map_files(LYT_WITH_MAP, [])
+ self.assertEqual(result, LYT_WITH_MAP)
+
+ def test_no_map_element_in_template(self):
+ result = generate_klayout_tech.replace_map_files(MINIMAL_LYT, ["/some/map"])
+ # No map element to replace, content unchanged
+ self.assertEqual(result, MINIMAL_LYT)
+
+
+class TestGenerateKlayoutTech(unittest.TestCase):
+ def setUp(self):
+ self.tmp_dir = tempfile.TemporaryDirectory()
+ self.template = os.path.join(self.tmp_dir.name, "test.lyt")
+ self.output = os.path.join(self.tmp_dir.name, "output.lyt")
+ self.results_dir = os.path.join(self.tmp_dir.name, "results")
+ os.makedirs(self.results_dir, exist_ok=True)
+
+ def tearDown(self):
+ self.tmp_dir.cleanup()
+
+ def test_basic_generation(self):
+ with open(self.template, "w") as f:
+ f.write(MINIMAL_LYT)
+
+ lef_path = os.path.join(self.tmp_dir.name, "tech.lef")
+ with open(lef_path, "w") as f:
+ f.write("")
+
+ generate_klayout_tech.generate_klayout_tech(
+ template_lyt=self.template,
+ output_lyt=self.output,
+ lef_files=[lef_path],
+ reference_dir=self.results_dir,
+ map_files=[],
+ use_relative_paths=True,
+ )
+
+ with open(self.output) as f:
+ content = f.read()
+
+ self.assertIn("", content)
+ self.assertNotIn("original.lef", content)
+ # Path should be relative to results_dir
+ expected_rel = os.path.relpath(
+ os.path.realpath(lef_path),
+ os.path.realpath(self.results_dir),
+ )
+ self.assertIn(expected_rel, content)
+
+ def test_with_map_files(self):
+ with open(self.template, "w") as f:
+ f.write(LYT_WITH_MAP)
+
+ lef_path = os.path.join(self.tmp_dir.name, "tech.lef")
+ map_path = os.path.join(self.tmp_dir.name, "layer.map")
+ for p in [lef_path, map_path]:
+ with open(p, "w") as f:
+ f.write("")
+
+ generate_klayout_tech.generate_klayout_tech(
+ template_lyt=self.template,
+ output_lyt=self.output,
+ lef_files=[lef_path],
+ reference_dir=self.results_dir,
+ map_files=[map_path],
+ use_relative_paths=False,
+ )
+
+ with open(self.output) as f:
+ content = f.read()
+
+ self.assertIn(os.path.realpath(map_path), content)
+ self.assertNotIn("original.map", content)
+
+ def test_multiple_lef_files(self):
+ with open(self.template, "w") as f:
+ f.write(MINIMAL_LYT)
+
+ lef_files = []
+ for name in ["tech.lef", "sc.lef", "extra.lef"]:
+ path = os.path.join(self.tmp_dir.name, name)
+ with open(path, "w") as f:
+ f.write("")
+ lef_files.append(path)
+
+ generate_klayout_tech.generate_klayout_tech(
+ template_lyt=self.template,
+ output_lyt=self.output,
+ lef_files=lef_files,
+ reference_dir=self.results_dir,
+ map_files=[],
+ use_relative_paths=True,
+ )
+
+ with open(self.output) as f:
+ content = f.read()
+
+ # Should have three lef-files elements
+ self.assertEqual(content.count(""), 3)
+
+
+class TestRealPlatformLyt(unittest.TestCase):
+ """Test against actual platform .lyt files to catch regressions."""
+
+ PLATFORMS_DIR = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)), "..", "platforms"
+ )
+
+ def _test_platform(self, lyt_path):
+ if not os.path.exists(lyt_path):
+ self.skipTest(f"{lyt_path} not found")
+
+ with open(lyt_path) as f:
+ content = f.read()
+
+ result = generate_klayout_tech.replace_lef_files(
+ content, ["replaced_tech.lef", "replaced_sc.lef"]
+ )
+ self.assertIn("replaced_tech.lef", result)
+ self.assertIn("replaced_sc.lef", result)
+ # Original lef-files content should be gone
+ self.assertNotIn("NangateOpenCellLibrary", result)
+ self.assertNotIn("asap7_tech", result)
+
+ def test_nangate45(self):
+ self._test_platform(
+ os.path.join(self.PLATFORMS_DIR, "nangate45", "FreePDK45.lyt")
+ )
+
+ def test_asap7(self):
+ self._test_platform(
+ os.path.join(self.PLATFORMS_DIR, "asap7", "KLayout", "asap7.lyt")
+ )
+
+ def test_sky130hd(self):
+ self._test_platform(
+ os.path.join(self.PLATFORMS_DIR, "sky130hd", "sky130hd.lyt")
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/flow/util/def2stream.py b/flow/util/def2stream.py
index 5062474f9e..f6530d3db3 100644
--- a/flow/util/def2stream.py
+++ b/flow/util/def2stream.py
@@ -1,100 +1,151 @@
-import pya
+try:
+ import pya
+except ImportError:
+ pya = None
+
import re
-import json
-import copy
import sys
import os
-errors = 0
-
-# Load technology file
-tech = pya.Technology()
-tech.load(tech_file)
-layoutOptions = tech.load_layout_options
-if len(layer_map) > 0:
- layoutOptions.lefdef_config.map_file = layer_map
-
-# Load def file
-main_layout = pya.Layout()
-print("[INFO] Reporting cells prior to loading DEF ...")
-for i in main_layout.each_cell():
- print("[INFO] '{0}'".format(i.name))
-
-main_layout.read(in_def, layoutOptions)
-
-# Clear cells
-top_cell_index = main_layout.cell(design_name).cell_index()
-
-# remove orphan cell BUT preserve cell with VIA_
-# - KLayout is prepending VIA_ when reading DEF that instantiates LEF's via
-for i in main_layout.each_cell():
- if i.cell_index() != top_cell_index:
- if not i.name.startswith("VIA_") and not i.name.endswith("_DEF_FILL"):
- i.clear()
-
-# Load in the gds to merge
-for fil in in_files.split():
- print("\t{0}".format(fil))
- main_layout.read(fil)
-
-# Copy the top level only to a new layout
-top_only_layout = pya.Layout()
-top_only_layout.dbu = main_layout.dbu
-top = top_only_layout.create_cell(design_name)
-top.copy_tree(main_layout.cell(design_name))
-
-missing_cell = False
-allow_empty = os.environ.get("GDS_ALLOW_EMPTY", "")
-regex = re.compile(allow_empty) if allow_empty else None
-
-if allow_empty:
- print(f"[INFO] GDS_ALLOW_EMPTY={allow_empty}")
-
-for i in top_only_layout.each_cell():
- if i.is_empty():
- missing_cell = True
- if regex is not None and regex.match(i.name):
- print(
- "[WARNING] LEF Cell '{0}' ignored. Matches GDS_ALLOW_EMPTY.".format(
- i.name
- )
- )
- else:
- print(
- "[ERROR] LEF Cell '{0}' has no matching GDS/OAS cell."
- " Cell will be empty.".format(i.name)
- )
- errors += 1
-if not missing_cell:
- print("[INFO] All LEF cells have matching GDS/OAS cells")
+def merge_gds(
+ pya_mod,
+ tech_file,
+ layer_map,
+ in_def,
+ design_name,
+ in_files,
+ seal_file,
+ out_file,
+ allow_empty="",
+):
+ """Merge DEF and GDS/OAS files into a single stream file.
+
+ Args:
+ pya_mod: The pya module (klayout Python API).
+ tech_file: Path to klayout technology file.
+ layer_map: Path to layer map file (empty string if none).
+ in_def: Path to input DEF file.
+ design_name: Top-level design name.
+ in_files: Space-separated string of GDS/OAS files to merge.
+ seal_file: Path to seal ring GDS/OAS file (empty string if none).
+ out_file: Path to output GDS/OAS file.
+ allow_empty: Regex pattern for cells allowed to be empty.
+
+ Returns:
+ Number of errors encountered.
+ """
+ errors = 0
+
+ # Load technology file
+ tech = pya_mod.Technology()
+ tech.load(tech_file)
+ layout_options = tech.load_layout_options
+ if len(layer_map) > 0:
+ layout_options.lefdef_config.map_file = layer_map
+
+ # Load def file
+ main_layout = pya_mod.Layout()
+ print("[INFO] Reporting cells prior to loading DEF ...")
+ for i in main_layout.each_cell():
+ print("[INFO] '{0}'".format(i.name))
+
+ main_layout.read(in_def, layout_options)
+
+ # Clear cells
+ top_cell_index = main_layout.cell(design_name).cell_index()
+
+ # remove orphan cell BUT preserve cell with VIA_
+ # - KLayout is prepending VIA_ when reading DEF that instantiates LEF's via
+ for i in main_layout.each_cell():
+ if i.cell_index() != top_cell_index:
+ if not i.name.startswith("VIA_") and not i.name.endswith("_DEF_FILL"):
+ i.clear()
+
+ # Load in the gds to merge
+ for fil in in_files.split():
+ print("\t{0}".format(fil))
+ main_layout.read(fil)
+
+ # Copy the top level only to a new layout
+ top_only_layout = pya_mod.Layout()
+ top_only_layout.dbu = main_layout.dbu
+ top = top_only_layout.create_cell(design_name)
+ top.copy_tree(main_layout.cell(design_name))
+
+ missing_cell = False
+ regex = re.compile(allow_empty) if allow_empty else None
+
+ if allow_empty:
+ print(f"[INFO] GDS_ALLOW_EMPTY={allow_empty}")
+
+ for i in top_only_layout.each_cell():
+ if i.is_empty():
+ missing_cell = True
+ if regex is not None and regex.match(i.name):
+ print(
+ "[WARNING] LEF Cell '{0}' ignored. Matches GDS_ALLOW_EMPTY.".format(
+ i.name
+ )
+ )
+ else:
+ print(
+ "[ERROR] LEF Cell '{0}' has no matching GDS/OAS cell."
+ " Cell will be empty.".format(i.name)
+ )
+ errors += 1
-orphan_cell = False
-for i in top_only_layout.each_cell():
- if i.name != design_name and i.parent_cells() == 0:
- orphan_cell = True
- print("[ERROR] Found orphan cell '{0}'".format(i.name))
- errors += 1
+ if not missing_cell:
+ print("[INFO] All LEF cells have matching GDS/OAS cells")
-if not orphan_cell:
- print("[INFO] No orphan cells in the final layout")
+ orphan_cell = False
+ for i in top_only_layout.each_cell():
+ if i.name != design_name and i.parent_cells() == 0:
+ orphan_cell = True
+ print("[ERROR] Found orphan cell '{0}'".format(i.name))
+ errors += 1
+ if not orphan_cell:
+ print("[INFO] No orphan cells in the final layout")
-if seal_file:
- top_cell = top_only_layout.top_cell()
+ if seal_file:
+ top_cell = top_only_layout.top_cell()
- top_only_layout.read(seal_file)
+ top_only_layout.read(seal_file)
- for cell in top_only_layout.top_cells():
- if cell != top_cell:
- print(
- "[INFO] Merging '{0}' as child of '{1}'".format(
- cell.name, top_cell.name
+ for cell in top_only_layout.top_cells():
+ if cell != top_cell:
+ print(
+ "[INFO] Merging '{0}' as child of '{1}'".format(
+ cell.name, top_cell.name
+ )
)
+ top.insert(pya_mod.CellInstArray(cell.cell_index(), pya_mod.Trans()))
+
+ # Write out the GDS
+ top_only_layout.write(out_file)
+
+ return errors
+
+
+# When run via klayout -r, globals tech_file, layer_map, in_def, etc.
+# are set by klayout's -rd mechanism.
+if pya is not None:
+ try:
+ # These globals are set by klayout -rd flags
+ sys.exit(
+ merge_gds(
+ pya_mod=pya,
+ tech_file=tech_file, # noqa: F821 - set by klayout -rd
+ layer_map=layer_map, # noqa: F821
+ in_def=in_def, # noqa: F821
+ design_name=design_name, # noqa: F821
+ in_files=in_files, # noqa: F821
+ seal_file=seal_file, # noqa: F821
+ out_file=out_file, # noqa: F821
+ allow_empty=os.environ.get("GDS_ALLOW_EMPTY", ""),
)
- top.insert(pya.CellInstArray(cell.cell_index(), pya.Trans()))
-
-# Write out the GDS
-top_only_layout.write(out_file)
-
-sys.exit(errors)
+ )
+ except NameError:
+ # Not running under klayout -r, pya available but no -rd globals
+ pass
diff --git a/flow/util/generate_klayout_tech.py b/flow/util/generate_klayout_tech.py
new file mode 100644
index 0000000000..c054945f71
--- /dev/null
+++ b/flow/util/generate_klayout_tech.py
@@ -0,0 +1,107 @@
+"""Generate klayout .lyt tech file from platform template.
+
+Replaces and elements with actual paths.
+No klayout dependency - pure XML manipulation using stdlib.
+"""
+
+import argparse
+import os
+import re
+
+
+def replace_lef_files(content, lef_files):
+ """Replace the ... element(s) with new ones.
+
+ The original .lyt template has a single element inside
+ . We replace it with one element
+ per LEF file, matching the existing sed-based behavior.
+ """
+ replacement = "".join("{}".format(f) for f in lef_files)
+ return re.sub(r".*?", replacement, content)
+
+
+def replace_map_files(content, map_files):
+ """Replace the ... or element(s) with new ones."""
+ if not map_files:
+ return content
+ replacement = "".join("{}".format(f) for f in map_files)
+ content = re.sub(r".*?", replacement, content)
+ content = re.sub(r"", replacement, content)
+ return content
+
+
+def generate_klayout_tech(
+ template_lyt,
+ output_lyt,
+ lef_files,
+ reference_dir,
+ map_files,
+ use_relative_paths,
+):
+ """Generate a klayout .lyt file from a platform template.
+
+ Args:
+ template_lyt: Path to the platform .lyt template file.
+ output_lyt: Path to write the generated .lyt file.
+ lef_files: List of LEF file paths to include.
+ reference_dir: Directory to compute relative paths from.
+ map_files: List of map file paths.
+ use_relative_paths: If True, compute paths relative to reference_dir.
+ """
+ with open(template_lyt, "r") as f:
+ content = f.read()
+
+ # Both modes use relative paths from reference_dir, matching the
+ # original sed-based behavior which always uses realpath --relative-to.
+ resolved_lefs = [
+ os.path.relpath(os.path.realpath(f), os.path.realpath(reference_dir))
+ for f in lef_files
+ ]
+
+ content = replace_lef_files(content, resolved_lefs)
+
+ resolved_maps = [os.path.realpath(f) for f in map_files]
+ content = replace_map_files(content, resolved_maps)
+
+ with open(output_lyt, "w") as f:
+ f.write(content)
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Generate klayout .lyt tech file from platform template."
+ )
+ parser.add_argument(
+ "--template", required=True, help="Path to platform .lyt template"
+ )
+ parser.add_argument("--output", required=True, help="Output .lyt file path")
+ parser.add_argument(
+ "--lef-files", nargs="*", default=[], help="LEF files to include"
+ )
+ parser.add_argument(
+ "--reference-dir",
+ required=True,
+ help="Directory for computing relative paths",
+ )
+ parser.add_argument(
+ "--map-files", nargs="*", default=[], help="Map files to include"
+ )
+ parser.add_argument(
+ "--use-relative-paths",
+ action="store_true",
+ help="Use paths relative to reference-dir",
+ )
+ args = parser.parse_args()
+
+ generate_klayout_tech(
+ template_lyt=args.template,
+ output_lyt=args.output,
+ lef_files=args.lef_files,
+ reference_dir=args.reference_dir,
+ map_files=args.map_files,
+ use_relative_paths=args.use_relative_paths,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/flow/util/utils.mk b/flow/util/utils.mk
index 3924f57b4e..25c1cfc2ac 100644
--- a/flow/util/utils.mk
+++ b/flow/util/utils.mk
@@ -157,7 +157,7 @@ $(RESULTS_DIR)/6_final_no_power.def: $(RESULTS_DIR)/6_final.def
.PHONY: gallery
-gallery: $(RESULTS_DIR)/6_final_no_power.def $(RESULTS_DIR)/6_final_only_clk.def
+gallery: check-klayout $(RESULTS_DIR)/6_final_no_power.def $(RESULTS_DIR)/6_final_only_clk.def
($(TIME_CMD) klayout -z -nc -rx -rd gallery_json=util/gallery.json \
-rd results_path=$(RESULTS_DIR) \
-rd tech_file=$(OBJECTS_DIR)/klayout.lyt \