From 99f902cd3444c363f7e06bcc8a116c48e58091db Mon Sep 17 00:00:00 2001 From: euler Date: Sun, 9 Aug 2026 01:08:07 -0500 Subject: [PATCH 1/8] gdstk backend: close API gaps so the tutorials run Running the tutorial notebooks under GLAYOUT_BACKEND=gdstk fails almost immediately: 4 of 14 pass. Two reasons, one on each side. The notebooks import Component, rectangle, boolean and cell straight from gdsfactory instead of glayout.backend, so they bypass the backend selection entirely. Under the default backend both names resolve to the same class and nobody notices; under gdstk, gdsfactory's add_ref() gets a gdstk Component and rejects it. Symbols glayout.backend does not re-export (text_freetype, array) are left on gdsfactory. The gdstk ComponentReference/Component are also missing pieces of the gdsfactory surface that glayout's own cells and the tutorials use: movex/movey took a bare delta; move() already accepted destination= ref.name read-only, but cells label placements (ref.name = "pfet_2") ref.x / ref.y had xmin/xmax/ymin/ymax but not the centre accessors write_gds required a filename and ignored gdsdir= Component.show absent ref.name stores the label on the reference rather than renaming the target cell, which would rename every other placement of it too. Tutorial notebooks under gdstk: 4/14 -> 10/14. The three BJT tutorials still fail (Component indexing, add_ref(columns=)) and are untouched here. Notebooks under the default gdsfactory backend are unaffected: the imports resolve to the same objects. --- src/glayout/backend/_gdstk.py | 79 +++++++++++++++++-- .../BJT_tutorials/test_bjt_gdsfactory.ipynb | 7 +- tutorial/GLayout_Cmirror.ipynb | 4 +- tutorial/GLayout_Introduction.ipynb | 4 +- tutorial/GLayout_Via.ipynb | 4 +- tutorial/glayout_tutorial_5T_OTA_part1.ipynb | 5 +- tutorial/glayout_tutorial_5T_OTA_part2.ipynb | 10 ++- tutorial/glayout_tutorial_FVF_part1.ipynb | 10 ++- tutorial/glayout_tutorial_FVF_part2.ipynb | 5 +- tutorial/glayout_tutorial_INV_part1.ipynb | 12 +-- tutorial/glayout_tutorial_INV_part2.ipynb | 5 +- 11 files changed, 111 insertions(+), 34 deletions(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index 80dceec1..bed79747 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -29,6 +29,7 @@ # cell_decorator_settings, .activate()). Extra fields are allowed so callers # can pass arbitrary pdk-specific config. from pydantic import BaseModel, ConfigDict # noqa: E402 +import os as _os class _GdsWriteSettings(BaseModel): @@ -264,6 +265,9 @@ def __init__(self, parent: "Component", gref: Optional[gdstk.Reference] = None): self._ref = gref # owner is the Component this reference has been added to (not the target) self.owner: Optional["Component"] = None + # a label for this placement; falls back to the target cell's name + self._name: Optional[str] = None + # --- metadata ---------------------------------------------------------- @property @@ -308,14 +312,31 @@ def x_reflection(self, value: bool) -> None: self._ref.x_reflection = bool(value) # --- movement (mutate + return self) ---------------------------------- - def movex(self, dx: float = 0.0) -> "ComponentReference": + def movex( + self, + origin: float = 0.0, + destination: Optional[float] = None, + ) -> "ComponentReference": + """Move along x, mirroring ``move``'s calling conventions: + - movex(dx) — translate by dx + - movex(destination=x) — translate by x + - movex(origin=x0, + destination=x1) — translate by x1-x0 + """ + dx = float(origin) if destination is None else float(destination) - float(origin) ox, oy = self.origin - self.origin = (ox + float(dx), oy) + self.origin = (ox + dx, oy) return self - def movey(self, dy: float = 0.0) -> "ComponentReference": + def movey( + self, + origin: float = 0.0, + destination: Optional[float] = None, + ) -> "ComponentReference": + """Move along y. See :meth:`movex` for the calling conventions.""" + dy = float(origin) if destination is None else float(destination) - float(origin) ox, oy = self.origin - self.origin = (ox, oy + float(dy)) + self.origin = (ox, oy + dy) return self def move( @@ -418,6 +439,16 @@ def center(self) -> Coord: (x0, y0), (x1, y1) = self.bbox return ((x0 + x1) / 2.0, (y0 + y1) / 2.0) + @property + def x(self) -> float: + """Centre x. gdsfactory exposes this on references, not just on cells.""" + return self.center[0] + + @property + def y(self) -> float: + """Centre y. Counterpart of :attr:`x`.""" + return self.center[1] + @property def xmin(self) -> float: return self.bbox[0][0] @property @@ -429,7 +460,15 @@ def ymax(self) -> float: return self.bbox[1][1] @property def name(self) -> str: - return self.parent.name + return self._name if self._name is not None else self.parent.name + + @name.setter + def name(self, value: str) -> None: + # gdsfactory lets callers label a placement without touching the cell it + # points at (``ref.name = "pfet_2"``), and cells and tutorials do exactly + # that. Keep it on the reference: renaming the parent here would rename + # every other reference to the same cell too. + self._name = str(value) def __repr__(self) -> str: return f"ComponentReference(parent={self.parent.name!r}, origin={self.origin}, rotation={self.rotation})" @@ -468,6 +507,21 @@ def name(self) -> str: def name(self, value: str) -> None: self._cell.name = str(value) + def show(self, *args, **kwargs) -> None: + """Open the layout in KLayout, like gdsfactory's Component.show(). + + Writes a temp .gds and hands it to klive when reachable. Tutorials + call this for interactive viewing, so it must stay quiet headless. + """ + import tempfile + path = _os.path.join(tempfile.gettempdir(), f"{self.name}.gds") + self.write_gds(path) + try: + from gdsfactory.show import show as _gf_show # type: ignore + _gf_show(path) + except Exception: + pass + def __repr__(self) -> str: return f"Component(name={self.name!r}, ports={list(self.ports)}, refs={len(self._references)})" @@ -773,7 +827,20 @@ def visit(cell: gdstk.Cell) -> None: visit(self._cell) return order - def write_gds(self, filename: str, unit: float = 1e-6, precision: float = 1e-9) -> str: + def write_gds( + self, + filename: Optional[str] = None, + unit: float = 1e-6, + precision: float = 1e-9, + gdsdir: Optional[str] = None, + ) -> str: + # Match gdsfactory's write_gds(gdspath=None, gdsdir=None): the path + # may be omitted (defaults to ".gds") and a directory may be + # given on its own. Tutorials use both call styles. + if filename is None: + filename = f"{self.name}.gds" + if gdsdir is not None: + filename = str(_os.path.join(str(gdsdir), str(filename))) lib = gdstk.Library(unit=unit, precision=precision) used_names: set[str] = set() for cell in self._collect_cells(): diff --git a/tutorial/BJT_tutorials/test_bjt_gdsfactory.ipynb b/tutorial/BJT_tutorials/test_bjt_gdsfactory.ipynb index c878bdc0..687bb63b 100644 --- a/tutorial/BJT_tutorials/test_bjt_gdsfactory.ipynb +++ b/tutorial/BJT_tutorials/test_bjt_gdsfactory.ipynb @@ -47,9 +47,10 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.geometry import boolean\n", - "from gdsfactory.components import text_freetype, rectangle, array, rectangular_ring" + "from glayout.backend import Component\n", + "from glayout.backend import boolean\n", + "from glayout.backend import rectangle, rectangular_ring\n", + "from gdsfactory.components import text_freetype, array" ] }, { diff --git a/tutorial/GLayout_Cmirror.ipynb b/tutorial/GLayout_Cmirror.ipynb index 112f8c31..14aa2be9 100644 --- a/tutorial/GLayout_Cmirror.ipynb +++ b/tutorial/GLayout_Cmirror.ipynb @@ -37,7 +37,7 @@ "from glayout.util.comp_utils import move, movex, movey, align_comp_to_port, evaluate_bbox, prec_center\n", "from glayout.routing.straight_route import straight_route\n", "from glayout.routing.c_route import c_route\n", - "from gdsfactory import Component\n", + "from glayout.backend import Component\n", "import gdstk\n", "import svgutils.transform as sg\n", "import IPython.display\n", @@ -103,7 +103,7 @@ "from glayout.util.comp_utils import evaluate_bbox, prec_center\n", "from glayout.routing.straight_route import straight_route\n", "from glayout.routing.c_route import c_route\n", - "from gdsfactory import Component\n" + "from glayout.backend import Component\n" ] }, { diff --git a/tutorial/GLayout_Introduction.ipynb b/tutorial/GLayout_Introduction.ipynb index a779df12..74011d53 100644 --- a/tutorial/GLayout_Introduction.ipynb +++ b/tutorial/GLayout_Introduction.ipynb @@ -193,8 +193,8 @@ "metadata": {}, "outputs": [], "source": [ - "from gdsfactory import Component\n", - "from gdsfactory.components import rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", "\n", "def makeMet1Rectangle(pdk, length):\n", " met1 = pdk.get_glayer(\"met1\")\n", diff --git a/tutorial/GLayout_Via.ipynb b/tutorial/GLayout_Via.ipynb index e2e30c23..7278a011 100644 --- a/tutorial/GLayout_Via.ipynb +++ b/tutorial/GLayout_Via.ipynb @@ -104,8 +104,8 @@ "outputs": [], "source": [ "from glayout import sky130, gf180\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle" ] }, { diff --git a/tutorial/glayout_tutorial_5T_OTA_part1.ipynb b/tutorial/glayout_tutorial_5T_OTA_part1.ipynb index d66f7bd7..6a0536b8 100644 --- a/tutorial/glayout_tutorial_5T_OTA_part1.ipynb +++ b/tutorial/glayout_tutorial_5T_OTA_part1.ipynb @@ -136,8 +136,9 @@ "outputs": [], "source": [ "from glayout import MappedPDK, sky130, gf180\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "from glayout import nmos, pmos\n", "from glayout import via_stack\n", diff --git a/tutorial/glayout_tutorial_5T_OTA_part2.ipynb b/tutorial/glayout_tutorial_5T_OTA_part2.ipynb index 1d14a4f5..16c9a1dc 100644 --- a/tutorial/glayout_tutorial_5T_OTA_part2.ipynb +++ b/tutorial/glayout_tutorial_5T_OTA_part2.ipynb @@ -95,8 +95,9 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype" ] }, { @@ -236,8 +237,9 @@ "fivet_ota_code_string = \"\"\"\n", "from glayout import MappedPDK, sky130 , gf180\n", "# from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "from glayout import nmos, pmos\n", "from glayout import via_stack\n", diff --git a/tutorial/glayout_tutorial_FVF_part1.ipynb b/tutorial/glayout_tutorial_FVF_part1.ipynb index 3808aed2..a07cb1b2 100644 --- a/tutorial/glayout_tutorial_FVF_part1.ipynb +++ b/tutorial/glayout_tutorial_FVF_part1.ipynb @@ -171,8 +171,9 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype" ] }, { @@ -863,8 +864,9 @@ "fvf_code_string = \"\"\"\n", "from glayout import MappedPDK, sky130 , gf180\n", "# from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "from glayout import nmos, pmos\n", "from glayout import via_stack\n", diff --git a/tutorial/glayout_tutorial_FVF_part2.ipynb b/tutorial/glayout_tutorial_FVF_part2.ipynb index 1ba93304..0c34ddb6 100644 --- a/tutorial/glayout_tutorial_FVF_part2.ipynb +++ b/tutorial/glayout_tutorial_FVF_part2.ipynb @@ -113,8 +113,9 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype" ] }, { diff --git a/tutorial/glayout_tutorial_INV_part1.ipynb b/tutorial/glayout_tutorial_INV_part1.ipynb index eb3160d5..1569b0db 100644 --- a/tutorial/glayout_tutorial_INV_part1.ipynb +++ b/tutorial/glayout_tutorial_INV_part1.ipynb @@ -164,8 +164,9 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype" ] }, { @@ -803,9 +804,10 @@ "source": [ "inv_code_string = \"\"\"\n", "from glayout import MappedPDK, sky130 , gf180\n", - "from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import cell\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "from glayout import nmos, pmos\n", "from glayout import via_stack\n", diff --git a/tutorial/glayout_tutorial_INV_part2.ipynb b/tutorial/glayout_tutorial_INV_part2.ipynb index bec0ebc9..7aa84f0d 100644 --- a/tutorial/glayout_tutorial_INV_part2.ipynb +++ b/tutorial/glayout_tutorial_INV_part2.ipynb @@ -105,8 +105,9 @@ "outputs": [], "source": [ "from glayout import MappedPDK, sky130 , gf180\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "import gdsfactory as gf\n", "gf.clear_cache()" From 2c1f2353af0a9ad621a6c372e0cf22fec0a9b6a8 Mon Sep 17 00:00:00 2001 From: euler Date: Sun, 9 Aug 2026 21:04:25 -0500 Subject: [PATCH 2/8] gdstk backend: snap to the PDK's grid, not to 1 nm Every vertex of a gdstk-generated layout lands off-grid on gf180. The DRC reports it on all of them -- 1301 violations on the LIF cell used to check this, split across contact_OFFGRID x348, via1_OFFGRID x276, metal1_OFFGRID x252, metal2_OFFGRID x132 and comp_OFFGRID x60. snap_to_grid() took a bare `nm: int = 1` default. gdsfactory's version reads the pitch from the active PDK instead: nm = int(get_grid_size() * 1000 * grid_factor) which is 5 nm on gf180. Rounding a 5 nm process to 1 nm produces values like 10.246 where the process wants 10.245, and every one of them is a violation. Two pieces were missing. Pdk.activate() was a no-op, so nothing recorded which PDK was active; and grid_size kept the class default of 0.001 because gdsfactory used to fill it in from its own PDK database on activate. The real pitch is already in gds_write_settings.precision (5e-9 m on both gf180 and sky130), so activate() now derives grid_size from it and registers the PDK for snap_to_grid to read. `nm=` still overrides when a caller wants a specific pitch. After: 0 of 1228 vertices off-grid, same as the gdsfactory backend, and the 1301 OFFGRID violations are gone. snap_to_2xgrid(10.2463) returns 10.25 on both backends now. DRC on diff_pair, current_mirror_nfet and transmission_gate under gdsfactory is unchanged. --- src/glayout/backend/_gdstk.py | 43 +++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index bed79747..aebb40fa 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -44,6 +44,10 @@ class _CellDecoratorSettings(BaseModel): cache: bool = False +# The PDK whose grid snap_to_grid() should use. Set by Pdk.activate(). +_ACTIVE_PDK: Optional["Pdk"] = None + + class Pdk(BaseModel): """Minimal shim for gdsfactory.pdk.Pdk. Holds enough state for MappedPDK to function.""" @@ -53,15 +57,29 @@ class Pdk(BaseModel): name: str layers: Optional[dict] = None default_decorator: Optional[Any] = None - grid_size: float = 0.001 # microns; matches gdsfactory default + grid_size: float = 0.001 # microns; corrected in activate() from precision gds_write_settings: _GdsWriteSettings = _GdsWriteSettings() cell_decorator_settings: _CellDecoratorSettings = _CellDecoratorSettings() def activate(self) -> None: - """No-op. gdsfactory's activate() registered the PDK in a global - registry; that registry is a gdsfactory concern and isn't needed - once gdsfactory is out of the import graph.""" - return None + """Register this PDK as the active one. + + gdsfactory kept a global registry so that helpers like snap_to_grid + could look up the process grid. Most of that registry is a gdsfactory + concern, but the grid is not: snapping to the wrong pitch puts every + vertex off-grid, and the DRC reports it on all of them. + """ + # gdsfactory filled grid_size in from its PDK database here. Without + # that database the class default (1 nm) survives, and snapping a 5 nm + # process to 1 nm puts every vertex off-grid -- the DRC then flags + # comp, metal, contact and via alike. precision already carries the + # real pitch, so derive it rather than duplicating the number. + precision_um = float(self.gds_write_settings.precision) * 1e6 + if precision_um > 0 and self.grid_size != precision_um: + object.__setattr__(self, "grid_size", precision_um) + + global _ACTIVE_PDK + _ACTIVE_PDK = self def validate_layers(self, layers_required) -> None: """Mimics gdsfactory.pdk.Pdk.validate_layers — raise if any named @@ -884,16 +902,23 @@ def Polygon(points, layer=(0, 0), datatype=None) -> gdstk.Polygon: # --------------------------------------------------------------------------- -def snap_to_grid(x, nm: int = 1): - """Snap `x` (in micrometers) to an `nm`-nanometer grid. +def snap_to_grid(x, nm: Optional[int] = None, grid_factor: int = 1): + """Snap `x` (in micrometers) to the active PDK's grid. + + Mirrors gdsfactory.snap.snap_to_grid, which reads the grid from the active + PDK rather than assuming one: gf180 is on 5 nm, and snapping it to 1 nm + leaves every vertex off-grid (the DRC then flags comp, metal, contact and + via alike). `nm` overrides the lookup when a caller needs a specific pitch. - Matches gdsfactory.snap.snap_to_grid semantics used in this repo. Accepts scalars or iterables. """ if x is None: return None if isinstance(x, (list, tuple)): - return type(x)(snap_to_grid(v, nm) for v in x) + return type(x)(snap_to_grid(v, nm, grid_factor) for v in x) + if nm is None: + grid_um = _ACTIVE_PDK.grid_size if _ACTIVE_PDK is not None else 0.001 + nm = max(1, int(round(grid_um * 1000.0 * grid_factor))) return round(float(x) * 1000.0 / nm) * nm / 1000.0 From 121cda206c3d468e26bbbfc6617b2a5084030502 Mon Sep 17 00:00:00 2001 From: euler Date: Sun, 9 Aug 2026 22:08:24 -0500 Subject: [PATCH 3/8] gdstk backend: fix move() and the order of get_ports_list move(destination=) translated relative to the bbox centre instead of (0,0), which is gdsfactory's default. c_route places its extension rectangles with move(destination=...) followed by relative movex, so the route shifted by half a rectangle: the LIF neuron came out 32.4 um wide instead of 29.9, with 54 extra M2.2a violations. get_ports_list returned dict order; gdsfactory sorts clockwise (west, north, east, south). Cells look ports up by substring, so the order changes which port gets routed. With both, neurona.ipynb gives 1168.6 um2 and 31 violations on either backend, identical to gdsfactory. --- src/glayout/backend/_gdstk.py | 84 +++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index aebb40fa..af5e0fd8 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -267,6 +267,41 @@ def _as_layer(layer) -> Layer: # --------------------------------------------------------------------------- +def _sort_ports_clockwise(ports: list) -> list: + """Order ports the way gdsfactory's select_ports() does. + + select_ports defaults to clockwise=True, so ports come out bucketed by + orientation and swept west, north, east, south -- west sorted south to + north, north west to east, east north to south, south east to west. + + The order is not cosmetic. Cells and notebooks pick ports out of + get_ports_list() by substring, e.g. + + next(p for p in cap.get_ports_list() if "bottom_met" in p.name) + + and a different order hands back a different port: on a 10x15 mimcap that + is array_row0_col0_bottom_met_W at (-4.250,-6.400) rather than + bottom_met_W at (-5.600,0.000), so the route lands somewhere else. + """ + buckets = {"E": [], "N": [], "W": [], "S": []} + for p in ports: + angle = (p.orientation or 0) % 360 + if angle <= 45 or angle >= 315: + buckets["E"].append(p) + elif 45 <= angle <= 135: + buckets["N"].append(p) + elif 135 <= angle <= 225: + buckets["W"].append(p) + else: + buckets["S"].append(p) + + buckets["W"].sort(key=lambda p: +p.center[1]) # south to north + buckets["N"].sort(key=lambda p: +p.center[0]) # west to east + buckets["E"].sort(key=lambda p: -p.center[1]) # north to south + buckets["S"].sort(key=lambda p: -p.center[0]) # east to west + return buckets["W"] + buckets["N"] + buckets["E"] + buckets["S"] + + class ComponentReference: """Wraps a `gdstk.Reference`. Exposes transform mutation and transformed views of the parent component's ports/bbox.""" @@ -362,24 +397,37 @@ def move( origin: Optional[Coord] = None, destination: Optional[Coord] = None, ) -> "ComponentReference": - """Move this reference. Two calling conventions: - - move((dx, dy)) — translate by offset - - move(destination=(x, y)) — move so the ref's center lands at (x, y) - - move(origin=(x0, y0), - destination=(x1, y1)) — translate by (x1-x0, y1-y0) + """Translate this reference by (destination - origin). + + `origin` defaults to (0, 0), NOT to the reference's centre -- so + move(destination=(x, y)) is a plain translation by (x, y), the same as + move((x, y)). That is gdsfactory's signature, and the difference is not + academic: c_route places its extension rectangles with + + e1_extension.move(destination=edge1.center) + e1_extension.movex(0 - evaluate_bbox(e1_extension)[0]) + + i.e. an absolute-looking call followed by relative nudges. Centring the + bbox on the destination instead injects an offset of half the + rectangle, and the route walks off: on the LIF neuron the met2 return + path ran to x=-8.125 instead of -1.250, widening the cell 8% and + raising 54 M2.2a violations that gdsfactory never produces. + + Either endpoint may be a Port (or anything exposing `.center`), which + is how callers route to a port without unpacking it. """ - if destination is None and origin is not None and not isinstance(origin, ComponentReference): - # single-arg form: treat as offset - return self.movex(origin[0]).movey(origin[1]) + def _coord(v): + c = getattr(v, "center", None) + return (float(v[0]), float(v[1])) if c is None else (float(c[0]), float(c[1])) + if destination is None: - return self - if origin is None: - # move by (destination - current center) - cx, cy = self.center - dx, dy = destination[0] - cx, destination[1] - cy - else: - dx, dy = destination[0] - origin[0], destination[1] - origin[1] - return self.movex(dx).movey(dy) + if origin is None: + return self + # single-arg form: move((dx, dy)) is a translation by that offset + destination, origin = origin, (0.0, 0.0) + ox, oy = _coord((0.0, 0.0) if origin is None else origin) + dx, dy = _coord(destination) + return self.movex(dx - ox).movey(dy - oy) def rotate(self, angle_deg: float, center: Coord = (0.0, 0.0)) -> "ComponentReference": # rotate the reference's placement about `center` @@ -446,7 +494,7 @@ def get_ports_list(self, prefix: str = "", **filters) -> list[Port]: if skip: continue out.append(p) - return out + return _sort_ports_clockwise(out) @property def bbox(self) -> tuple[Coord, Coord]: @@ -676,7 +724,7 @@ def get_ports_list(self, prefix: str = "", **filters) -> list[Port]: out.append(p.copy(name=f"{prefix}{name}")) else: out.append(p) - return out + return _sort_ports_clockwise(out) # --- geometry --------------------------------------------------------- def add_polygon(self, points, layer: Optional[Layer] = None) -> gdstk.Polygon: From 66065ed200c6ca1f183aa723c46b2a5372970f2c Mon Sep 17 00:00:00 2001 From: euler Date: Wed, 12 Aug 2026 21:30:04 -0500 Subject: [PATCH 4/8] gdstk backend: add_ports takes a dict, and Component indexes by port Two gaps that show up running the team's primitives notebook. add_ports iterated its argument assuming a sequence of Port, but gdsfactory also accepts a name->port mapping and glayout passes ref.ports straight in. Iterating that yields the names, so resistor() died with "'str' object has no attribute 'name'". And __getitem__ was missing. bjt indexes the component to read a port's width, and without it the error is "'Component' object is not subscriptable", which says nothing about the port it was after. Added on Component and ComponentReference, with the KeyError listing the ports that do exist. With these, resistor, pnp and npn build on the gdstk backend. --- src/glayout/backend/_gdstk.py | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index af5e0fd8..49bb5be1 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -477,6 +477,21 @@ def ports(self) -> dict[str, Port]: for name, p in self.parent.ports.items() } + def __getitem__(self, key: str) -> Port: + """comp["port_name"], como en gdsfactory. + + No es azucar: primitivos como bjt indexan el componente para leer el + ancho de un puerto, y sin esto el error que sale ("Component object is + not subscriptable") no dice nada del puerto que buscaba. + """ + try: + return self.ports[key] + except KeyError: + raise KeyError( + f"no port {key!r} on {getattr(self, 'name', self)!r}; " + f"hay {sorted(self.ports)[:8]}..." + ) from None + def get_ports_list(self, prefix: str = "", **filters) -> list[Port]: """Filter ports. `prefix` filters to names starting with that prefix (matches gdsfactory.component.Component.get_ports_list). Extra @@ -684,9 +699,14 @@ def add_port( def add_ports( self, - ports: Iterable[Port], + ports: Union[Iterable[Port], "dict[str, Port]"], prefix: str = "", ) -> "Component": + # gdsfactory accepts either a sequence of ports or a name->port mapping, + # and glayout passes `ref.ports` -- a dict -- straight through. Iterating + # that yields the names, so `p.name` blows up with a str. + if hasattr(ports, "values"): + ports = list(ports.values()) for p in ports: new_name = f"{prefix}{p.name}" if prefix else p.name np = p.copy(name=new_name) @@ -709,6 +729,21 @@ def pprint_ports(self) -> None: % (name, p.center[0], p.center[1], p.width, p.orientation or 0.0, p.layer)) + def __getitem__(self, key: str) -> Port: + """comp["port_name"], como en gdsfactory. + + No es azucar: primitivos como bjt indexan el componente para leer el + ancho de un puerto, y sin esto el error que sale ("Component object is + not subscriptable") no dice nada del puerto que buscaba. + """ + try: + return self.ports[key] + except KeyError: + raise KeyError( + f"no port {key!r} on {getattr(self, 'name', self)!r}; " + f"hay {sorted(self.ports)[:8]}..." + ) from None + def get_ports_list(self, prefix: str = "", **filters) -> list[Port]: out: list[Port] = [] for name, p in self.ports.items(): From 857b6fbf0fe4713f428c0d02133de7602f0d5d22 Mon Sep 17 00:00:00 2001 From: euler Date: Mon, 24 Aug 2026 07:44:37 -0500 Subject: [PATCH 5/8] gdstk backend: add_ref takes columns/rows/spacing `test_bjt_gdsfactory` lays its contact rings out with `add_ref(reference, columns=, rows=, spacing=)`, which gdsfactory accepts and this backend did not, so the notebook died on a TypeError partway through. gdstk.Reference has the same notion natively, so the array stays one reference rather than becoming rows*columns of them. Verified in CI on the fork: with this, the tutorial suite runs 14/14 on gdstk. Without it, 13/14 -- that notebook the only failure. --- src/glayout/backend/_gdstk.py | 30 ++++++++++++++++++++++++++++-- tests/test_gdstk_backend.py | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index 49bb5be1..3ef1e15d 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -616,8 +616,34 @@ def unlock(self) -> "Component": return self # --- add / << ---------------------------------------------------------- - def add_ref(self, component: "Component", alias: Optional[str] = None) -> ComponentReference: - ref = component.ref() + def add_ref( + self, + component: "Component", + alias: Optional[str] = None, + columns: int = 1, + rows: int = 1, + spacing: Optional[tuple] = None, + ) -> ComponentReference: + """Reference `component`, optionally as a repeated array. + + gdsfactory accepts columns/rows/spacing here and the BJT tutorial + lays its contact rings out that way. gdstk has the same notion + natively, so the array stays one reference rather than becoming + rows*columns of them. + """ + if columns != 1 or rows != 1: + if spacing is None: + raise ValueError( + "add_ref: spacing is required when columns or rows > 1" + ) + gref = gdstk.Reference( + component._cell, origin=(0.0, 0.0), + columns=int(columns), rows=int(rows), + spacing=(float(spacing[0]), float(spacing[1])), + ) + ref = ComponentReference(component, gref) + else: + ref = component.ref() self.add(ref) return ref diff --git a/tests/test_gdstk_backend.py b/tests/test_gdstk_backend.py index 6bb4f310..80a1606e 100644 --- a/tests/test_gdstk_backend.py +++ b/tests/test_gdstk_backend.py @@ -30,6 +30,28 @@ def test_component_api(self): c.add_port(name="p1", center=(0, 0), width=1, orientation=0, layer=(1, 0)) self.assertIn("p1", c.ports) + def test_add_ref_array(self): + """add_ref(columns=, rows=, spacing=) lays out a repeated reference. + + `test_bjt_gdsfactory` builds its contact rings this way. Without it + the notebook dies on a TypeError halfway through. + """ + from glayout.backend import Component, rectangle + c = Component("array_probe") + unit = rectangle(size=(1, 1), layer=(1, 0)) + ref = c.add_ref(unit, columns=3, rows=2, spacing=(2, 2)) + self.assertIsNotNone(ref) + # 3 columns at pitch 2 span 1 + 2*2 = 5; 2 rows span 1 + 2 = 3. + (x0, y0), (x1, y1) = c.bbox + self.assertAlmostEqual(x1 - x0, 5.0) + self.assertAlmostEqual(y1 - y0, 3.0) + + def test_add_ref_array_needs_spacing(self): + from glayout.backend import Component, rectangle + c = Component("array_probe_nospacing") + with self.assertRaises(ValueError): + c.add_ref(rectangle(size=(1, 1), layer=(1, 0)), columns=2) + def test_primitives_build(self): from glayout.pdk.sky130_mapped.sky130_mapped import sky130_mapped_pdk as pdk from glayout.primitives.via_gen import via_stack, via_array From 48a51daad1f7e36dcdeaaa2e45d46613a86b4d6e Mon Sep 17 00:00:00 2001 From: euler Date: Sat, 29 Aug 2026 07:11:40 -0500 Subject: [PATCH 6/8] tests: pin the snap grid to the PDK's, not to 1 nm The rounding this PR fixes had no test, so nothing stops a 1 nm default from coming back. Four checks, in the order the failure actually happens: - activate() derives grid_size from precision (5e-9 m -> 0.005 um), so the pitch is never written down in a second place that can drift. - snap_to_grid puts 10.2463 at 10.245, not at 10.246. That value is legal on a 1 nm grid and off-grid on gf180's 5 nm one, which is the whole bug. - an explicit nm= still overrides, so callers that need another pitch keep working. - every vertex of a built nmos(fingers=2) lands on the 5 nm grid. Snapping scalars right is not the same as a device coming out clean: the generators compose dozens of snapped values, and this is the check that matches what the DRC reports. Verified to fail without the fix, by making activate() skip the grid correction: 3 of the 4 fail, the built device showing 3156 off-grid vertices. The override check passes either way by design -- it guards behaviour this PR keeps. --- tests/test_gdstk_backend.py | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_gdstk_backend.py b/tests/test_gdstk_backend.py index 80a1606e..7a0d6983 100644 --- a/tests/test_gdstk_backend.py +++ b/tests/test_gdstk_backend.py @@ -88,5 +88,81 @@ def test_composite_diff_pair_writes_gds(self): self.assertGreater(os.path.getsize(path), 0) +class GridSnapTests(unittest.TestCase): + """The grid the backend rounds to has to come from the PDK. + + gf180 and sky130 are both 5 nm processes. `snap_to_grid` used to take a + bare `nm: int = 1`, so a coordinate the process wants at 10.245 was kept + at 10.246 -- legal at 1 nm, off-grid at 5 nm, and the DRC flags every + such vertex: comp, metal, contact and via alike. These tests pin the + grid to the PDK so a 1 nm default cannot come back unnoticed. + """ + + GRID_UM = 0.005 # gf180 / sky130 manufacturing grid + + @classmethod + def setUpClass(cls): + os.environ["GLAYOUT_BACKEND"] = "gdstk" + os.environ.setdefault("PDK_ROOT", "/tmp") + from glayout import backend + backend.set_backend("gdstk") + + def _gf180(self): + from glayout.pdk.gf180_mapped.gf180_mapped import gf180_mapped_pdk + gf180_mapped_pdk.activate() + return gf180_mapped_pdk + + def test_activate_derives_grid_from_precision(self): + """activate() must take the grid from precision, not keep the default. + + precision already carries the real pitch (5e-9 m), so the grid is + derived from it rather than written down a second place that could + drift out of step with it. + """ + pdk = self._gf180() + self.assertAlmostEqual(pdk.grid_size, self.GRID_UM, places=9) + + def test_snap_lands_on_the_pdk_grid(self): + """The value in the bug report: 10.246 is legal at 1 nm, not at 5 nm.""" + from glayout.backend import snap_to_grid + self._gf180() + self.assertAlmostEqual(snap_to_grid(10.2463), 10.245, places=9) + self.assertAlmostEqual(snap_to_grid(-0.0011), 0.0, places=9) + for value in (0.0012, 3.14159, 10.2463, -7.7777): + snapped = snap_to_grid(value) + self.assertAlmostEqual( + snapped / self.GRID_UM, round(snapped / self.GRID_UM), places=6, + msg=f"{value} snapped to {snapped}, which is off a 5 nm grid", + ) + + def test_explicit_pitch_still_overrides(self): + """Callers that need a specific pitch keep the old behaviour.""" + from glayout.backend import snap_to_grid + self._gf180() + self.assertAlmostEqual(snap_to_grid(10.2463, nm=1), 10.246, places=9) + + def test_built_geometry_lands_on_the_grid(self): + """The end-to-end claim: real devices come out on-grid. + + Snapping scalars correctly is not the same as a built cell being + clean -- the generators compose dozens of snapped values. This walks + every vertex of a flattened device and is the check that corresponds + to what the DRC reports. + """ + pdk = self._gf180() + from glayout.primitives.fet import nmos + device = nmos(pdk, fingers=2) + fuera = [] + for polygon in device._cell.get_polygons(depth=None): + for x, y in polygon.points: + for coord in (x, y): + pasos = coord / self.GRID_UM + if abs(pasos - round(pasos)) > 1e-6: + fuera.append(coord) + self.assertEqual( + fuera[:8], [], f"{len(fuera)} vertices off the 5 nm grid", + ) + + if __name__ == "__main__": unittest.main() From 97b1a86add0b5b453d65c039521c2b5f4b03bfb5 Mon Sep 17 00:00:00 2001 From: euler Date: Sat, 29 Aug 2026 05:51:10 -0500 Subject: [PATCH 7/8] tests/notebooks: stop pushing multi-MB displays through iopub in CI The tutorial job hangs intermittently at the 180s cell timeout, on whichever cell happens to be waiting. Instrumented on the runner itself (DEBUG logs with timestamps, 8 runs): the kernel finishes the cell, sends idle, and the reply goes missing -- sometimes surfacing ~179s later, sometimes never, in which case the next execute_request is also lost and the run dies. The trigger is the display helpers: display_component draws the whole opamp into a ~2.7MB SVG per message. A/B on the same machine, same notebook: 3/3 runs with displays stalled or died, 3/3 without them ran clean in 12.5s. Three layers, each measured: - --strip-displays: the harness executes a copy with display lines dropped (syntax-guarded per cell). CI executes for regressions, not for pictures. - jupyter-client pinned to 8.9.1: 8.10.0 (released 2026-08-28 12:17 UTC) quadrupled the loss rate, 4/6 against 1/6, measured A/B. This is also why the same commit passed on the 25th and hung on the 28th: the install is unpinned, so the environment drifts with PyPI. - --retries 1: only for the "error" class (crash/timeout), never for a cell failure, so a genuinely broken notebook still fails every attempt. klayout was ruled out (10/10 bare runs at 1.5s on the runner), as were the runners themselves (identical container and venv timings on green and red runs) and the PR content (byte-identical opamp SVG across runs). --- .github/workflows/notebooks.yml | 24 +++++++- tests/notebooks/run_tutorial_notebooks.py | 75 ++++++++++++++++++++--- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/.github/workflows/notebooks.yml b/.github/workflows/notebooks.yml index 20fe16c5..4a4a47a6 100644 --- a/.github/workflows/notebooks.yml +++ b/.github/workflows/notebooks.yml @@ -123,14 +123,34 @@ jobs: # pulls in gdstk / svgutils / ipywidgets; jupyter+nbconvert+ # ipykernel are the only extras the notebook harness needs. uv pip install -e . - uv pip install jupyter nbconvert ipykernel + # jupyter-client pinned: 8.10.0 (published 2026-08-28 12:17 UTC) + # quadrupled the rate of lost kernel replies on this runner -- + # measured A/B on the same machine, 4/6 hangs against 1/6 with + # 8.9.1. Unpinned, each run silently takes whatever PyPI shipped + # that morning, which is why one commit passed on the 25th and + # hung on the 28th. + uv pip install jupyter nbconvert ipykernel "jupyter-client==8.9.1" + + # Runs on cache hits too. The install step above is skipped when the + # venv is restored, so a pin written only there is inert: the cache + # key hashes setup.py and src/, not this file, and a restored venv + # keeps whatever jupyter-client it was built with. That is also how + # 8.10.0 got in -- a src change busted the key on the 28th and the + # rebuild took the version published that morning. Enforcing the pin + # here is a no-op when it already matches. + - name: Enforce the jupyter-client pin + run: | + set -euxo pipefail + . "$GITHUB_WORKSPACE/.venv/bin/activate" + uv pip install "jupyter-client==8.9.1" + python -c "import jupyter_client; print(jupyter_client.__version__)" - name: Run all tutorial notebooks run: | set -euxo pipefail . "$GITHUB_WORKSPACE/.venv/bin/activate" python tests/notebooks/run_tutorial_notebooks.py \ - --out-dir notebook_results + --out-dir notebook_results --strip-displays --retries 1 - name: Upload notebook artifacts if: always() diff --git a/tests/notebooks/run_tutorial_notebooks.py b/tests/notebooks/run_tutorial_notebooks.py index a2cc3d93..eb7714ce 100644 --- a/tests/notebooks/run_tutorial_notebooks.py +++ b/tests/notebooks/run_tutorial_notebooks.py @@ -119,6 +119,41 @@ def _scan_executed(path: Path) -> Optional[Dict[str, Any]]: return None +def _strip_displays(nb: Path) -> Path: + """Write a sibling copy of `nb` with display calls removed, for CI. + + The tutorials push multi-MB SVGs through iopub (display_component draws + the whole opamp: ~2.7 MB per message). Measured on the runner itself, + that is what stalls the kernel<->client transport: the same notebook run + 3x with displays stalled or died every time, and 3x without them ran + clean in 12.5s. CI executes for regressions, not for pictures, so the + displays go. + + Lines are dropped, not commented, matching the measured experiment. If + dropping a line leaves a cell that no longer compiles (say, a display + that was the only statement under a `with`), that cell keeps its + original source: correctness over stripping. + """ + import json + doc = json.loads(nb.read_text(encoding="utf-8")) + quitar = ("display_component(", "display_gds(", "display(") + for cell in doc.get("cells", []): + if cell.get("cell_type") != "code": + continue + original = cell["source"] + kept = [l for l in original if not any(q in l for q in quitar)] + if kept == original: + continue + try: + compile("".join(kept), "", "exec") + except SyntaxError: + continue + cell["source"] = kept + out = nb.with_name(nb.stem + "__stripped.ipynb") + out.write_text(json.dumps(doc), encoding="utf-8") + return out + + def _run_one( nb: Path, executed_dir: Path, @@ -126,10 +161,12 @@ def _run_one( timeout_per_cell: int, timeout_per_notebook: int, kernel_name: str, + strip_displays: bool = False, ) -> NotebookResult: rel = str(nb.relative_to(REPO_ROOT)) stem = nb.stem workdir = nb.parent + a_ejecutar = _strip_displays(nb) if strip_displays else nb executed = executed_dir / f"{stem}.ipynb" log = log_dir / f"{stem}.log" executed.parent.mkdir(parents=True, exist_ok=True) @@ -141,7 +178,7 @@ def _run_one( "--ExecutePreprocessor.allow_errors=True", f"--ExecutePreprocessor.kernel_name={kernel_name}", "--output", str(executed), - nb.name, + a_ejecutar.name, ] print(f"[RUN] {rel}", flush=True) start = time.monotonic() @@ -259,6 +296,18 @@ def main() -> int: "--kernel-name", default="python3", help="Jupyter kernel name to launch (default: python3).", ) + p.add_argument( + "--strip-displays", action="store_true", + help="Drop display calls before executing. The multi-MB SVGs they " + "push through iopub are what stalls the kernel transport in CI " + "(measured: 3/3 stalled with them, 3/3 clean at 12.5s without).", + ) + p.add_argument( + "--retries", type=int, default=0, + help="Re-run a notebook that ends in 'error' (crash/timeout, not a " + "cell failure) up to this many times. Transport races lose " + "messages; a genuinely broken notebook still fails every try.", + ) p.add_argument( "--only", default=None, help="Comma-separated notebook basenames (with or without .ipynb) to limit the run.", @@ -288,12 +337,24 @@ def main() -> int: results: List[NotebookResult] = [] for nb in nbs: - r = _run_one( - nb, executed_dir, log_dir, - timeout_per_cell=args.cell_timeout, - timeout_per_notebook=args.notebook_timeout, - kernel_name=args.kernel_name, - ) + for intento in range(1 + max(0, args.retries)): + r = _run_one( + nb, executed_dir, log_dir, + timeout_per_cell=args.cell_timeout, + timeout_per_notebook=args.notebook_timeout, + kernel_name=args.kernel_name, + strip_displays=args.strip_displays, + ) + # only "error" (nbconvert crash / timeout) is worth retrying: + # that is the transport-race class. A "fail" is a real cell + # exception and retrying it would only hide it. + if r.status != "error" or intento == args.retries: + break + print(f"[RETRY] {r.notebook} ({r.duration_s:.1f}s) " + f"{r.error_name}: attempt {intento + 2}", flush=True) + temporal = nb.with_name(nb.stem + "__stripped.ipynb") + if temporal.exists(): + temporal.unlink() print(f"[{r.status.upper()}] {r.notebook} ({r.duration_s:.1f}s)" + (f" — cell {r.errored_cell} {r.error_name}: {r.error_message}" if r.status != "pass" else ""), From fe2c7d14afa6af77c2ff260a6ffead1f20c4ad42 Mon Sep 17 00:00:00 2001 From: euler Date: Mon, 14 Sep 2026 03:03:08 -0500 Subject: [PATCH 8/8] tests/notebooks: drop the display stripping, keep the pin and the retry PR #125 has the better diagnosis. The tutorials wrap generation in `with widgets.Output():`, and under headless nbconvert that capture makes nbclient wait out the full per-cell timeout even though the kernel is already idle -- 10 min per widget cell against 11 s without it. That is a far larger effect than anything measured here. My own A/B was confounded: removing `display_component` removed the multi-MB SVG *and* the widget-capture cell at the same time, so it could not tell them apart. And stripping the displays changed what CI exercises -- the drawing path stopped being covered at all -- where #125 keeps the displays and disables only the capture. Theirs is the better fix, and `_strip_displays` inserted at the same line of run_tutorial_notebooks.py that #125 inserts into, so this also removes the conflict. Kept, because #125 does not have them and they are independent of which diagnosis is right: - jupyter-client==8.9.1, plus a step that enforces it on cache hits. The cache key hashes setup.py and src/, not the workflow, so a pin written only in the install step is inert when the venv is restored. That is how 8.10.0 got in: a src change busted the key on 2026-08-28 and the cold rebuild took the version published that morning. - --retries 1 for the crash/timeout class only. A failing cell is never retried, so a genuinely broken notebook still fails every try. --- .github/workflows/notebooks.yml | 2 +- tests/notebooks/run_tutorial_notebooks.py | 46 +---------------------- 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/.github/workflows/notebooks.yml b/.github/workflows/notebooks.yml index 4a4a47a6..a10db5da 100644 --- a/.github/workflows/notebooks.yml +++ b/.github/workflows/notebooks.yml @@ -150,7 +150,7 @@ jobs: set -euxo pipefail . "$GITHUB_WORKSPACE/.venv/bin/activate" python tests/notebooks/run_tutorial_notebooks.py \ - --out-dir notebook_results --strip-displays --retries 1 + --out-dir notebook_results --retries 1 - name: Upload notebook artifacts if: always() diff --git a/tests/notebooks/run_tutorial_notebooks.py b/tests/notebooks/run_tutorial_notebooks.py index eb7714ce..84dc34d7 100644 --- a/tests/notebooks/run_tutorial_notebooks.py +++ b/tests/notebooks/run_tutorial_notebooks.py @@ -119,41 +119,6 @@ def _scan_executed(path: Path) -> Optional[Dict[str, Any]]: return None -def _strip_displays(nb: Path) -> Path: - """Write a sibling copy of `nb` with display calls removed, for CI. - - The tutorials push multi-MB SVGs through iopub (display_component draws - the whole opamp: ~2.7 MB per message). Measured on the runner itself, - that is what stalls the kernel<->client transport: the same notebook run - 3x with displays stalled or died every time, and 3x without them ran - clean in 12.5s. CI executes for regressions, not for pictures, so the - displays go. - - Lines are dropped, not commented, matching the measured experiment. If - dropping a line leaves a cell that no longer compiles (say, a display - that was the only statement under a `with`), that cell keeps its - original source: correctness over stripping. - """ - import json - doc = json.loads(nb.read_text(encoding="utf-8")) - quitar = ("display_component(", "display_gds(", "display(") - for cell in doc.get("cells", []): - if cell.get("cell_type") != "code": - continue - original = cell["source"] - kept = [l for l in original if not any(q in l for q in quitar)] - if kept == original: - continue - try: - compile("".join(kept), "", "exec") - except SyntaxError: - continue - cell["source"] = kept - out = nb.with_name(nb.stem + "__stripped.ipynb") - out.write_text(json.dumps(doc), encoding="utf-8") - return out - - def _run_one( nb: Path, executed_dir: Path, @@ -161,12 +126,10 @@ def _run_one( timeout_per_cell: int, timeout_per_notebook: int, kernel_name: str, - strip_displays: bool = False, ) -> NotebookResult: rel = str(nb.relative_to(REPO_ROOT)) stem = nb.stem workdir = nb.parent - a_ejecutar = _strip_displays(nb) if strip_displays else nb executed = executed_dir / f"{stem}.ipynb" log = log_dir / f"{stem}.log" executed.parent.mkdir(parents=True, exist_ok=True) @@ -178,7 +141,7 @@ def _run_one( "--ExecutePreprocessor.allow_errors=True", f"--ExecutePreprocessor.kernel_name={kernel_name}", "--output", str(executed), - a_ejecutar.name, + nb.name, ] print(f"[RUN] {rel}", flush=True) start = time.monotonic() @@ -296,12 +259,6 @@ def main() -> int: "--kernel-name", default="python3", help="Jupyter kernel name to launch (default: python3).", ) - p.add_argument( - "--strip-displays", action="store_true", - help="Drop display calls before executing. The multi-MB SVGs they " - "push through iopub are what stalls the kernel transport in CI " - "(measured: 3/3 stalled with them, 3/3 clean at 12.5s without).", - ) p.add_argument( "--retries", type=int, default=0, help="Re-run a notebook that ends in 'error' (crash/timeout, not a " @@ -343,7 +300,6 @@ def main() -> int: timeout_per_cell=args.cell_timeout, timeout_per_notebook=args.notebook_timeout, kernel_name=args.kernel_name, - strip_displays=args.strip_displays, ) # only "error" (nbconvert crash / timeout) is worth retrying: # that is the transport-race class. A "fail" is a real cell