[BuildLibrary] allow prefixing/renaming/postprocessing in BuildCellsView

This commit is contained in:
Jan Petykiewicz 2026-08-28 20:25:14 -07:00
commit 890e7e5c0f
3 changed files with 611 additions and 40 deletions

View file

@ -45,6 +45,16 @@ def main() -> None:
builder = LibraryBuilder() builder = LibraryBuilder()
cells = builder.cells cells = builder.cells
# Naming and export policy can be bound once without changing individual
# declarations. For example, a foundry-specific group could use:
#
# cells = builder.cells.prefixed(
# 'foundry_',
# facade=lambda proxy: devices.ports_to_data(proxy),
# )
#
# Dynamic names use the same view: cells[generated_name] = cell(factory)(...)
# #
# Load some devices from a GDS file # Load some devices from a GDS file
# #

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from functools import wraps from functools import wraps
from types import MappingProxyType from types import MappingProxyType
@ -16,7 +17,7 @@ from .mapping import Library, LibraryView
from .overlay import OverlayLibrary from .overlay import OverlayLibrary
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterator, KeysView, Mapping, Sequence from collections.abc import Callable, Iterator, KeysView, Sequence
from ..pattern import Pattern from ..pattern import Pattern
@ -76,8 +77,8 @@ class BuildReport:
@dataclass @dataclass
class _BuildRecipe: class _BuildRecipe:
""" Captured deferred call to a pattern factory. """ """ Captured deferred call to a pattern or tree factory. """
func: Callable[..., Pattern] func: Callable[..., Pattern | TreeView]
args: tuple[Any, ...] args: tuple[Any, ...]
kwargs: dict[str, Any] kwargs: dict[str, Any]
explicit_dependencies: tuple[str, ...] = () explicit_dependencies: tuple[str, ...] = ()
@ -87,9 +88,16 @@ class _BuildRecipe:
return self return self
def cell(func: Callable[..., Pattern]) -> Callable[..., _BuildRecipe]: @dataclass(frozen=True)
class _BuildDeclaration:
"""One deferred or direct declaration plus its implementation transform."""
value: Pattern | TreeView | _BuildRecipe
postprocess: Callable[[Pattern], Pattern] | None = None
def cell(func: Callable[..., Pattern | TreeView]) -> Callable[..., _BuildRecipe]:
""" """
Wrap a plain pattern factory so calls return deferred build recipes. Wrap a plain Pattern or single-top tree factory so calls return deferred build recipes.
Use as either `cell(fn)(...)` or `@cell`. Use as either `cell(fn)(...)` or `@cell`.
""" """
@ -111,19 +119,138 @@ class _LibraryPlaceholder:
return '<LibraryBuilder.library>' return '<LibraryBuilder.library>'
def _identity_name(name: str) -> str:
return name
def _make_facade(
library: ILibrary,
target: str,
transform: Callable[[Pattern], Pattern],
) -> Pattern:
from ..pattern import Pattern # noqa: PLC0415
implementation = library[target]
proxy = Pattern(ports=implementation.ports).ref(target)
result = transform(proxy)
if not isinstance(result, Pattern):
raise BuildError(f'Facade transform returned {type(result).__name__}, expected Pattern')
return result
class BuildCellsView: class BuildCellsView:
""" """
Attribute-based declaration namespace for `LibraryBuilder`. Attribute-based declaration namespace for `LibraryBuilder`.
This is the ergonomic authoring surface exposed as `builder.cells`. It is This is the ergonomic authoring surface exposed as `builder.cells`. It is
intentionally write-focused: attribute assignment and deletion register intentionally write-focused: attribute or item assignment and deletion
declarations, while attribute reads fail with guidance to build first and register declarations, while reads fail with guidance to build first and
use the returned library. use the returned library.
"""
__slots__ = ('_library',)
def __init__(self, library: LibraryBuilder) -> None: `prefixed()` and `renamed()` create independent write-through views over
the same builder. Their optional `postprocess` callable transforms the
implementation Pattern. If a name changes, their optional `facade`
callable transforms a generated Pattern with copied ports and a reference
to the implementation.
Example:
`cells = builder.cells.prefixed(prefix, facade=encode_ports)`
`cells.device = cell(make_device)(width=10)`
`cells[generated_name] = cell(make_device)(width=20)`
"""
__slots__ = ('_facade', '_library', '_postprocess', '_rename')
def __init__(
self,
library: LibraryBuilder,
*,
rename: Callable[[str], str] = _identity_name,
facade: Callable[[Pattern], Pattern] | None = None,
postprocess: Callable[[Pattern], Pattern] | None = None,
) -> None:
object.__setattr__(self, '_library', library) object.__setattr__(self, '_library', library)
object.__setattr__(self, '_rename', rename)
object.__setattr__(self, '_facade', facade)
object.__setattr__(self, '_postprocess', postprocess)
def prefixed(
self,
prefix: str | None,
*,
facade: Callable[[Pattern], Pattern] | None = None,
postprocess: Callable[[Pattern], Pattern] | None = None,
) -> BuildCellsView:
"""
Return an independent write-through view which conditionally prefixes names.
Names which already start with a non-empty `prefix` are left unchanged.
`None` and the empty string select the identity naming policy.
Args:
prefix: Prefix for implementation names.
facade: Transform applied to a generated logical-name proxy when
prefixing changes a name.
postprocess: Transform applied to the implementation Pattern on
each validation or build.
"""
if prefix is not None and not isinstance(prefix, str):
raise TypeError('Build cell prefixes must be strings or None.')
self._validate_transform('facade', facade)
self._validate_transform('postprocess', postprocess)
def add_prefix(name: str) -> str:
if not prefix or name.startswith(prefix):
return name
return prefix + name
return BuildCellsView(
self._library,
rename=add_prefix,
facade=facade,
postprocess=postprocess,
)
def renamed(
self,
rename: Callable[[str], str],
*,
facade: Callable[[Pattern], Pattern] | None = None,
postprocess: Callable[[Pattern], Pattern] | None = None,
) -> BuildCellsView:
"""
Return an independent write-through view using `rename(name)` for output names.
`rename` must be deterministic and return a string. `facade` and
`postprocess` have the same behavior as in `prefixed()`.
"""
if not callable(rename):
raise TypeError('Build cell name transforms must be callable.')
self._validate_transform('facade', facade)
self._validate_transform('postprocess', postprocess)
return BuildCellsView(
self._library,
rename=rename,
facade=facade,
postprocess=postprocess,
)
@staticmethod
def _validate_transform(
description: str,
transform: Callable[[Pattern], Pattern] | None,
) -> None:
if transform is not None and not callable(transform):
raise TypeError(f'Build cell {description} transforms must be callable or None.')
def _physical_name(self, name: str) -> str:
if not isinstance(name, str):
raise TypeError('Build cell names must be strings.')
physical_name = self._rename(name)
if not isinstance(physical_name, str):
raise TypeError(
f'Build cell name transform returned {type(physical_name).__name__}, expected str.'
)
return physical_name
def __getattr__(self, name: str) -> Pattern: def __getattr__(self, name: str) -> Pattern:
raise BuildError( raise BuildError(
@ -131,16 +258,47 @@ class BuildCellsView:
'Call build() and index the returned library instead.' 'Call build() and index the returned library instead.'
) )
def __setattr__(self, name: str, value: Pattern | _BuildRecipe) -> None: def __getitem__(self, name: str) -> Pattern:
if name == '_library': raise BuildError(
object.__setattr__(self, name, value) f'LibraryBuilder.cells[{name!r}] is write-only during authoring. '
return 'Call build() and index the returned library instead.'
self._library[name] = value )
def __setitem__(
self,
name: str,
value: Pattern | TreeView | _BuildRecipe,
) -> None:
self._library._assert_editable()
physical_name = self._physical_name(name)
self._library._register_view_declaration(
logical_name=name,
physical_name=physical_name,
value=value,
facade=self._facade,
postprocess=self._postprocess,
)
def __delitem__(self, name: str) -> None:
self._library._assert_editable()
physical_name = self._physical_name(name)
if physical_name != name:
emitted = [physical_name]
if self._facade is not None:
emitted.append(name)
raise BuildError(
f'Cannot delete renamed build declaration {name!r} through this view; '
f'it emitted {emitted}. Delete concrete names through builder.cells[...] instead.'
)
del self._library[name]
def __setattr__(self, name: str, value: Pattern | TreeView | _BuildRecipe) -> None:
self[name] = value
def __delattr__(self, name: str) -> None: def __delattr__(self, name: str) -> None:
if name == '_library': if name in self.__slots__:
raise AttributeError(name) raise AttributeError(name)
del self._library[name] del self[name]
class LibraryBuilder(INameView): class LibraryBuilder(INameView):
@ -148,8 +306,8 @@ class LibraryBuilder(INameView):
Two-phase declaration surface for mixed imported/generated libraries. Two-phase declaration surface for mixed imported/generated libraries.
A `LibraryBuilder` collects three kinds of inputs: A `LibraryBuilder` collects three kinds of inputs:
- direct declared `Pattern` objects - direct declared `Pattern` objects or single-top trees
- deferred recipes created with `cell(...)` - deferred Pattern or tree recipes created with `cell(...)`
- imported source-backed library views added with `add_source(...)` - imported source-backed library views added with `add_source(...)`
The builder itself is not a normal readable library during authoring. The builder itself is not a normal readable library during authoring.
@ -164,7 +322,7 @@ class LibraryBuilder(INameView):
self._library_placeholder = _LibraryPlaceholder(self) self._library_placeholder = _LibraryPlaceholder(self)
self._frozen = False self._frozen = False
self._building = False self._building = False
self._declarations: dict[str, Pattern | _BuildRecipe] = {} self._declarations: dict[str, _BuildDeclaration] = {}
self._sources: list[tuple[ILibraryView, dict[str, str]]] = [] self._sources: list[tuple[ILibraryView, dict[str, str]]] = []
self._names: dict[str, None] = {} self._names: dict[str, None] = {}
@ -194,11 +352,16 @@ class LibraryBuilder(INameView):
def __setitem__( def __setitem__(
self, self,
key: str, key: str,
value: Pattern | _BuildRecipe, value: Pattern | TreeView | _BuildRecipe,
) -> None: ) -> None:
self._assert_editable() self._register_declarations(((key, value, None),))
if key in self._names:
raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!') def _prepare_declaration(
self,
value: Pattern | TreeView | _BuildRecipe,
postprocess: Callable[[Pattern], Pattern] | None,
) -> _BuildDeclaration:
from ..pattern import Pattern # noqa: PLC0415
if isinstance(value, _BuildRecipe): if isinstance(value, _BuildRecipe):
placeholders = ( placeholders = (
@ -207,14 +370,57 @@ class LibraryBuilder(INameView):
) )
if any(placeholder is not self.library for placeholder in placeholders): if any(placeholder is not self.library for placeholder in placeholders):
raise BuildError('A recipe cannot use another LibraryBuilder.library placeholder.') raise BuildError('A recipe cannot use another LibraryBuilder.library placeholder.')
declaration = value elif callable(value):
else:
if callable(value):
raise TypeError('LibraryBuilder recipes must be wrapped with cell(fn)(...) or @cell.') raise TypeError('LibraryBuilder recipes must be wrapped with cell(fn)(...) or @cell.')
declaration = value elif not isinstance(value, Pattern | Mapping):
raise TypeError(
f'LibraryBuilder declarations must be a Pattern, tree, or cell recipe, not {type(value).__name__}.'
)
return _BuildDeclaration(value=value, postprocess=postprocess)
self._declarations[key] = declaration def _register_declarations(
self._names[key] = None self,
entries: Sequence[tuple[str, Pattern | TreeView | _BuildRecipe, Callable[[Pattern], Pattern] | None]],
) -> None:
self._assert_editable()
entry_names = [name for name, _value, _postprocess in entries]
if any(not isinstance(name, str) for name in entry_names):
raise TypeError('Build cell names must be strings.')
if len(set(entry_names)) != len(entry_names):
raise LibraryError(f'Duplicate names in build declaration: {entry_names}')
conflicts = [name for name in entry_names if name in self._names]
if conflicts:
raise LibraryError(f'Build declaration names already exist: {conflicts}')
prepared = [
(name, self._prepare_declaration(value, postprocess))
for name, value, postprocess in entries
]
for name, declaration in prepared:
self._declarations[name] = declaration
self._names[name] = None
def _register_view_declaration(
self,
*,
logical_name: str,
physical_name: str,
value: Pattern | TreeView | _BuildRecipe,
facade: Callable[[Pattern], Pattern] | None,
postprocess: Callable[[Pattern], Pattern] | None,
) -> None:
entries: list[tuple[str, Pattern | TreeView | _BuildRecipe, Callable[[Pattern], Pattern] | None]] = [
(physical_name, value, postprocess),
]
if physical_name != logical_name and facade is not None:
facade_recipe = _BuildRecipe(
func=_make_facade,
args=(self.library, physical_name, facade),
kwargs={},
explicit_dependencies=(physical_name,),
)
entries.append((logical_name, facade_recipe, None))
self._register_declarations(entries)
def __delitem__(self, key: str) -> None: def __delitem__(self, key: str) -> None:
self._assert_editable() self._assert_editable()
@ -431,6 +637,23 @@ class LibraryBuilder(INameView):
return session, report return session, report
class _NamesWithout(INameView):
"""Name view which temporarily makes one reserved declaration available."""
def __init__(self, names: INameView, omitted: str) -> None:
self._names = names
self._omitted = omitted
def __iter__(self) -> Iterator[str]:
return (name for name in self._names if name != self._omitted)
def __len__(self) -> int:
return len(self._names) - int(self._omitted in self._names)
def __contains__(self, key: object) -> bool:
return key != self._omitted and key in self._names
class _BuildSessionLibrary(ILibrary): class _BuildSessionLibrary(ILibrary):
""" """
Internal overlay-backed library used while a `LibraryBuilder` is executing. Internal overlay-backed library used while a `LibraryBuilder` is executing.
@ -629,6 +852,75 @@ class _BuildSessionLibrary(ILibrary):
return return
raise BuildError(f'Missing dependency "{name}"') raise BuildError(f'Missing dependency "{name}"')
def _apply_postprocess(
self,
name: str,
pattern: Pattern,
postprocess: Callable[[Pattern], Pattern] | None,
) -> Pattern:
from ..pattern import Pattern # noqa: PLC0415
if postprocess is None:
return pattern
result = postprocess(pattern)
if not isinstance(result, Pattern):
raise BuildError(
f'Postprocess transform for "{name}" returned {type(result).__name__}, expected Pattern'
)
return result
def _commit_declared_tree(
self,
name: str,
tree: TreeView,
postprocess: Callable[[Pattern], Pattern] | None,
) -> Pattern:
from ..pattern import map_targets # noqa: PLC0415
view = tree if isinstance(tree, ILibraryView) else LibraryView(tree)
source_order = tuple(view.source_order())
temp = Library(copy.deepcopy({source_name: view[source_name] for source_name in source_order}))
tops = temp.tops()
if len(tops) != 1:
raise BuildError(f'Tree declaration for "{name}" must have exactly one topcell, found {tops}')
old_top = tops[0]
source_order = (old_top, *(source_name for source_name in temp if source_name != old_top))
def rename_tree_source(names: INameView, source_name: str) -> str:
if source_name == old_top:
return name
if source_name in names:
return _rename_patterns(names, source_name)
return source_name
source_to_visible = _plan_source_names(
_NamesWithout(self, name),
source_order,
rename_theirs=rename_tree_source,
rename_when='always',
)
temp.mapping[old_top] = self._apply_postprocess(name, temp[old_top], postprocess)
rename_map = _source_rename_map(source_to_visible)
if rename_map:
target_map = cast('dict[str | None, str | None]', rename_map)
remapped_refs = {
source_name: map_targets(temp[source_name].refs, lambda target: target_map.get(target, target))
for source_name in source_order
}
for source_name, refs in remapped_refs.items():
temp[source_name].refs = refs
for source_name in source_order:
visible_name = source_to_visible[source_name]
self[visible_name] = temp[source_name]
if source_name not in (old_top, visible_name):
self._provenance[visible_name] = replace(
self._provenance[visible_name],
requested_name=source_name,
)
return temp[old_top]
def _ensure_declared(self, name: str) -> None: def _ensure_declared(self, name: str) -> None:
from ..pattern import Pattern # noqa: PLC0415 from ..pattern import Pattern # noqa: PLC0415
@ -639,30 +931,44 @@ class _BuildSessionLibrary(ILibrary):
raise BuildError(f'Cycle detected while building declared cells: {chain}') raise BuildError(f'Cycle detected while building declared cells: {chain}')
declaration = self._builder._declarations[name] declaration = self._builder._declarations[name]
value = declaration.value
self._declared_stack.append(name) self._declared_stack.append(name)
try: try:
if isinstance(declaration, _BuildRecipe): if isinstance(value, _BuildRecipe):
for dep in declaration.explicit_dependencies: for dep in value.explicit_dependencies:
self._ensure_named(dep) self._ensure_named(dep)
args = tuple( args = tuple(
self if arg is self._builder.library else arg self if arg is self._builder.library else arg
for arg in declaration.args for arg in value.args
) )
kwargs = { kwargs = {
key: self if value is self._builder.library else value key: self if arg_value is self._builder.library else arg_value
for key, value in declaration.kwargs.items() for key, arg_value in value.kwargs.items()
} }
pattern = declaration.func(*args, **kwargs) result = value.func(*args, **kwargs)
if not isinstance(pattern, Pattern):
raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern') # noqa: TRY301
else: else:
pattern = declaration.deepcopy() result = value.deepcopy() if isinstance(value, Pattern) else value
if isinstance(result, Pattern):
published_pattern = result
pattern = self._apply_postprocess(name, result, declaration.postprocess)
elif isinstance(result, Mapping):
pattern = self._commit_declared_tree(name, result, declaration.postprocess)
published_pattern = pattern
else:
raise BuildError( # noqa: TRY301
f'Recipe for "{name}" returned {type(result).__name__}, expected Pattern or single-top tree'
)
if name in self._overlay: if name in self._overlay:
if self._overlay[name] is not pattern: existing = self._overlay[name]
if existing is not published_pattern:
raise BuildError( # noqa: TRY301 raise BuildError( # noqa: TRY301
f'Recipe for "{name}" wrote a different pattern into the session under its own name.' f'Recipe for "{name}" wrote a different pattern into the session under its own name.'
) )
if existing is not pattern:
del self._overlay[name]
self._overlay[name] = pattern
else: else:
self[name] = pattern self[name] = pattern
self._built.add(name) self._built.add(name)

View file

@ -21,6 +21,7 @@ from ..library import (
) )
from ..pattern import Pattern from ..pattern import Pattern
from ..ports import Port from ..ports import Port
from ..utils.ports2data import ports_to_data
def _owned_by(report: BuildReport, owner: str) -> set[str]: def _owned_by(report: BuildReport, owner: str) -> set[str]:
@ -832,3 +833,257 @@ def test_build_session_subtree_preserves_type_and_builds_deferred_dependencies()
assert child_calls == 1 assert child_calls == 1
assert set(built) == {"top", "child", "leaf", "unrelated"} assert set(built) == {"top", "child", "leaf", "unrelated"}
def test_build_cells_prefixed_view_generates_facades_for_attributes_and_items() -> None:
builder = LibraryBuilder()
cells = builder.cells.prefixed(
"vendor_",
facade=lambda proxy: ports_to_data(proxy, (10, 2)),
)
cells.fixed = cell(Pattern)(ports={"p": Port((1, 2), 0)})
dynamic_name = "dynamic"
cells[dynamic_name] = cell(Pattern)(ports={"q": Port((3, 4), 0)})
built, report = builder.build(output="library")
assert set(built) == {"vendor_fixed", "fixed", "vendor_dynamic", "dynamic"}
assert set(built["fixed"].refs) == {"vendor_fixed"}
assert set(built["dynamic"].refs) == {"vendor_dynamic"}
assert set(built["fixed"].ports) == {"p"}
assert len(built["fixed"].labels[(10, 2)]) == 1
assert not built["vendor_fixed"].labels
assert report.dependency_graph["fixed"] == frozenset({"vendor_fixed"})
assert report.dependency_graph["dynamic"] == frozenset({"vendor_dynamic"})
def test_build_cells_prefixed_view_postprocesses_only_the_implementation() -> None:
source = Pattern(ports={"p": Port((0, 0), 0)})
builder = LibraryBuilder()
physical = builder.cells.prefixed(
"vendor_",
postprocess=lambda pattern: ports_to_data(pattern, (20, 1)),
)
physical["cell"] = source
unprefixed = builder.cells.prefixed(
None,
postprocess=lambda pattern: ports_to_data(pattern, (21, 1)),
)
unprefixed["local"] = Pattern(ports={"q": Port((1, 1), 0)})
built, _report = builder.build(output="library")
assert set(built) == {"vendor_cell", "local"}
assert len(built["vendor_cell"].labels[(20, 1)]) == 1
assert len(built["local"].labels[(21, 1)]) == 1
assert not source.labels
def test_build_cells_postprocess_precedes_facade_port_copy() -> None:
builder = LibraryBuilder()
def add_port(pattern: Pattern) -> Pattern:
pattern.ports["added"] = Port((5, 6), 0)
return pattern
cells = builder.cells.prefixed(
"impl_",
postprocess=add_port,
facade=lambda pattern: ports_to_data(pattern, (30, 0)),
)
cells.device = Pattern()
built, _report = builder.build(output="library")
assert set(built["impl_device"].ports) == {"added"}
assert set(built["device"].ports) == {"added"}
assert built["device"].labels[(30, 0)][0].string.startswith("added:")
def test_build_cells_facade_is_ignored_when_name_does_not_change() -> None:
builder = LibraryBuilder()
def fail_if_called(_pattern: Pattern) -> Pattern:
raise AssertionError("facade should not be called")
cells = builder.cells.prefixed("vendor_", facade=fail_if_called)
cells["vendor_cell"] = Pattern()
built, _report = builder.build(output="library")
assert set(built) == {"vendor_cell"}
def test_build_cells_renamed_views_are_independent_and_accept_dynamic_names() -> None:
builder = LibraryBuilder()
upper = builder.cells.renamed(str.upper)
suffixed = builder.cells.renamed(lambda name: name + "_v2")
builder.cells.plain = Pattern()
upper["upper"] = Pattern()
suffixed["other"] = Pattern()
built, _report = builder.build(output="library")
assert set(built) == {"plain", "UPPER", "other_v2"}
def test_build_cells_policy_registration_is_atomic() -> None:
builder = LibraryBuilder()
builder.cells.public = Pattern()
prefixed = builder.cells.prefixed("impl_", facade=lambda pattern: pattern)
with pytest.raises(LibraryError, match="already exist"):
prefixed.public = Pattern()
assert set(builder) == {"public"}
assert "impl_public" not in builder
def test_build_cells_policy_views_validate_names_and_transforms() -> None:
builder = LibraryBuilder()
invalid_name = builder.cells.renamed(lambda _name: None) # type: ignore[arg-type,return-value]
with pytest.raises(TypeError, match="expected str"):
invalid_name["cell"] = Pattern()
with pytest.raises(TypeError, match="must be callable"):
builder.cells.prefixed("x_", facade=object()) # type: ignore[arg-type]
assert not builder
def test_build_cells_view_deletion_requires_concrete_names_after_rename() -> None:
builder = LibraryBuilder()
prefixed = builder.cells.prefixed("impl_", facade=lambda pattern: pattern)
prefixed["cell"] = Pattern()
with pytest.raises(BuildError, match="Delete concrete names"):
del prefixed["cell"]
del builder.cells["cell"]
del builder.cells["impl_cell"]
builder.cells["plain"] = Pattern()
del builder.cells["plain"]
assert not builder
def test_build_cells_item_reads_remain_write_only() -> None:
builder = LibraryBuilder()
builder.cells["cell"] = Pattern()
with pytest.raises(BuildError, match="write-only"):
_ = builder.cells["cell"]
def test_build_cells_prefixed_view_normalizes_recipe_tree_and_generates_facade() -> None:
builder = LibraryBuilder()
builder.cells["_helper"] = Pattern()
cells = builder.cells.prefixed(
"impl_",
postprocess=lambda pattern: Pattern(ports={"out": Port((7, 8), 0)}, refs=pattern.refs),
facade=lambda pattern: ports_to_data(pattern, (40, 0)),
)
def make_tree() -> Library:
tree = Library({"_helper": Pattern(), "old_top": Pattern()})
tree["old_top"].ref("_helper")
return tree
cells.tree = cell(make_tree)()
built, report = builder.build(output="library")
assert {"_helper", "impl_tree", "tree"} <= set(built)
helper_targets = set(built["impl_tree"].refs)
assert "_helper" not in helper_targets
assert len(helper_targets) == 1
renamed_helper = helper_targets.pop()
assert renamed_helper in built
assert report.provenance[renamed_helper].kind == "helper"
assert report.provenance[renamed_helper].requested_name == "_helper"
assert report.provenance[renamed_helper].owner_declared_name == "impl_tree"
assert set(built["tree"].ports) == {"out"}
assert set(built["tree"].refs) == {"impl_tree"}
def test_build_cells_view_accepts_direct_tree_and_rejects_multiple_tops() -> None:
builder = LibraryBuilder()
builder.cells["tree"] = Library({"old_top": Pattern()})
built, _report = builder.build(output="library")
assert set(built) == {"tree"}
invalid = LibraryBuilder()
invalid.cells["tree"] = cell(lambda: Library({"one": Pattern(), "two": Pattern()}))()
with pytest.raises(BuildError, match="exactly one topcell"):
invalid.validate()
def test_build_cells_tree_declared_name_wins_over_single_use_helper() -> None:
builder = LibraryBuilder()
def make_tree() -> Library:
top = Pattern()
top.ref("_device")
return Library({"_device": Pattern(), "old_top": top})
builder.cells["_device"] = cell(make_tree)()
built, report = builder.build(output="library")
assert "_device" in built
assert set(built["_device"].refs) != {"_device"}
assert len(built["_device"].refs) == 1
helper_name = next(iter(built["_device"].refs))
assert helper_name in built
assert report.provenance["_device"].kind == "declared"
assert report.provenance["_device"].requested_name == "_device"
assert report.provenance[helper_name].kind == "helper"
assert report.provenance[helper_name].requested_name == "_device"
assert report.provenance[helper_name].owner_declared_name == "_device"
def test_build_cells_tree_declared_name_rejects_public_helper_conflict() -> None:
builder = LibraryBuilder()
def make_tree() -> Library:
top = Pattern()
top.ref("device")
return Library({"device": Pattern(), "old_top": top})
builder.cells["device"] = cell(make_tree)()
with pytest.raises(BuildError, match="Unresolved duplicate key"):
builder.validate()
del builder.cells["device"]
builder.cells["device"] = Pattern()
builder.validate()
def test_build_cells_replacement_postprocess_updates_self_published_recipe_and_facade() -> None:
builder = LibraryBuilder()
cells = builder.cells.prefixed(
"impl_",
postprocess=lambda _pattern: Pattern(ports={"new": Port((5, 6), 0)}),
facade=lambda pattern: ports_to_data(pattern, (50, 0)),
)
def make_device(lib: ILibrary) -> Pattern:
original = Pattern(ports={"old": Port((1, 2), 0)})
lib["impl_device"] = original
return original
cells.device = cell(make_device)(builder.library)
built, report = builder.build(output="library")
assert set(built) == {"impl_device", "device"}
assert set(built["impl_device"].ports) == {"new"}
assert set(built["device"].ports) == {"new"}
assert built["device"].labels[(50, 0)][0].string.startswith("new:")
assert report.provenance["impl_device"].kind == "declared"
assert report.dependency_graph["device"] == frozenset({"impl_device"})
def test_build_cells_rejects_unrelated_self_published_pattern() -> None:
builder = LibraryBuilder()
def make_device(lib: ILibrary) -> Pattern:
lib["device"] = Pattern()
return Pattern()
builder.cells.device = cell(make_device)(builder.library)
with pytest.raises(BuildError, match="wrote a different pattern"):
builder.validate()