[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

@ -2,6 +2,7 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass, replace
from functools import wraps
from types import MappingProxyType
@ -16,7 +17,7 @@ from .mapping import Library, LibraryView
from .overlay import OverlayLibrary
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, KeysView, Mapping, Sequence
from collections.abc import Callable, Iterator, KeysView, Sequence
from ..pattern import Pattern
@ -76,8 +77,8 @@ class BuildReport:
@dataclass
class _BuildRecipe:
""" Captured deferred call to a pattern factory. """
func: Callable[..., Pattern]
""" Captured deferred call to a pattern or tree factory. """
func: Callable[..., Pattern | TreeView]
args: tuple[Any, ...]
kwargs: dict[str, Any]
explicit_dependencies: tuple[str, ...] = ()
@ -87,9 +88,16 @@ class _BuildRecipe:
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`.
"""
@ -111,19 +119,138 @@ class _LibraryPlaceholder:
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:
"""
Attribute-based declaration namespace for `LibraryBuilder`.
This is the ergonomic authoring surface exposed as `builder.cells`. It is
intentionally write-focused: attribute assignment and deletion register
declarations, while attribute reads fail with guidance to build first and
intentionally write-focused: attribute or item assignment and deletion
register declarations, while reads fail with guidance to build first and
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, '_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:
raise BuildError(
@ -131,16 +258,47 @@ class BuildCellsView:
'Call build() and index the returned library instead.'
)
def __setattr__(self, name: str, value: Pattern | _BuildRecipe) -> None:
if name == '_library':
object.__setattr__(self, name, value)
return
self._library[name] = value
def __getitem__(self, name: str) -> Pattern:
raise BuildError(
f'LibraryBuilder.cells[{name!r}] is write-only during authoring. '
'Call build() and index the returned library instead.'
)
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:
if name == '_library':
if name in self.__slots__:
raise AttributeError(name)
del self._library[name]
del self[name]
class LibraryBuilder(INameView):
@ -148,8 +306,8 @@ class LibraryBuilder(INameView):
Two-phase declaration surface for mixed imported/generated libraries.
A `LibraryBuilder` collects three kinds of inputs:
- direct declared `Pattern` objects
- deferred recipes created with `cell(...)`
- direct declared `Pattern` objects or single-top trees
- deferred Pattern or tree recipes created with `cell(...)`
- imported source-backed library views added with `add_source(...)`
The builder itself is not a normal readable library during authoring.
@ -164,7 +322,7 @@ class LibraryBuilder(INameView):
self._library_placeholder = _LibraryPlaceholder(self)
self._frozen = False
self._building = False
self._declarations: dict[str, Pattern | _BuildRecipe] = {}
self._declarations: dict[str, _BuildDeclaration] = {}
self._sources: list[tuple[ILibraryView, dict[str, str]]] = []
self._names: dict[str, None] = {}
@ -194,11 +352,16 @@ class LibraryBuilder(INameView):
def __setitem__(
self,
key: str,
value: Pattern | _BuildRecipe,
value: Pattern | TreeView | _BuildRecipe,
) -> None:
self._assert_editable()
if key in self._names:
raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!')
self._register_declarations(((key, value, None),))
def _prepare_declaration(
self,
value: Pattern | TreeView | _BuildRecipe,
postprocess: Callable[[Pattern], Pattern] | None,
) -> _BuildDeclaration:
from ..pattern import Pattern # noqa: PLC0415
if isinstance(value, _BuildRecipe):
placeholders = (
@ -207,14 +370,57 @@ class LibraryBuilder(INameView):
)
if any(placeholder is not self.library for placeholder in placeholders):
raise BuildError('A recipe cannot use another LibraryBuilder.library placeholder.')
declaration = value
else:
if callable(value):
raise TypeError('LibraryBuilder recipes must be wrapped with cell(fn)(...) or @cell.')
declaration = value
elif callable(value):
raise TypeError('LibraryBuilder recipes must be wrapped with cell(fn)(...) or @cell.')
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
self._names[key] = None
def _register_declarations(
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:
self._assert_editable()
@ -431,6 +637,23 @@ class LibraryBuilder(INameView):
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):
"""
Internal overlay-backed library used while a `LibraryBuilder` is executing.
@ -629,6 +852,75 @@ class _BuildSessionLibrary(ILibrary):
return
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:
from ..pattern import Pattern # noqa: PLC0415
@ -639,30 +931,44 @@ class _BuildSessionLibrary(ILibrary):
raise BuildError(f'Cycle detected while building declared cells: {chain}')
declaration = self._builder._declarations[name]
value = declaration.value
self._declared_stack.append(name)
try:
if isinstance(declaration, _BuildRecipe):
for dep in declaration.explicit_dependencies:
if isinstance(value, _BuildRecipe):
for dep in value.explicit_dependencies:
self._ensure_named(dep)
args = tuple(
self if arg is self._builder.library else arg
for arg in declaration.args
for arg in value.args
)
kwargs = {
key: self if value is self._builder.library else value
for key, value in declaration.kwargs.items()
key: self if arg_value is self._builder.library else arg_value
for key, arg_value in value.kwargs.items()
}
pattern = declaration.func(*args, **kwargs)
if not isinstance(pattern, Pattern):
raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern') # noqa: TRY301
result = value.func(*args, **kwargs)
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 self._overlay[name] is not pattern:
existing = self._overlay[name]
if existing is not published_pattern:
raise BuildError( # noqa: TRY301
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:
self[name] = pattern
self._built.add(name)