Source code for jitx.run.runtime

from __future__ import annotations

import asyncio
from collections.abc import Callable, Coroutine, Generator, Mapping, Sequence
from logging import getLogger
from pathlib import Path
import threading
from types import UnionType
from typing import cast, overload

from jitx._translate.lookup import ComputedNet
from jitx._utils.unionfind import UnionFind
from jitx.copper import Copper
from jitx.net import Net, Port, PortAttachment, TopologyNet
from jitx.component import Component
from jitx.landpattern import PadMapping
from jitx.stackup import Stackup
from jitx.via import Via
from jitxcore._proto import idmap_pb2, messages_pb2

from .._translate.idmap import ReverseMapper, fqnameob
from .._websocket import Envelope, Message, PersistentWebSocketClient, runtime_uri
from ..design import Design
from ..inspect import Trace, extract, visit
from ..query import query
from ..transform import IDENTITY, Transform

logger = getLogger(__name__)


def _warn_if_issues(rmap: ReverseMapper) -> None:
    if not rmap.unattached_copper_warning:
        return
    W = 38

    def row(text: str = "") -> str:
        return "|" + (" " + text).center(W) + "|"

    lines = [
        "",
        "+" + "-" * W + "+",
        row("DESIGN COMPLETED WITH WARNINGS"),
        row(),
        row("The design may not export correctly."),
        row("Please review the warning(s) above."),
        "+" + "-" * W + "+",
        "",
    ]
    logger.warning("\n".join(lines))


[docs] class Runtime: class __Active(threading.local): runtime: list[Runtime | SyncRuntime | None] def __init__(self): super().__init__() self.runtime = [] active = __Active() @overload def __init__(self): ... @overload def __init__(self, *, uri: str): ... @overload def __init__(self, *, project: str | Path): ... def __init__(self, uri: str | None = None, project: str | Path | None = None): import jitx.run # if a Runtime object is constructed, it's fairly safe to assume that # we're not trying to autodetect a design run as '__main__'. jitx.run.autodetect_design = False self.__uri = uri or (runtime_uri(project=project) if project else runtime_uri()) self.__client = PersistentWebSocketClient(uri=self.__uri) self.__reentered = 0 @property def _client(self): return self.__client
[docs] def decouple(self): return self.__class__(uri=self.__uri)
def __enter(self, r: Runtime | SyncRuntime): Runtime.active.runtime.append(r) if not self.__reentered: self.__client.keepalive() self.__reentered += 1 return self.__reentered == 1 def __exit(self): self.__reentered -= 1 Runtime.active.runtime.pop() return self.__reentered == 0 async def __aenter__(self) -> Runtime: if self.__enter(self): await self.open() return self async def __aexit__(self, exc_type, exc_val, exc_tb): if self.__exit(): await self.close()
[docs] async def open(self): await self.__client.establish()
[docs] async def close(self): await self.__client.disconnect()
def __enter__(self) -> SyncRuntime: sr = SyncRuntime(self) if self.__enter(sr): sr.open() return sr def __exit__(self, exc_type, exc_val, exc_tb): sr = Runtime.active.runtime[-1] if self.__exit(): # should always be true. if isinstance(sr, SyncRuntime): sr.close()
[docs] @staticmethod def require(): if Runtime.active.runtime: r = Runtime.active.runtime[-1] if not isinstance(r, Runtime | SyncRuntime): raise TypeError("Active runtime is not a Runtime") return r else: return Runtime()
def _construct[T: Design](self, design: T | type[T]) -> T: return _instantiate_design(design)
[docs] async def submit[T: Design](self, design: T | type[T], *, name: str | None = None): constructed = self._construct(design) packaged, rmap = _package_design(constructed) design_name = name or fqnameob(constructed) rd = AsyncRuntimeDesign(constructed, design_name, rmap, self) conversation = await self.__client.root().request( Message( "des", "request", messages_pb2.Request( design_name=design_name, design_manager=messages_pb2.DesignManagerRequest( load=messages_pb2.LoadDesignTrigger(design=packaged) ), ), ), ) async for envelope in conversation: async with envelope.process(namespace="des") as des: @des def response(response: messages_pb2.Response): if response.HasField("design_manager"): pass # trigger = response.design_manager.trigger # if trigger.message: # result_message = trigger.message elif response.HasField("error"): raise RuntimeError(f"Load failed: {response.error.message}") @des def ids(idmap: idmap_pb2.IDMap): from jitx._instantiation import instantiation # flattening might generate IDs that generate access to implicit # elements, which then needs to trigger instantiation. with instantiation.require(), instantiation.frame(): rd._rmap.populate_stable_id_chains(idmap) @des async def stdin(data: Envelope): await conversation.send(data.message.response({"message": "N"})) @des def stdout(data: Mapping): logger.info(data["message"]) @des def unhandled(): raise RuntimeError( f"Unhandled response message: {envelope.type}" # noqa: B023 ) return rd
[docs] async def capture[T: RuntimeDesign]( self, design: T, *, name: str | None = None ) -> T: from jitx._translate.reverse_flow.linker import Capture if name is None: name = fqnameob(design.root) conversation = await self.__client.route(f"design/{name}").request( Message( "des", "request", messages_pb2.Request( design_name=name, physical_design=messages_pb2.PhysicalDesignRequest( get_layout_output=messages_pb2.GetLayoutOutputQuery() ), ), ) ) async for envelope in conversation: with envelope.process(namespace="des") as des: @des def response(response: messages_pb2.Response): layout = response.physical_design.layout_output.output linker = Capture(layout, design._rmap, design=design.root) linker.run() _warn_if_issues(design._rmap) pass @des def stdout(data: Mapping): logger.info(data["message"]) @des def failure(data: Mapping): raise RuntimeError( f"Failed to capture output ({data['kind']}): {data['message']}" ) @des def unhandled(): raise RuntimeError( f"Unhandled response message: {envelope.type}" # noqa: B023 ) design._invalidate() return design
[docs] class SyncRuntime: class _AsyncBridgeThread: def __init__(self): self._loop: asyncio.AbstractEventLoop | None = None self._thread: threading.Thread | None = None self._ready = threading.Event() def start(self) -> None: if self._thread is not None: return def _run(): loop = asyncio.new_event_loop() self._loop = loop asyncio.set_event_loop(loop) # link calling and running thread's runtime stack # it's a blocking call, so it's safe to do so. self._ready.set() try: loop.run_forever() finally: pending = asyncio.all_tasks(loop) for t in pending: t.cancel() loop.run_until_complete( asyncio.gather(*pending, return_exceptions=True) ) loop.close() self._thread = threading.Thread( target=_run, name="jitx-sync-async-bridge", daemon=True ) self._thread.start() self._ready.wait() def run(self, coro): assert self._loop is not None, "loop thread not started" return asyncio.run_coroutine_threadsafe(coro, self._loop).result() def close(self) -> None: if self._loop is None or self._thread is None: return self._loop.call_soon_threadsafe(self._loop.stop) self._thread.join() self._loop = None self._thread = None self._ready.clear() def __init__(self, runtime: Runtime): self.__runtime = runtime self.__loop_thread = SyncRuntime._AsyncBridgeThread()
[docs] def runasync[T](self, coro: Coroutine[None, None, T]) -> T: from jitx._instantiation import instantiation self.__loop_thread.start() outer_instantiation = instantiation.data.stack async def decoupler(): instantiation.data.stack = outer_instantiation async with self.__runtime.decouple(): return await coro return self.__loop_thread.run(decoupler())
# Technically we'd be free to run an event loop here if there isn't one, # however, if we, for example, run websockets open like this, and then # websockets close the same way, those will be in different event loops, # and it has callbacks from the first event loop running in the second # which it does not like. # return asyncio.run(coro) # So it turns out it doesn't matter whether there's an event loop on # this thread or not, we always have to start a new one. def __enter__(self): return self.__runtime.__enter__() def __exit__(self, exc_type, exc_ob, exc_tb): return self.__runtime.__exit__(exc_type, exc_ob, exc_tb) # these should logically not be possible to construct from code, # and thus could just be exceptions, but it seems fine to keep the parity. # having them defined allows the Runtime|SyncRuntime return type to not def __aenter__(self): return self.__runtime.__aenter__() def __aexit__(self, exc_type, exc_ob, exc_tb): return self.__runtime.__aexit__(exc_type, exc_ob, exc_tb)
[docs] def close(self): self.runasync(self.__runtime.close()) self.__loop_thread.close()
[docs] def open(self): self.runasync(self.__runtime.open())
[docs] def submit[T: Design](self, design: T | type[T], *, name: str | None = None): ard = self.runasync(self.__runtime.submit(design, name=name)) return SyncRuntimeDesign(ard.root, ard.name, ard._rmap, self)
[docs] def capture[T: RuntimeDesign](self, design: T, *, name: str | None = None) -> T: return self.runasync(self.__runtime.capture(design, name=name))
class _RuntimeDispatch: @overload def __call__(self): ... @overload def __call__(self, *, uri: str): ... @overload def __call__(self, *, project: str | Path): ... def __call__(self, *, uri: str | None = None, project: str | Path | None = None): if uri: r = Runtime(uri=uri) elif project: r = Runtime(project=project) else: r = Runtime.require() return r def __enter__(self): return self().__enter__() def __exit__(self, exc_type, exc_ob, exc_tb): r = Runtime.active.runtime[-1] assert r is not None return r.__exit__(exc_type, exc_ob, exc_tb) async def __aenter__(self): return await self().__aenter__() async def __aexit__(self, exc_type, exc_ob, exc_tb): r = Runtime.active.runtime[-1] assert r is not None return await r.__aexit__(exc_type, exc_ob, exc_tb) async def submit[T: Design](self, design: T | type[T], *, name: str | None = None): async with Runtime.require() as r: return await r.submit(design, name=name) async def capture[T: RuntimeDesign]( self, design: T, *, name: str | None = None ) -> T: async with Runtime.require() as r: return await r.capture(design, name=name) @staticmethod def require(): return Runtime.require() dispatch = _RuntimeDispatch()
[docs] class RuntimeDesign[T: Design]: root: T _rmap: ReverseMapper def __init__(self, design: T, name: str, rmap: ReverseMapper): self.root = design self._rmap = rmap self.__name = name self.__memonets: RuntimeDesign.Nets | None = None def _invalidate(self): self.__memonets = None @property def name(self): return self.__name
[docs] def query[Q]( self, target: type[Q] | tuple[type[Q], ...], /, *, through: tuple[type, ...] | UnionType | None = None, transform: Transform | None = IDENTITY, opaque: tuple[type, ...] | UnionType | None = None, refs: bool = False, filter: Callable[[Q], bool] | None = None, ) -> Generator[tuple[Trace, Q], None, None]: """Convenience method to query this design. See :py:meth:`~jitx.query.TransformQuery.query` for details.""" return query( self.root, target, through=through, transform=transform, opaque=opaque, refs=refs, filter=filter, )
[docs] def nets(self): if self.__memonets is not None: return self.__memonets uf: UnionFind[Net | TopologyNet] = UnionFind() for net in extract(self.root, Net | TopologyNet): uf.add(net) if cn := ComputedNet.get(net): if cn.net: uf.union(net, cn.net) non_computed_count = 0 for net in extract(self.root, Net | TopologyNet): if isinstance(net, Net): def recurse(net): count = 0 for n in net._connected: if isinstance(n, Net): if not uf.connected(net, n): count += 1 uf.union(net, n) count += recurse(n) return count non_computed_count += recurse(net) if non_computed_count: logger.debug( "Unioned nets without computed net connection: %d", non_computed_count ) missing_nets: list[tuple[Trace, Component, set[Port]]] = [] for trace, comp in visit(self.root, (Component, PortAttachment)): if isinstance(comp, Component): missing_net_on_comp: list[Port] = [] for pm in extract(comp, PadMapping): for port, pads in pm.items(): cn = ComputedNet.get(port) if not cn: missing_net_on_comp.append(port) continue if not isinstance(pads, Sequence): pads = (pads,) for pad in pads: cn.assign(pad) if missing_net_on_comp: missing_nets.append((trace, comp, set(missing_net_on_comp))) elif isinstance(comp, PortAttachment): if isinstance(comp.port, Sequence): for ap, cp in zip( extract(comp.attachment, Port, filter=Port.is_single_pin), comp.port, strict=False, ): cn = ComputedNet.get(cp) if cn: cn.assign(ap) else: cn = ComputedNet.get(comp.port) if cn: cn.assign(comp.attachment) if missing_nets: for trace, comp, ports in missing_nets: portnames = [] for t, _pt in visit( comp, Port, filter=lambda p, ports=ports: p in ports ): portnames.append(str(t.path)) if len(portnames) > 1: portnames.insert(0, "") # don't leave one on the same line logger.warning( "no computed net for port in component %s: %s", trace.path, "\n ".join(portnames), ) self.__memonets = self.Nets(uf) return self.__memonets
[docs] def layers(self): return self.Layers(self.root.substrate.stackup)
[docs] class Net[N: Port]: def __init__(self, nets: Sequence[Net[N] | TopologyNet[N]]): cname = None for net in nets: nname = net._connected_name if nname: if cname and cname != nname: logger.warning( "Duplicate connected names encountered for nets '%s' and '%s'", cname, net._connected_name, ) else: cname = nname self.nets = nets self.name = cname @overload def __iter__( self: RuntimeDesign.Net[Port], ) -> Generator[Port | Copper | Via, None, None]: ... @overload def __iter__(self) -> Generator[N, None, None]: ... def __iter__(self): # cast to erase the port type, or pyright thinks the overloads # might be incompatible - they can't really be at this point, # but hard to prove. nets = cast(Sequence[Net], self.nets) for net in nets: yield from net
[docs] class Nets: def __init__(self, uf: UnionFind[Net | TopologyNet]): self.__uf = uf self.__nets = { root: RuntimeDesign.Net(group) for root, group in uf.groups().items() }
[docs] def find(self, element) -> RuntimeDesign.Net | None: if isinstance(element, Net | TopologyNet): net = element else: cnet = ComputedNet.get(element) if cnet and cnet.net: net = cnet.net else: return None if net not in self.__uf: return None return self.__nets.get(self.__uf.find(net))
[docs] class Layers: def __init__(self, stackup: Stackup): self.__conductor_count = len(stackup.conductors)
[docs] def normalize(self, layer: int): if layer < 0: layer += self.__conductor_count return layer
[docs] class AsyncRuntimeDesign[T: Design](RuntimeDesign[T]): def __init__(self, design: T, name: str, rmap: ReverseMapper, runtime: Runtime): super().__init__(design, name, rmap) self.__runtime = runtime
[docs] async def capture(self): return await self.__runtime.capture(self)
[docs] class SyncRuntimeDesign[T: Design](RuntimeDesign[T]): def __init__(self, design: T, name: str, rmap: ReverseMapper, runtime: SyncRuntime): super().__init__(design, name, rmap) self.__runtime = runtime
[docs] def capture(self): return self.__runtime.capture(self)
def _instantiate_design[T: Design](design: T | type[T]) -> T: from jitx._instantiation import instantiation from jitx._structural import Instantiable, Proxy import gc if isinstance(design, Instantiable): cls = design._instantiable_() if not (isinstance(cls, type) and issubclass(cls, Design)): raise ValueError(f"Not an instantiable design: {cls}") with instantiation.require(): with instantiation.frame(): instantiated = design._instantiate_({}) elif isinstance(design, type): if not issubclass(design, Design): raise ValueError(f"Not a Design subclass: {cls}") try: if hasattr(design, "__signature__"): design.__signature__.bind() except TypeError: raise ValueError( "Design is parameterized but no parameters were provided" ) from None with instantiation.require(): with instantiation.frame(): instantiated = design() else: instantiated = design assert isinstance(instantiated, Design), ( f"Instantiation resulted in non-Design object {Proxy.type(instantiated)}" ) gc.collect() return cast(T, instantiated) def _package_design(design: Design): from jitx._instantiation import instantiation from jitx._translate.design import package_design from .._translate.idmap import idmap if not isinstance(design, Design): raise TypeError(f"{design} is not a Design class") idmap.clear() with instantiation.require(): packaged = package_design(design) rmap = idmap.export() return packaged, rmap