Source code for extra_platforms.group

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Group a collection of traits. Also referred as families."""

from __future__ import annotations

from collections import Counter
from collections.abc import Iterable
from dataclasses import dataclass, field, replace
from functools import cached_property
from types import MappingProxyType
from typing import cast

from .trait import Trait, _Identifiable, _resolve_alias

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator

    from ._types import _T, _TNestedReferences


_MembersMapping = MappingProxyType[str, Trait]


def _flatten(items: Iterable) -> Iterator:
    """Recursively flatten nested iterables (except strings).

    Yields items from nested iterables one at a time, preserving order.
    Strings are treated as atomic values, not iterable containers.
    """
    for item in items:
        if isinstance(item, Iterable) and not isinstance(item, (str, bytes)):
            yield from _flatten(item)
        else:
            yield item


[docs] def extract_members(*other: _TNestedReferences) -> Iterator[Trait]: """Returns all traits found in ``other``. ``other`` can be an arbitrarily nested :class:`~collections.abc.Iterable` of :class:`~extra_platforms.Group`, :class:`~extra_platforms.Trait`, or their IDs. ``None`` values and empty iterables are silently ignored. .. caution:: Can returns duplicates. """ for item in _flatten(other): match item: case None: continue case Trait(): yield item case Group(): yield from item._members.values() case str(): yield from traits_from_ids(item) case _: raise TypeError(f"Unsupported type: {type(item)}")
[docs] @dataclass(frozen=True) class Group(_Identifiable): """A :class:`~extra_platforms.Group` identifies a collection of :class:`~extra_platforms.Trait` members. Additionally of the common fields inherited from ``_Identifiable``, each group provides: - ``members``: An iterable of :class:`~extra_platforms.Trait` instances that belong to this group. - ``member_ids``: A :class:`frozenset` of member IDs for quick lookup. - ``canonical``: A :class:`bool` indicating if the group is canonical (non-overlapping). - various :class:`set`-like operations (union, intersection, difference, etc.). """ unknown_symbol = "UNKNOWN" """Groups use ``UNKNOWN`` instead of ``UNKNOWN_GROUP``.""" members: Iterable[Trait] = field(repr=False, default_factory=tuple) """Traits in this group. Normalized to :class:`~types.MappingProxyType` at init, providing O(1) lookup by ID. """ @property def _members(self) -> _MembersMapping: """Typed access to members as :class:`~types.MappingProxyType`. .. warning:: The ``members`` field is typed as :class:`~collections.abc.Iterable` to accept any iterable at construction time. After ``__post_init__``, it is always a :class:`~types.MappingProxyType`. This property provides a :func:`~typing.cast` to that type, avoiding ``# type: ignore`` comments throughout the class. """ return cast(_MembersMapping, self.members)
[docs] def __post_init__(self): """Normalize members to a sorted, deduplicated mapping. .. hint:: Docstring generation is deferred to avoid circular imports during module initialization. See _docstrings._initialize_all_docstrings(). """ super().__post_init__() # Override detection_func_id and unless_decorator_id for groups with "all_" prefix. # Groups with "all_" prefix get "is_any_*" detection functions and "unless_any_*" # decorators (singular form) to better convey the "any member matches" semantic. # Class-type groups (those matching Trait subclasses) use the subclass's # type_id. if self.id.startswith("all_"): suffix = self.id[4:] # Map group suffix to singular type_id using Trait and its subclasses. # e.g., "architectures" β†’ "architecture", "platforms" β†’ "platform", # "ci" β†’ "ci", "traits" β†’ "trait" suffix_to_type_id = { cls.all_group.lower()[4:]: cls.type_id for cls in (Trait, *Trait.__subclasses__()) } if suffix in suffix_to_type_id: suffix = suffix_to_type_id[suffix] object.__setattr__(self, "detection_func_id", f"is_any_{suffix}") object.__setattr__(self, "unless_decorator_id", f"unless_any_{suffix}") # Override IDs for groups with "_without_" to use "_not_" instead. # This produces more natural function names like is_unix_not_macos() instead of # is_unix_without_macos(). if "_without_" in self.id: func_id = self.detection_func_id.replace("_without_", "_not_") skip_id = self.skip_decorator_id.replace("_without_", "_not_") unless_id = self.unless_decorator_id.replace("_without_", "_not_") object.__setattr__(self, "detection_func_id", func_id) object.__setattr__(self, "skip_decorator_id", skip_id) object.__setattr__(self, "unless_decorator_id", unless_id) # Accept either a MappingProxyType, dict, or iterable of Traits. if isinstance(self.members, MappingProxyType): traits = self.members.values() elif isinstance(self.members, dict): traits = self.members.values() else: traits = self.members # Deduplicate and sort by ID, then build the immutable mapping. sorted_traits = sorted(set(traits), key=lambda t: t.id) object.__setattr__( self, "members", MappingProxyType({t.id: t for t in sorted_traits}), )
[docs] def generate_docstring(self) -> str: """Generate comprehensive docstring for this group instance. Combines the attribute docstring from the source module with various metadata. """ from extra_platforms._docstrings import get_attribute_docstring lines = [] # Fetch attribute docstring from source module. source_docstring = get_attribute_docstring( f"extra_platforms.{self.data_module_id}", self.symbol_id ) if source_docstring: lines.extend(source_docstring.strip().split("\n")) lines.append("") # Add metadata. lines.append(f"- **ID**: ``{self.id}``") lines.append(f"- **Name**: {self.name}") lines.append(f"- **Icon**: {self.icon}") lines.append( f"- **Canonical**: ``{self.canonical}`` {'β¬₯' if self.canonical else ''}" ) lines.append(f"- **Detection function**: :func:`~{self.detection_func_id}`") lines.append( f"- **Pytest decorators**: :data:`~pytest.{self.skip_decorator_id}` / " f":data:`~pytest.{self.unless_decorator_id}`" ) # Add list of members with links to their definitions. member_links = [f":data:`~{m.symbol_id}`" for m in self] type_counts = Counter(type(m).__name__ for m in self) if member_links: # Format type information with links. type_parts = [ f"{count} :class:`~{class_name}`" for class_name, count in sorted(type_counts.items()) ] type_info = ", ".join(type_parts) lines.append(f"- **Members** ({type_info}): {', '.join(member_links)}") return "\n".join(lines)
@property def member_ids(self) -> frozenset[str]: """A :class:`frozenset` of member IDs that belong to this group.""" return frozenset(self._members.keys())
[docs] def __hash__(self) -> int: """Hash based on group ID and member IDs.""" return hash((self.id, self.member_ids))
@cached_property def canonical(self) -> bool: """Returns :data:`True` if the group is canonical (non-overlapping), :data:`False` otherwise. A canonical group is one that does not share any members with other canonical groups. All canonical groups are non-overlapping. Non-canonical groups are provided for convenience, but overlap with each other or with canonical groups. .. hint:: Canonical groups are denoted with a β¬₯ symbol in the documentation and tables. """ # Avoid circular import. from .group_data import NON_OVERLAPPING_GROUPS return self in NON_OVERLAPPING_GROUPS
[docs] def __iter__(self) -> Iterator[Trait]: """Iterate over the members of the group.""" yield from self._members.values()
[docs] def __len__(self) -> int: """Return the number of members in the group.""" return len(self._members)
[docs] def __bool__(self) -> bool: """Return :data:`True` if the group has members, :data:`False` otherwise.""" return len(self._members) > 0
[docs] def __contains__(self, item: Trait | str) -> bool: """Test if :class:`~extra_platforms.Trait` object or its ID is part of the group.""" if isinstance(item, str): return item in self._members return item.id in self._members and self._members[item.id] == item
[docs] def __getitem__(self, member_id: str) -> Trait: """Return the trait whose ID is ``member_id``.""" try: return self._members[member_id] except KeyError: raise KeyError(f"No trait found whose ID is {member_id}") from None
[docs] def items(self) -> Iterator[tuple[str, Trait]]: """Iterate over the traits of the group as key-value pairs.""" yield from self._members.items()
[docs] @staticmethod def _extract_members(*other: _TNestedReferences) -> Iterator[Trait]: """Deprecated alias for :func:`~extra_platforms.extract_members`. .. deprecated:: 8.0.0 Use :func:`~extra_platforms.extract_members` instead. """ # Prevent circular import. from ._deprecated import _warn_deprecated _warn_deprecated("Group._extract_members()", "extract_members()") return extract_members(*other)
[docs] @staticmethod def _extract_platforms(*other: _TNestedReferences) -> Iterator[Trait]: """Deprecated alias for :func:`~extra_platforms.extract_members`. .. deprecated:: 6.0.0 Use :func:`~extra_platforms.extract_members` instead. """ # Prevent circular import. from ._deprecated import _warn_deprecated _warn_deprecated("Group._extract_platforms()", "extract_members()") return extract_members(*other)
[docs] def isdisjoint(self, other: _TNestedReferences) -> bool: """Return :data:`True` if the group has no members in common with ``other``. Groups are disjoint if and only if their intersection is an empty :class:`set`. ``other`` can be an arbitrarily nested :class:`~collections.abc.Iterable` of :class:`~extra_platforms.Group` and :class:`~extra_platforms.Trait`. """ return set(self._members.values()).isdisjoint(extract_members(other))
[docs] def fullyintersects(self, other: _TNestedReferences) -> bool: """Return :data:`True` if the group has all members in common with ``other``.""" return set(self._members.values()) == set(extract_members(other))
[docs] def issubset(self, other: _TNestedReferences) -> bool: """Test whether every member in the group is in other.""" return set(self._members.values()).issubset(extract_members(other))
__le__ = issubset
[docs] def __lt__(self, other: _TNestedReferences) -> bool: """Test whether every member in the group is in other, but not all.""" other_members = set(extract_members(other)) self_members = set(self._members.values()) return self_members < other_members
[docs] def issuperset(self, other: _TNestedReferences) -> bool: """Test whether every member in other is in the group.""" return set(self._members.values()).issuperset(extract_members(other))
__ge__ = issuperset
[docs] def __gt__(self, other: _TNestedReferences) -> bool: """Test whether every member in other is in the group, but not all.""" other_members = set(extract_members(other)) self_members = set(self._members.values()) return self_members > other_members
[docs] def union(self, *others: _TNestedReferences) -> Group: """Return a new :class:`~extra_platforms.Group` with members from the group and all others. .. caution:: The new :class:`~extra_platforms.Group` will inherits the metadata of the first one. All other groups' metadata will be ignored. """ return Group( self.id, self.name, self.icon, set(self._members.values()).union( *(extract_members(other) for other in others) ), )
__or__ = union __ior__ = union
[docs] def intersection(self, *others: _TNestedReferences) -> Group: """Return a new :class:`~extra_platforms.Group` with members common to the group and all others. .. caution:: The new :class:`~extra_platforms.Group` will inherits the metadata of the first one. All other groups' metadata will be ignored. """ return Group( self.id, self.name, self.icon, set(self._members.values()).intersection( *(extract_members(other) for other in others) ), )
__and__ = intersection __iand__ = intersection
[docs] def difference(self, *others: _TNestedReferences) -> Group: """Return a new :class:`~extra_platforms.Group` with members in the group that are not in the others. .. caution:: The new :class:`~extra_platforms.Group` will inherits the metadata of the first one. All other groups' metadata will be ignored. """ return Group( self.id, self.name, self.icon, set(self._members.values()).difference( *(extract_members(other) for other in others) ), )
__sub__ = difference __isub__ = difference
[docs] def symmetric_difference(self, other: _TNestedReferences) -> Group: """Return a new :class:`~extra_platforms.Group` with members in either the group or other but not both. .. caution:: The new :class:`~extra_platforms.Group` will inherits the metadata of the first one. All other groups' metadata will be ignored. """ return Group( self.id, self.name, self.icon, set(self._members.values()).symmetric_difference(extract_members(other)), )
__xor__ = symmetric_difference __ixor__ = symmetric_difference
[docs] def copy( self, id: str | None = None, name: str | None = None, icon: str | None = None, members: Iterable[Trait] | None = None, ) -> Group: """Return a shallow copy of the group. Fields can be overridden by passing new values as arguments. """ kwargs = {k: v for k, v in locals().items() if k != "self" and v is not None} return replace(self, **kwargs)
[docs] def add(self, member: Trait | str) -> Group: """Return a new :class:`~extra_platforms.Group` with the specified trait added. If the trait is already in the group, returns a copy unchanged. Args: member: A :class:`~extra_platforms.Trait` object or trait ID string to add. Returns: A new :class:`~extra_platforms.Group` instance with the trait added. Raises: ValueError: If the trait ID is not recognized. """ if isinstance(member, str): traits = traits_from_ids(member) member = traits[0] if member in self: return self.copy() return Group( self.id, self.name, self.icon, set(self._members.values()) | {member}, )
[docs] def remove(self, member: Trait | str) -> Group: """Return a new :class:`~extra_platforms.Group` with the specified trait removed. Raises :exc:`KeyError` if the trait is not in the group. Args: member: A :class:`~extra_platforms.Trait` object or trait ID string to remove. Returns: A new :class:`~extra_platforms.Group` instance with the trait removed. Raises: KeyError: If the trait is not in the group. """ member_id = member.id if isinstance(member, Trait) else member if member_id not in self._members: raise KeyError(f"Trait '{member_id}' is not in the group") new_members = { tid: trait for tid, trait in self._members.items() if tid != member_id } return Group( self.id, self.name, self.icon, tuple(new_members.values()), )
[docs] def discard(self, member: Trait | str) -> Group: """Return a new :class:`~extra_platforms.Group` with the specified trait removed if present. Unlike :meth:`remove`, this does not raise an error if the trait is not found. Args: member: A :class:`~extra_platforms.Trait` object or trait ID string to remove. Returns: A new :class:`~extra_platforms.Group` instance with the trait removed, or a copy if not present. """ member_id = member.id if isinstance(member, Trait) else member if member_id not in self._members: return self.copy() new_members = { tid: trait for tid, trait in self._members.items() if tid != member_id } return Group( self.id, self.name, self.icon, tuple(new_members.values()), )
[docs] def pop(self, member_id: str | None = None) -> tuple[Trait, Group]: """Remove and return a trait from the group. Args: member_id: Optional trait ID to remove. If not provided, removes an arbitrary trait (specifically, the first one in iteration order). Returns: A :class:`tuple` of (removed :class:`~extra_platforms.Trait`, new :class:`~extra_platforms.Group`). Raises: KeyError: If ``member_id`` is provided but not found in the group. KeyError: If the group is empty. """ if not self._members: raise KeyError("pop from an empty group") if member_id is None: # Pop arbitrary (first) member. member_id = next(iter(self._members)) if member_id not in self._members: raise KeyError(f"Trait '{member_id}' is not in the group") popped_trait = self._members[member_id] new_members = { tid: trait for tid, trait in self._members.items() if tid != member_id } new_group = Group( self.id, self.name, self.icon, tuple(new_members.values()), ) return popped_trait, new_group
[docs] def clear(self) -> Group: """Return a new empty :class:`~extra_platforms.Group` with the same metadata. Returns: A new :class:`~extra_platforms.Group` instance with no members but same id, name, and icon. """ return Group( self.id, self.name, self.icon, tuple(), )
# ============================================================================= # Lookup and reduction functions # ============================================================================= def _unique(items: Iterable[_T]) -> tuple[_T, ...]: """Return a :class:`tuple` with duplicates removed, preserving order. This uses :meth:`dict.fromkeys` which: - Preserves insertion order (guaranteed since Python 3.7) - Removes duplicates (:class:`dict` keys are unique) """ return tuple(dict.fromkeys(items))
[docs] def traits_from_ids(*trait_and_group_ids: str) -> tuple[Trait, ...]: """Returns a deduplicated :class:`tuple` of traits matching the provided IDs. IDs are case-insensitive, and can refer to any traits or groups. Matching groups will be expanded to the :class:`~extra_platforms.Trait` instances they contain. Aliases are automatically resolved to their canonical IDs, with a warning emitted to encourage using the canonical ID directly. Order of the returned traits matches the order of the provided IDs. .. tip:: If you want to reduce the returned set and removes as much overlaps as possible, you can use the :func:`~extra_platforms.reduce` function on the results. """ # Avoid circular import. from .group_data import ALL_IDS, ALL_TRAIT_IDS, ALL_TRAITS # Normalize to lowercase and resolve aliases. ids = _unique((_resolve_alias(s.lower()) for s in trait_and_group_ids)) # Check for unrecognized IDs (aliases have already been resolved). unrecognized_ids = set(ids) - ALL_IDS if unrecognized_ids: raise ValueError( "Unrecognized group or trait IDs: " + ", ".join(sorted(unrecognized_ids)) ) traits = [] for trait_id in ids: if trait_id in ALL_TRAIT_IDS: traits.append(ALL_TRAITS[trait_id]) else: groups = groups_from_ids(trait_id) assert len(groups) == 1 traits.extend(groups[0]) return _unique(traits)
[docs] def groups_from_ids(*group_ids: str) -> tuple[Group, ...]: """Returns a deduplicated :class:`tuple` of groups matching the provided IDs. IDs are case-insensitive. Order of the returned :class:`~extra_platforms.Group` instances matches the order of the provided IDs. .. tip:: If you want to reduce the returned set and removes as much overlaps as possible, you can use the :func:`~extra_platforms.reduce` function on the results. """ # Avoid circular import. from .group_data import ALL_GROUP_IDS, ALL_GROUPS ids = _unique((s.lower() for s in group_ids)) unrecognized_ids = set(ids) - ALL_GROUP_IDS if unrecognized_ids: raise ValueError( "Unrecognized group IDs: " + ", ".join(sorted(unrecognized_ids)) ) # Build lookup dict for O(1) access instead of O(n) iteration per ID. group_by_id = {g.id: g for g in ALL_GROUPS} return _unique(group_by_id[gid] for gid in ids)
[docs] def reduce( items: _TNestedReferences, target_pool: Iterable[Group | Trait] | None = None, ) -> frozenset[Group | Trait]: """Reduce a collection of traits to a minimal set. Returns a deduplicated set of :class:`~extra_platforms.Group` and :class:`~extra_platforms.Trait` that covers the same exact traits as the original input, but group as much traits as possible, to reduce the number of items. Only the groups defined in the ``target_pool`` are considered for the reduction. If no reference pool is provided, use all known groups. .. note:: The algorithm is a variant of the `Set Cover Problem <https://en.wikipedia.org/wiki/Set_cover_problem>`_, which is NP-hard. This implementation uses a `greedy approximation <https://en.wikipedia.org/wiki/Set_cover_problem#Greedy_algorithm>`_ that iteratively selects the largest group fitting the remaining uncovered traits. .. todo:: Should we rename or alias this method to ``collapse()``? Cannot decide if it is more descriptive or not... """ # Avoid circular import. from .group_data import ALL_GROUPS # Collect all traits. uncovered = set(extract_members(items)) if not uncovered: return frozenset() # Build candidate groups: those that are subsets of the input traits. if target_pool is None: target_pool = ALL_GROUPS candidates = [ g for g in target_pool if isinstance(g, Group) and g.issubset(uncovered) ] # Greedy selection: repeatedly pick the largest group that fits remaining traits. # Sort candidates by size (descending), then by ID for determinism. candidates.sort(key=lambda g: (-len(g), g.id)) result: set[Group | Trait] = set() for group in candidates: # Only select if the group's members are all still uncovered. group_members = set(group) if group_members <= uncovered: result.add(group) uncovered -= group_members # Add any remaining uncovered traits individually. result.update(uncovered) return frozenset(result)