Source code for jitx.run.monitor

"""Callbacks on changes to a runtime design's layout.

Reached through ``RuntimeDesign.layout.monitor``. Changes are reported however
they were made: by requests, in the UI, or by other clients. User events,
subclasses of :class:`UserEvent`, are application-defined events sent with
``RuntimeDesign.layout.emit`` or by other clients (such as UI hooks).
"""

from __future__ import annotations

import asyncio
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
import inspect
from logging import getLogger
import math
from types import TracebackType
from typing import Any, cast

import jitxcore._proto.physical_design_pb2 as pdpb2
from google.protobuf.json_format import MessageToDict

from jitx.circuit import Circuit, Route
from jitx.component import Component
from jitx.controlpoint import ControlPoint
from jitx.copper import Pour
from jitx.feature import Feature
from jitx.via import Via

from .._translate.idmap import ReverseMapper, composite_id, fqname
from .._translate.reverse_flow.resolve import resolve

logger = getLogger(__name__)

type JSON = str | int | float | bool | None | list[JSON] | dict[str, JSON]
"""A JSON-compatible value."""


[docs] class UserEvent: """Base for application-defined layout events. Subclass it to define a kind of event; the subclass's fully qualified name identifies the kind on the wire, so events of different applications do not collide. An event carries JSON-compatible ``data`` and a sequence of layout ``objects`` (components, ports, routes, vias, ...), which ``data`` may refer to by index. A subclass may parse both into its own fields, and may override the :attr:`data` and :attr:`objects` properties, which are what is sent. A received event is constructed as ``cls(data, objects)``, so a subclass must accept those two arguments positionally, and call this ``__init__``. >>> class Highlight(UserEvent): ... def __init__(self, data: Mapping[str, JSON], objects: Sequence[object | None]): ... super().__init__(data, objects) ... self.color = data["color"] >>> await design.layout.emit(Highlight({"color": "red"}, [c.u1])) Args: data: JSON-compatible values. Numbers that are whole are received as ``int``, so they can index :attr:`objects`. objects: Layout objects. A received object the design does not have is None; see :attr:`resolved`. """ def __init__( self, data: Mapping[str, JSON] | None = None, objects: Sequence[object | None] = (), ): self.__data = dict(data or {}) self.__objects = tuple(objects) self._resolved = True @property def data(self) -> Mapping[str, JSON]: """The event's JSON-compatible values.""" return self.__data @property def objects(self) -> Sequence[object | None]: """The event's layout objects.""" return self.__objects @property def resolved(self) -> bool: """Whether every object of a received event was found in the design. Objects not found are None in :attr:`objects`.""" return self._resolved
[docs] @dataclass(frozen=True) class Changes[T]: """Objects of one kind added, changed, or removed by one change to the layout. Removed objects are no longer in the design tree. An object the runtime replaces when it changes (a via, a control point, an area region's feature) is reported as removed, and its replacement as added.""" added: Sequence[T] = () changed: Sequence[T] = () removed: Sequence[T] = () def __bool__(self) -> bool: return bool(self.added or self.changed or self.removed)
class _ChangeLog[T]: # Collects the changes to one kind of object while a change is applied. def __init__(self): self.__added: dict[int, T] = {} self.__changed: dict[int, T] = {} self.__removed: dict[int, T] = {} def add(self, obj: T) -> None: self.__added[id(obj)] = obj def change(self, obj: T) -> None: if id(obj) not in self.__added: self.__changed[id(obj)] = obj def remove(self, obj: T) -> None: self.__changed.pop(id(obj), None) # Made and gone within the change: nothing to report. if self.__added.pop(id(obj), None) is None: self.__removed[id(obj)] = obj def changes(self) -> Changes[T]: return Changes( tuple(self.__added.values()), tuple(self.__changed.values()), tuple(self.__removed.values()), )
[docs] class Monitor: """A registered callback. Unregister it with :meth:`close`, or by using it as a context manager. >>> with design.layout.monitor.placements(moved): ... ... """ def __init__(self, remove: Callable[[], None]): self.__remove: Callable[[], None] | None = remove
[docs] def close(self) -> None: """Unregister the callback. Calling it again is a no-op.""" remove, self.__remove = self.__remove, None if remove is not None: remove()
def __enter__(self) -> Monitor: return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: self.close()
[docs] class LayoutMonitor: """Registers callbacks run on changes to a runtime design's layout. Callbacks run on the runtime's event loop as changes arrive, after the whole change has been applied to ``design.root``, and receive objects of the current tree (a capture replaces it). A callback returning an awaitable has it run as a task. A callback must not block: blocking calls on a sync design (requests, capture) raise when made from one. Exceptions raised by a callback are logged. """ def __init__(self, monitors: _Monitors): self.__monitors = monitors
[docs] def placements( self, callback: Callable[[Sequence[Component | Circuit]], object] ) -> Monitor: """Run ``callback`` with the instances whose placement changed. Each moved instance is reported, including those anchored to a moved one; circuits are reported when they move as a whole. Returns: The registration; close it to stop. """ entries = self.__monitors.placements entry = (callback,) entries.append(entry) return Monitor(lambda: entries.remove(entry))
[docs] def routes(self, callback: Callable[[Changes[Route]], object]) -> Monitor: """Run ``callback`` with the routes the runtime made, rerouted (routed or blocked), or removed. Returns: The registration; close it to stop. """ return self.__monitors.register("routes", callback)
[docs] def vias(self, callback: Callable[[Changes[Via]], object]) -> Monitor: """Run ``callback`` with the vias the runtime added, moved, or removed. A via placed in code is not reported. Returns: The registration; close it to stop. """ return self.__monitors.register("vias", callback)
[docs] def controls(self, callback: Callable[[Changes[ControlPoint]], object]) -> Monitor: """Run ``callback`` with the control points the runtime added, changed, or removed. A control point placed in code is not reported. Returns: The registration; close it to stop. """ return self.__monitors.register("controls", callback)
[docs] def regions(self, callback: Callable[[Changes[Feature | Pour]], object]) -> Monitor: """Run ``callback`` with the area regions the runtime added, changed, or removed: a feature per region, and for a copper pour region a :class:`~jitx.copper.Pour` per layer (changed when its rank changes; see :meth:`pours` for its copper). Returns: The registration; close it to stop. """ return self.__monitors.register("regions", callback)
[docs] def pours(self, callback: Callable[[Changes[Pour]], object]) -> Monitor: """Run ``callback`` with the pours whose copper the runtime computed (changed, or added for a pour the design did not have), or removed. A pour removed with its area region is reported by :meth:`regions`. Returns: The registration; close it to stop. """ return self.__monitors.register("pours", callback)
[docs] def user[E: UserEvent]( self, event: type[E], callback: Callable[[E], object] ) -> Monitor: """Run ``callback`` with each received user event of type ``event``. Args: event: The event class. Only events sent as exactly this class are received. callback: Receives the event, constructed as ``event(data, objects)``. Returns: The registration; close it to stop. """ entries = self.__monitors.user.setdefault(user_event_name(event), []) entry = (event, cast(Callable[[UserEvent], object], callback)) entries.append(entry) return Monitor(lambda: entries.remove(entry))
[docs] def user_event_name(event: type[UserEvent]) -> str: """The wire name of a user event class.""" return fqname(event)
class _Monitors: # The callbacks registered on a design, shared by its views so they # survive captures. def __init__(self): self.placements: list[ tuple[Callable[[Sequence[Component | Circuit]], object]] ] = [] self.user: dict[ str, list[tuple[type[UserEvent], Callable[[UserEvent], object]]] ] = {} self.kinds: dict[str, list[tuple[Callable[[Any], object]]]] = {} # Keeps tasks of async callbacks alive until they finish. self.__tasks: set[asyncio.Future[Any]] = set() def notify_placements(self, instances: Sequence[Component | Circuit]) -> None: if not instances: return # A copy: a callback may close its own registration. for (callback,) in list(self.placements): self.__call(callback, instances) def register(self, kind: str, callback: Callable[[Any], object]) -> Monitor: entries = self.kinds.setdefault(kind, []) entry = (callback,) entries.append(entry) return Monitor(lambda: entries.remove(entry)) def notify(self, kind: str, changes: Changes[Any]) -> None: if not changes: return for (callback,) in list(self.kinds.get(kind, ())): self.__call(callback, changes) def notify_user(self, event: pdpb2.UserEvent, rmap: ReverseMapper) -> None: entries = self.user.get(event.name) if not entries: logger.debug("No monitor for user event %s", event.name) return data = _json_value(MessageToDict(event.data)) if event.HasField("data") else {} objects = [ resolve(cid, rmap, False, context=f"UserEvent {event.name}") for cid in event.objects ] resolved = all(o is not None for o in objects) constructed: dict[type[UserEvent], UserEvent | None] = {} for cls, callback in list(entries): if cls not in constructed: try: instance = cls(cast(dict[str, JSON], data), objects) instance._resolved = resolved constructed[cls] = instance except Exception: logger.exception("Failed to construct user event %s", event.name) constructed[cls] = None instance = constructed[cls] if instance is not None: self.__call(callback, instance) def __call[A](self, callback: Callable[[A], object], arg: A) -> None: try: result = callback(arg) except Exception: logger.exception("Layout monitor callback %r failed", callback) return if inspect.isawaitable(result): try: task = asyncio.ensure_future(result) except RuntimeError: # No running loop to run it on. if inspect.iscoroutine(result): result.close() logger.error( "Layout monitor callback %r returned an awaitable " "with no event loop to run it", callback, ) return self.__tasks.add(task) task.add_done_callback(self.__done) def __done(self, task: asyncio.Future[Any]) -> None: self.__tasks.discard(task) if not task.cancelled() and task.exception() is not None: logger.error("Layout monitor callback failed", exc_info=task.exception())
[docs] def user_event_proto(event: UserEvent, rmap: ReverseMapper) -> pdpb2.UserEvent: """The wire form of ``event``. Raises: TypeError: if ``data`` is not JSON-compatible. ValueError: if an object has no runtime ID. """ data = event.data _check_json(dict(data), "data") message = pdpb2.UserEvent(name=user_event_name(type(event))) message.data.update(data) for i, obj in enumerate(event.objects): key = rmap.stable_id_of(obj) if obj is not None else None if key is None: raise ValueError(f"the runtime has no ID for objects[{i}]: {obj!r}") message.objects.append(composite_id(key)) return message
def _check_json(value: object, path: str) -> None: if value is None or isinstance(value, str | bool | int): return if isinstance(value, float): if not math.isfinite(value): raise TypeError(f"{path} is not a finite number: {value!r}") return if isinstance(value, list | tuple): for i, v in enumerate(cast(Sequence[object], value)): _check_json(v, f"{path}[{i}]") return if isinstance(value, dict): for k, v in cast(dict[object, object], value).items(): if not isinstance(k, str): raise TypeError(f"{path} has a non-string key: {k!r}") _check_json(v, f"{path}[{k!r}]") return raise TypeError(f"{path} is not JSON-compatible: {type(value).__name__}") def _json_value(value: Any) -> JSON: # Struct numbers are doubles; whole ones are made ints again, so they can # index the objects (the UI's JS does not tell 1 from 1.0 anyway). if isinstance(value, float) and value.is_integer(): return int(value) if isinstance(value, list): return [_json_value(v) for v in cast(list[Any], value)] if isinstance(value, dict): return {k: _json_value(v) for k, v in cast(dict[str, Any], value).items()} return value