"""
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.
"""
...