# tangl/core/selector.py"""Query selectors for entity collections.Selectors are pure predicate objects used to query entities and registries withoutembedding matching logic on entity classes. This is the v38 replacement for legacy``Entity.matches(**criteria)`` style matching.See Also--------:class:`tangl.core.registry.Registry` ``find_all(selector=...)`` and ``chain_find_all(...)`` consume selectors."""from__future__importannotationsfromtypingimportAny,Callable,Iterable,Iterator,Self,Type,TypeVarfrompydanticimportBaseModelfromtangl.type_hintsimportIdentifierfrom.entityimportEntityET=TypeVar("ET",bound=Entity)
[docs]classSelector(BaseModel,extra="allow"):"""Pure query predicate model for matching entities. Why --- Selectors decouple query criteria from entity implementation. Entities expose attributes and predicate-like methods; selectors provide reusable matching logic. Key Features ------------ - ``Selector`` is a Pydantic model, not an :class:`Entity`. - ``predicate`` stores an optional custom callable pre-check. - With ``extra=\"allow\"``, all criteria kwargs beyond ``predicate`` are stored in ``__pydantic_extra__`` and interpreted by :meth:`matches`. Notes ----- - Criterion value ``typing.Any`` is a wildcard and is skipped. - ``None`` is *not* a wildcard; it is compared normally. - Any callable entity attribute is invoked with the criterion value, not only ``has_*`` / ``is_*`` names (those are conventions, not requirements). - Avoid properties that return callables on matchable names because selector callable detection is based on runtime ``callable(...)`` checks. Example: >>> class E(Entity): ... @property ... def label_rev(self): ... return self.label[::-1] >>> e = E(label="abc") >>> Selector( ... predicate=lambda x: x.label == "abc", ... label="abc", ... has_kind=E, ... label_rev="cba", ... ).matches(e) True >>> s = Selector(has_identifier="abc") >>> f = E(label="def") >>> list(s.filter([e, f])) [<E:abc>] """predicate:Callable[[Entity],bool]|None=Nonedeffilter(self,entities:Iterable[ET])->Iterator[ET]:"""Lazily filter an iterable of entities with this selector."""returnfilter(self.matches,entities)defmatches(self,entity:Entity)->bool:"""Return ``True`` when ``entity`` satisfies all selector criteria. Matching algorithm: 1. Evaluate ``predicate`` first, if provided. 2. Iterate ``__pydantic_extra__`` criteria. 3. For each criterion: - skip if value is ``typing.Any``; - fail if entity is missing the named attribute; - call attribute(value) when the attribute is callable; - otherwise compare by equality. Missing attributes are a hard non-match. Callable attributes receive the criterion as their sole argument (for example ``has_tags({"a"})``). """ifself.predicateisnotNoneandnotself.predicate(entity):returnFalseforattrib_name,target_valin(self.__pydantic_extra__or{}).items():iftarget_valisAny:continueifattrib_namein{"identifier","alias"}:attrib_name="has_identifier"ifattrib_namein{"is_instance","has_kind"}:ifhasattr(entity,attrib_name):attrib_value=getattr(entity,attrib_name)ifcallable(attrib_value):ifnotattrib_value(target_val):returnFalsecontinueifnotisinstance(entity,target_val):returnFalsecontinueifnothasattr(entity,attrib_name):returnFalseattrib_value=getattr(entity,attrib_name)ifcallable(attrib_value):ifnotattrib_value(target_val):returnFalseelifisinstance(attrib_value,Entity)andisinstance(target_val,Entity):# Entity-valued criteria are identity matches, not deep value matches.ifattrib_value.uid!=target_val.uid:returnFalseelifattrib_value!=target_val:returnFalsereturnTruedefwith_defaults(self,**criteria:Any)->Selector:"""Return a copy with non-conflicting defaults added. Existing criteria are preserved; new keys are added only when this selector does not already expose them as fields or extras. Example: >>> s = Selector(label="abc") >>> s2 = s.with_defaults(label="xyz", has_kind=Entity) >>> s2.label 'abc' >>> s2.has_kind is Entity True """forkeyinlist(criteria.keys()):ifhasattr(self,key):criteria.pop(key)returnself.model_copy(update=criteria)defwith_criteria(self,**criteria:Any)->Selector:"""Return a copy with overriding criteria. Most criteria overwrite existing values. ``has_kind`` is special-cased to prevent widening: replacement is applied only when the new kind is a subclass of the existing one. Example: >>> class P(Entity): ... pass >>> class C(P): ... pass >>> Selector(has_kind=C).with_criteria(has_kind=P).has_kind is C True >>> Selector(has_kind=P).with_criteria(has_kind=C).has_kind is C True """if"has_kind"incriteriaandhasattr(self,"has_kind"):ifnotissubclass(criteria["has_kind"],self.has_kind):criteria.pop("has_kind")returnself.model_copy(update=criteria)@classmethoddeffrom_identifier(cls,identifier:Identifier)->Self:"""Build ``Selector(has_identifier=identifier)``."""returncls(has_identifier=identifier)from_id=from_identifier@classmethoddeffrom_kind(cls,kind:Type[ET])->Self:"""Build ``Selector(has_kind=kind)``."""returncls(has_kind=kind)@classmethoddefchain_or(cls,*selectors:Selector)->Selector:"""Return a ChainedOrSelector. Example: >>> class P(Entity): ... foo: str = "bar" >>> s = Selector.chain_or(Selector(foo="bar"), Selector(foo="baz")) >>> s.matches(P()) True >>> s.matches(P(foo="baz")) True """classChainedOrSelector(Selector):selectors:tuple[Selector,...]defmatches(self,entity:Entity)->bool:ifnotsuper().matches(entity):returnFalsereturnany(selector.matches(entity)forselectorinself.selectors)returnChainedOrSelector(selectors=selectors)