Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d97b601c | ||
|
|
1852df3d53 |
@@ -237,3 +237,36 @@ class ShapeSearchTree:
|
|||||||
if overlaps_2d(grown, other_tile):
|
if overlaps_2d(grown, other_tile):
|
||||||
return shape
|
return shape
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def violation_pairs(self, clearance: int) -> list[tuple[TreeShape, TreeShape, bool]]:
|
||||||
|
"""Every different-net pair sharing a layer, at least one of them
|
||||||
|
routed, whose coppers come closer than ``clearance``: ``(routed tile,
|
||||||
|
other tile, touching)``. ``touching`` is True when the coppers
|
||||||
|
themselves overlap (a short, or copper inside a keepout); False for a
|
||||||
|
clearance violation. Each pair of tiles is reported once.
|
||||||
|
|
||||||
|
:meth:`has_violation` asks whether there is one; this lists them all,
|
||||||
|
for a report on copper that no engine has checked.
|
||||||
|
"""
|
||||||
|
pairs: list[tuple[TreeShape, TreeShape, bool]] = []
|
||||||
|
seen: set[tuple[int, int]] = set()
|
||||||
|
for shape in self.all_shapes():
|
||||||
|
if not shape.routed:
|
||||||
|
continue
|
||||||
|
mine = shape.exact if shape.exact is not None else shape.tile
|
||||||
|
grown = shape.clearance_region(clearance)
|
||||||
|
for other in self.overlapping(grown.bounding_box()):
|
||||||
|
if other is shape or other.net_no == shape.net_no:
|
||||||
|
continue
|
||||||
|
if not (shape.layers & other.layers):
|
||||||
|
continue
|
||||||
|
key = (min(id(shape), id(other)), max(id(shape), id(other)))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
theirs = other.exact if other.exact is not None else other.tile
|
||||||
|
touching = overlaps_2d(mine, theirs)
|
||||||
|
if not touching and (not other.clearance or not overlaps_2d(grown, theirs)):
|
||||||
|
continue # a rule area only counts an overlap
|
||||||
|
seen.add(key)
|
||||||
|
pairs.append((shape, other, touching))
|
||||||
|
return pairs
|
||||||
|
|||||||
+27
-6
@@ -25,6 +25,7 @@ from rpmroute.route.pipeline import (
|
|||||||
build_rooms_routing_result,
|
build_rooms_routing_result,
|
||||||
build_routing_result,
|
build_routing_result,
|
||||||
)
|
)
|
||||||
|
from rpmroute.route.verify import verify_result
|
||||||
from rpmroute.ses import write_ses
|
from rpmroute.ses import write_ses
|
||||||
|
|
||||||
#: which optional flags each engine actually implements. Anything not listed for
|
#: which optional flags each engine actually implements. Anything not listed for
|
||||||
@@ -251,6 +252,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
metavar="J",
|
metavar="J",
|
||||||
help="processes for --orders (default: one per order, up to the CPU count)",
|
help="processes for --orders (default: one per order, up to the CPU count)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--strict",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"exit with status 3 when the routed copper has shorts or clearance "
|
||||||
|
"violations (grid and room output is checked after routing; the "
|
||||||
|
"session file is still written)"
|
||||||
|
),
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -387,23 +397,34 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
for net, a, b, zone in in_zone:
|
for net, a, b, zone in in_zone:
|
||||||
print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr)
|
print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr)
|
||||||
|
|
||||||
|
flawed = False
|
||||||
if args.engine != "exact":
|
if args.engine != "exact":
|
||||||
# These engines route on approximations and do not check the copper
|
# These engines route on approximations and do not check the copper
|
||||||
# they write against exact shapes: a board can come out with shorts
|
# they write: check it now against the exact shapes the exact engine
|
||||||
# that this report would otherwise never mention.
|
# uses, so shorts are reported rather than shipped.
|
||||||
|
check = verify_result(dsn, result)
|
||||||
|
flawed = not check.clean
|
||||||
print(
|
print(
|
||||||
f"rpmroute: WARNING: the {args.engine} engine does not check its copper for "
|
f"rpmroute: {args.engine} output checked on exact shapes: "
|
||||||
"clearance or shorts; run KiCad's DRC on the result, or route with "
|
f"{check.shorts} short(s), {check.clearance} clearance violation(s)",
|
||||||
"--engine exact (the default)",
|
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
|
for kind, examples in (("short", check.short_examples), ("clearance", check.clearance_examples)):
|
||||||
|
for found in examples:
|
||||||
|
print(f" {kind}: {found}", file=sys.stderr)
|
||||||
|
if flawed:
|
||||||
|
print(
|
||||||
|
f"rpmroute: WARNING: this {args.engine} routing is not DRC-clean; fix it in "
|
||||||
|
"KiCad or route with --engine exact (the default)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
if args.output:
|
if args.output:
|
||||||
with open(args.output, "w", encoding="utf-8") as f:
|
with open(args.output, "w", encoding="utf-8") as f:
|
||||||
f.write(ses_text)
|
f.write(ses_text)
|
||||||
else:
|
else:
|
||||||
sys.stdout.write(ses_text)
|
sys.stdout.write(ses_text)
|
||||||
return 0
|
return 3 if (flawed and args.strict) else 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -155,38 +155,7 @@ def route_board_exact(
|
|||||||
owner = _counter()
|
owner = _counter()
|
||||||
|
|
||||||
# static items: pad and keepout copper (routed=False)
|
# static items: pad and keepout copper (routed=False)
|
||||||
for pin in board.get_pins():
|
insert_static_copper(tree, board, signal_layers, owner)
|
||||||
if pin.shape is None:
|
|
||||||
continue
|
|
||||||
box = pin.shape.bounding_box()
|
|
||||||
if box.is_empty():
|
|
||||||
continue
|
|
||||||
net = pin.net_nos[0] if pin.net_nos else -1
|
|
||||||
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
|
|
||||||
if pin_layers:
|
|
||||||
# a rotated pad is checked against its outline, not its box
|
|
||||||
exact = pin.shape if _is_rotated(pin.shape) else None
|
|
||||||
tree.insert(TreeShape(next(owner), net, pin_layers, box, exact=exact))
|
|
||||||
for net, copper_layers, box in board.get_fixed_copper():
|
|
||||||
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
|
|
||||||
if on and not box.is_empty():
|
|
||||||
tree.insert(TreeShape(next(owner), net, on, box))
|
|
||||||
for obstacle in board.get_obstacle_areas():
|
|
||||||
if obstacle.layer not in signal_layers:
|
|
||||||
continue
|
|
||||||
blocks = (
|
|
||||||
"all" if obstacle.blocks_wires and obstacle.blocks_vias
|
|
||||||
else "wires" if obstacle.blocks_wires else "vias"
|
|
||||||
)
|
|
||||||
for tile in obstacle.tiles:
|
|
||||||
box = tile.bounding_box()
|
|
||||||
if not box.is_empty():
|
|
||||||
tree.insert(
|
|
||||||
TreeShape(
|
|
||||||
next(owner), -1, frozenset({obstacle.layer}), box,
|
|
||||||
blocks=blocks, clearance=obstacle.clearance,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
via_layers = frozenset(signal_layers)
|
via_layers = frozenset(signal_layers)
|
||||||
accepted: dict[int, _Accepted] = {}
|
accepted: dict[int, _Accepted] = {}
|
||||||
@@ -325,6 +294,43 @@ def route_board_exact(
|
|||||||
# --- 45-degree (diagonal) recovery -------------------------------------------
|
# --- 45-degree (diagonal) recovery -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def insert_static_copper(tree: ShapeSearchTree, board: BasicBoard, signal_layers, owner) -> None:
|
||||||
|
"""Index the board's pads, fixed copper and keepouts (``routed=False``) on
|
||||||
|
``signal_layers``: what routed copper is checked against."""
|
||||||
|
for pin in board.get_pins():
|
||||||
|
if pin.shape is None:
|
||||||
|
continue
|
||||||
|
box = pin.shape.bounding_box()
|
||||||
|
if box.is_empty():
|
||||||
|
continue
|
||||||
|
net = pin.net_nos[0] if pin.net_nos else -1
|
||||||
|
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
|
||||||
|
if pin_layers:
|
||||||
|
# a rotated pad is checked against its outline, not its box
|
||||||
|
exact = pin.shape if _is_rotated(pin.shape) else None
|
||||||
|
tree.insert(TreeShape(next(owner), net, pin_layers, box, exact=exact))
|
||||||
|
for net, copper_layers, box in board.get_fixed_copper():
|
||||||
|
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
|
||||||
|
if on and not box.is_empty():
|
||||||
|
tree.insert(TreeShape(next(owner), net, on, box))
|
||||||
|
for obstacle in board.get_obstacle_areas():
|
||||||
|
if obstacle.layer not in signal_layers:
|
||||||
|
continue
|
||||||
|
blocks = (
|
||||||
|
"all" if obstacle.blocks_wires and obstacle.blocks_vias
|
||||||
|
else "wires" if obstacle.blocks_wires else "vias"
|
||||||
|
)
|
||||||
|
for tile in obstacle.tiles:
|
||||||
|
box = tile.bounding_box()
|
||||||
|
if not box.is_empty():
|
||||||
|
tree.insert(
|
||||||
|
TreeShape(
|
||||||
|
next(owner), -1, frozenset({obstacle.layer}), box,
|
||||||
|
blocks=blocks, clearance=obstacle.clearance,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _octilinear_candidates(a: IntPoint, b: IntPoint) -> list[list[IntPoint]]:
|
def _octilinear_candidates(a: IntPoint, b: IntPoint) -> list[list[IntPoint]]:
|
||||||
"""Octilinear (0/45/90/135-degree) polylines from ``a`` to ``b``.
|
"""Octilinear (0/45/90/135-degree) polylines from ``a`` to ``b``.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Check routed copper that no engine has checked.
|
||||||
|
|
||||||
|
The exact engine verifies every connection against exact pad and copper
|
||||||
|
shapes before it accepts it. The grid and room engines do not: they route on
|
||||||
|
approximations, and their output can short or crowd another net with nothing
|
||||||
|
in their report to say so. :func:`verify_result` puts a finished
|
||||||
|
:class:`RoutingResult` through the same exact geometry the exact engine uses
|
||||||
|
(its pads, fixed copper and keepouts, wires as boxes or slanted slabs, vias as
|
||||||
|
their pads) and lists what it finds.
|
||||||
|
|
||||||
|
A violation is a pair of different-net coppers on a shared layer, at least one
|
||||||
|
of them routed, closer than the board's clearance rule. It is a short when the
|
||||||
|
coppers themselves overlap (or routed copper sits inside a keepout), else a
|
||||||
|
clearance violation. Pairs are counted per routed segment or via, as KiCad's
|
||||||
|
DRC counts per item pair; pads already too close to each other in the design
|
||||||
|
are not the router's and are not counted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
from rpmroute.board import build_board
|
||||||
|
from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
|
||||||
|
from rpmroute.dsn.model import DsnBoard
|
||||||
|
from rpmroute.geometry import IntBox, IntPoint, segment_box, segment_slab
|
||||||
|
from rpmroute.geometry.polyline import corner_box
|
||||||
|
from rpmroute.ses.model import RoutingResult
|
||||||
|
|
||||||
|
from .exact_router import insert_static_copper
|
||||||
|
from .pipeline import _dsn_units_per_um, _rule_clearance_dsn, _via_half_board
|
||||||
|
|
||||||
|
# examples kept per kind; the counts are always complete
|
||||||
|
MAX_EXAMPLES = 5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Violation:
|
||||||
|
"""One offending pair: the routed copper's net, what it came too close
|
||||||
|
to, the layer, and roughly where (mm, DSN coordinates)."""
|
||||||
|
|
||||||
|
net: str
|
||||||
|
other: str
|
||||||
|
layer: str
|
||||||
|
x_mm: float
|
||||||
|
y_mm: float
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.net} x {self.other} on {self.layer} at ({self.x_mm:.2f}, {self.y_mm:.2f}) mm"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VerifyReport:
|
||||||
|
shorts: int = 0
|
||||||
|
clearance: int = 0
|
||||||
|
short_examples: list[Violation] = field(default_factory=list)
|
||||||
|
clearance_examples: list[Violation] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clean(self) -> bool:
|
||||||
|
return not (self.shorts or self.clearance)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_result(dsn: DsnBoard, result: RoutingResult) -> VerifyReport:
|
||||||
|
"""Check ``result``'s copper against ``dsn``'s pads, keepouts and each
|
||||||
|
other, with the board's clearance rule."""
|
||||||
|
board = build_board(dsn)
|
||||||
|
scale = max(dsn.resolution.value, 1)
|
||||||
|
clearance = round(_rule_clearance_dsn(dsn) * scale)
|
||||||
|
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
|
||||||
|
layer_index = {layer.name: i for i, layer in enumerate(dsn.layers)}
|
||||||
|
via_layers = frozenset(signal_layers)
|
||||||
|
net_numbers = {n.name: i + 1 for i, n in enumerate(dsn.nets)}
|
||||||
|
names = {number: name for name, number in net_numbers.items()}
|
||||||
|
|
||||||
|
tree = ShapeSearchTree()
|
||||||
|
owner = itertools.count(1)
|
||||||
|
insert_static_copper(tree, board, signal_layers, owner)
|
||||||
|
static_names: dict[int, str] = {}
|
||||||
|
for shape in tree.all_shapes():
|
||||||
|
static_names[shape.owner] = "keepout" if shape.net_no == -1 else f"pad of {names.get(shape.net_no, '?')}"
|
||||||
|
|
||||||
|
via_default = _via_half_board(dsn, scale)
|
||||||
|
for net in result.nets:
|
||||||
|
net_no = net_numbers.get(net.name)
|
||||||
|
if net_no is None:
|
||||||
|
continue
|
||||||
|
for wire in net.wires:
|
||||||
|
layer = layer_index.get(wire.layer)
|
||||||
|
if layer is None or layer not in signal_layers:
|
||||||
|
continue
|
||||||
|
half = max(round(wire.width * scale / 2), 1)
|
||||||
|
pts = [
|
||||||
|
IntPoint(round(wire.coords[i] * scale), round(wire.coords[i + 1] * scale))
|
||||||
|
for i in range(0, len(wire.coords) - 1, 2)
|
||||||
|
]
|
||||||
|
on = frozenset({layer})
|
||||||
|
for a, b in zip(pts, pts[1:], strict=False):
|
||||||
|
oid = next(owner)
|
||||||
|
if a.x == b.x or a.y == b.y:
|
||||||
|
tree.insert(TreeShape(oid, net_no, on, segment_box(a, b, half), routed=True))
|
||||||
|
else: # a 45-degree or slanted segment, on its exact copper
|
||||||
|
slab = segment_slab(a, b, half)
|
||||||
|
tree.insert(TreeShape(
|
||||||
|
oid, net_no, on, slab.bounding_box(), routed=True,
|
||||||
|
exact=slab, seg=("slab", a, b, half),
|
||||||
|
))
|
||||||
|
# the round end where it meets the next segment, with this one
|
||||||
|
tree.insert(TreeShape(oid, net_no, on, corner_box(b, half), routed=True))
|
||||||
|
for via in net.vias:
|
||||||
|
half = via_default or max(round(_wire_width(net) * scale / 2), 1)
|
||||||
|
x, y = round(via.x * scale), round(via.y * scale)
|
||||||
|
tree.insert(TreeShape(
|
||||||
|
next(owner), net_no, via_layers, IntBox(x - half, y - half, x + half, y + half), routed=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
mm_per_unit = 1 / (_dsn_units_per_um(dsn) * 1000 * scale)
|
||||||
|
# one entry per pair of items, however many of their tiles meet; a short
|
||||||
|
# if any of them overlap
|
||||||
|
by_pair: dict[tuple[int, int], tuple[TreeShape, TreeShape, bool]] = {}
|
||||||
|
for shape, other, touching in tree.violation_pairs(clearance):
|
||||||
|
key = (min(shape.owner, other.owner), max(shape.owner, other.owner))
|
||||||
|
seen = by_pair.get(key)
|
||||||
|
if seen is None or (touching and not seen[2]):
|
||||||
|
by_pair[key] = (shape, other, touching)
|
||||||
|
|
||||||
|
report = VerifyReport()
|
||||||
|
for shape, other, touching in by_pair.values():
|
||||||
|
layer = min(shape.layers & other.layers)
|
||||||
|
box = shape.tile.intersection(other.tile.offset(clearance))
|
||||||
|
if box.is_empty():
|
||||||
|
box = shape.tile
|
||||||
|
what = names.get(other.net_no, "?") if other.routed else static_names.get(other.owner, "?")
|
||||||
|
found = Violation(
|
||||||
|
names.get(shape.net_no, "?"), what, dsn.layers[layer].name,
|
||||||
|
(box.ll.x + box.ur.x) / 2 * mm_per_unit, (box.ll.y + box.ur.y) / 2 * mm_per_unit,
|
||||||
|
)
|
||||||
|
if touching:
|
||||||
|
report.shorts += 1
|
||||||
|
if len(report.short_examples) < MAX_EXAMPLES:
|
||||||
|
report.short_examples.append(found)
|
||||||
|
else:
|
||||||
|
report.clearance += 1
|
||||||
|
if len(report.clearance_examples) < MAX_EXAMPLES:
|
||||||
|
report.clearance_examples.append(found)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_width(net) -> float:
|
||||||
|
return max((w.width for w in net.wires), default=0.0)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""verify_result: copper no engine checked is checked on exact shapes."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rpmroute.dsn import parse_dsn
|
||||||
|
from rpmroute.route.verify import verify_result
|
||||||
|
from rpmroute.ses.model import RoutedWire, RoutingResult
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parents[1] / "dsn" / "fixtures" / "crossing_2net.dsn"
|
||||||
|
# rule: 2000 um wide traces, 2000 um clearance; NET_A runs along y = -40000
|
||||||
|
A = RoutedWire(layer="Top", width=2000, coords=[2000, -40000, 198000, -40000])
|
||||||
|
|
||||||
|
|
||||||
|
def _check(b_wire):
|
||||||
|
result = RoutingResult()
|
||||||
|
result.add_wire("NET_A", A)
|
||||||
|
result.add_wire("NET_B", b_wire)
|
||||||
|
return verify_result(parse_dsn(FIXTURE.read_text()), result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crossing_on_one_layer_is_a_short():
|
||||||
|
report = _check(RoutedWire(layer="Top", width=2000, coords=[100000, -15000, 100000, -65000]))
|
||||||
|
assert report.shorts == 1 and report.clearance == 0
|
||||||
|
found = report.short_examples[0]
|
||||||
|
assert {found.net, found.other} == {"NET_A", "NET_B"} and found.layer == "Top"
|
||||||
|
assert abs(found.x_mm - 100.0) < 1.5 and abs(found.y_mm + 40.0) < 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_copper_closer_than_the_rule_is_a_clearance_violation():
|
||||||
|
# edges 1500 um apart against a 2000 um rule
|
||||||
|
report = _check(RoutedWire(layer="Top", width=2000, coords=[50000, -36500, 150000, -36500]))
|
||||||
|
assert report.shorts == 0 and report.clearance == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_exactly_the_clearance_apart_is_clean():
|
||||||
|
report = _check(RoutedWire(layer="Top", width=2000, coords=[50000, -36000, 150000, -36000]))
|
||||||
|
assert report.clean
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crossing_on_another_layer_is_clean():
|
||||||
|
report = _check(RoutedWire(layer="Bottom", width=2000, coords=[100000, -15000, 100000, -65000]))
|
||||||
|
assert report.clean
|
||||||
+16
-2
@@ -82,9 +82,23 @@ def test_cli_default_engine_is_exact(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("engine", ["grid", "room"])
|
@pytest.mark.parametrize("engine", ["grid", "room"])
|
||||||
def test_unchecked_engines_say_so(tmp_path, capsys, engine):
|
def test_unchecked_engines_are_checked_after_routing(tmp_path, capsys, engine):
|
||||||
assert main([str(FIXTURE), "--engine", engine, "-do", str(tmp_path / "out.ses")]) == 0
|
assert main([str(FIXTURE), "--engine", engine, "-do", str(tmp_path / "out.ses")]) == 0
|
||||||
assert "does not check its copper" in capsys.readouterr().err
|
assert f"{engine} output checked on exact shapes: 0 short(s), 0 clearance" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_fails_a_run_whose_copper_is_not_clean(tmp_path, monkeypatch, capsys):
|
||||||
|
from rpmroute import cli
|
||||||
|
from rpmroute.route.verify import VerifyReport, Violation
|
||||||
|
|
||||||
|
bad = VerifyReport(shorts=1, short_examples=[Violation("A", "B", "F.Cu", 1.0, 2.0)])
|
||||||
|
monkeypatch.setattr(cli, "verify_result", lambda dsn, result: bad)
|
||||||
|
out = tmp_path / "out.ses"
|
||||||
|
assert main([str(FIXTURE), "--engine", "grid", "-do", str(out)]) == 0
|
||||||
|
assert main([str(FIXTURE), "--engine", "grid", "--strict", "-do", str(out)]) == 3
|
||||||
|
assert out.exists() # the session is still written
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "short: A x B on F.Cu at (1.00, 2.00) mm" in err and "not DRC-clean" in err
|
||||||
|
|
||||||
|
|
||||||
def test_the_exact_engine_does_not_warn(tmp_path, capsys):
|
def test_the_exact_engine_does_not_warn(tmp_path, capsys):
|
||||||
|
|||||||
Reference in New Issue
Block a user