Source code for jitxlib.parts.record

"""
What a provider returns
=======================

A provider answers with a :py:class:`ResolvedPart`: an object of its own carrying
a :py:class:`PartIdentity`, a JSON `payload`, and a `materialize` that attaches
geometry to the component.

`ResolvedPart` is a runtime-checkable protocol rather than a base class, so there
is nothing to subclass. Write a frozen dataclass with `identity` and `payload`
attributes and a `materialize` method:

>>> @dataclass(frozen=True)
... class AcmePart:
...     identity: PartIdentity
...     payload: dict[str, JSON]
...
...     def materialize(self, component: Any) -> None:
...         component.landpattern = ...
...         component.symbol = ...
...         apply_fields(component, decode_payload(self.payload))

The split between the two halves is about what the core may look at. The core
defines `PartIdentity`, so it can serialize, filter, rank, pin and report on it;
put in `attributes` whatever a design may reasonably select on. The provider
defines everything else: the core stores `payload` verbatim, hands it back to
`rehydrate` without reading it, and never builds geometry itself.

:py:func:`apply_fields` is the supported way to surface payload data on a
component, and :py:data:`RESERVED_FIELDS` is what it refuses to touch.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable

from .query import Scalar

# The recursive JSON type. A payload is whatever a provider needs to rebuild
# itself from a lockfile, so long as it survives a round trip through JSON.
type JSON = str | int | float | None | list[JSON] | dict[str, JSON]


def _normalize_manufacturer(value: str | None) -> str | None:
    """Blank normalizes to None, so there is one spelling of "unqualified"."""
    if value is None:
        return None
    stripped = value.strip()
    return stripped or None


# Set by the core from the identity, or the design's to decide. A field of the
# same name from either channel does not overwrite them.
RESERVED_FIELDS = frozenset(
    {
        "part",
        "value",
        "landpattern",
        "symbol",
        "mpn",
        "manufacturer",
        "in_bom",
        "soldered",
        "schematic_x_out",
        "reference_designator",
        "reference_designator_prefix",
    }
)


[docs] def apply_fields(component: Any, fields: Mapping[str, Any]) -> None: """Set `fields` on a resolved component, skipping the reserved names. For a provider surfacing what it carried in its payload; the core uses it for `identity.attributes`. Ordinary attribute sets, so the framework sees each one: `object.__setattr__` or writing `__dict__` would go around it. Skips `RESERVED_FIELDS` and underscore names, so a catalogue cannot decide what the design decides or overwrite geometry the core owns. """ for name, value in fields.items(): if name in RESERVED_FIELDS or name.startswith("_"): continue setattr(component, name, value)
[docs] @dataclass(frozen=True) class PartIdentity: """The core-defined, serializable half of a resolved part. An MPN identifies a part only together with its manufacturer. Leave `manufacturer` unset to mark a part as not orderable, which is what generator providers do for synthesized parts. `attributes` is for selection: whatever is put here must also be filtered and ordered by, since filter keys and rank keys are one namespace. Distributor part numbers belong here; so does anything a design may reasonably ask for. Data the source reports but cannot select on, or that may be stale, belongs in the provider's `payload` instead. A remote catalogue that returns `stock` or `lifecycle` without accepting them as query terms must not claim them here. Both reach the component as fields: the core sets attributes, and `materialize` sets whatever the provider carried in its payload. """ mpn: str manufacturer: str | None = None category: str = "" provider: str = "" provider_ref: str | None = None attributes: Mapping[str, Scalar] = field(default_factory=dict) def __post_init__(self): # The one sanctioned use of object.__setattr__: normalizing a field in a # frozen dataclass's __post_init__. Avoid it anywhere else. object.__setattr__( self, "manufacturer", _normalize_manufacturer(self.manufacturer) ) @property def orderable(self) -> bool: """Whether this identity names a real, purchasable part.""" return self.manufacturer is not None
[docs] def describe(self) -> str: """`mpn (manufacturer)`, or just the mpn when unqualified.""" if self.manufacturer is None: return self.mpn return f"{self.mpn} ({self.manufacturer})"
[docs] @runtime_checkable class ResolvedPart(Protocol): """A part a provider has selected. Authored by the provider. Must not be list-like, dict-like, or a JITX structural type: `traverse` descends into those, and since the component holds the part, provider internals would surface under the component's refpath. A frozen dataclass or plain object is fine; a NamedTuple is not. """ # Read-only, deliberately: a provider's part should be a frozen dataclass, # and a mutable protocol attribute would refuse one. @property def identity(self) -> PartIdentity: ... @property def payload(self) -> dict[str, JSON]: """This provider's own data: what it needs to reconstruct itself, and anything it reports but cannot be queried on. The second half is the counterpart to `PartIdentity.attributes`. An attribute is a promise to filter and rank on it, so a value the source only reports, such as a datasheet URL or a stock level that may be stale by the next run, goes here and is set on the component in `materialize`. Opaque to the core, versioned by the provider, and JSON only: it is written to the lockfile verbatim, so a quantity or any other non-JSON value here fails the write. `encode_payload` and `decode_payload` render quantities for it and read them back. """ ...
[docs] def materialize(self, component: Any) -> None: """Attach geometry to a freshly constructed category component. The provider builds or reuses whatever landpattern and symbol it sees fit, by any means. Contract: - MAY assign landpattern, symbol, and pad/symbol mappings. - MUST NOT create, remove or replace ports. The category owns its arity, and the ports already exist. - MUST be callable across distinct component instances; it is called exactly once per instance. """ ...