# 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 definitions. Also known as families or categories."""
from __future__ import annotations
from dataclasses import dataclass, field
from itertools import combinations
from typing import Iterable, Iterator
from .platforms import (
AIX,
ALTLINUX,
AMZN,
ANDROID,
ARCH,
BUILDROOT,
CENTOS,
CLOUDLINUX,
CYGWIN,
DEBIAN,
EXHERBO,
FEDORA,
FREEBSD,
GENTOO,
GUIX,
HURD,
IBM_POWERKVM,
KVMIBM,
LINUXMINT,
MACOS,
MAGEIA,
MANDRIVA,
MIDNIGHTBSD,
NETBSD,
OPENBSD,
OPENSUSE,
ORACLE,
PARALLELS,
PIDORA,
RASPBIAN,
RHEL,
ROCKY,
SCIENTIFIC,
SLACKWARE,
SLES,
SOLARIS,
SUNOS,
UBUNTU,
UNKNOWN_LINUX,
WINDOWS,
WSL1,
WSL2,
XENSERVER,
Platform,
)
[docs]
@dataclass(frozen=True)
class Group:
"""A ``Group`` identify a collection of ``Platform``.
Used to group platforms of the same family.
"""
id: str
"""Unique ID of the group."""
name: str
"""User-friendly description of a group."""
icon: str = field(repr=False, default="β")
"""Icon of the group."""
platforms: tuple[Platform, ...] = field(repr=False, default_factory=tuple)
"""Sorted list of platforms that belong to this group."""
platform_ids: frozenset[str] = field(default_factory=frozenset)
"""Set of platform IDs that belong to this group.
Used to test platform overlaps between groups.
"""
def __post_init__(self):
"""Keep the platforms sorted by IDs."""
object.__setattr__(
self,
"platforms",
tuple(sorted(self.platforms, key=lambda p: p.id)),
)
object.__setattr__(
self,
"platform_ids",
frozenset({p.id for p in self.platforms}),
)
# Double-check there is no duplicate platforms.
assert len(self.platforms) == len(self.platform_ids)
def __iter__(self) -> Iterator[Platform]:
"""Iterate over the platforms of the group."""
yield from self.platforms
def __len__(self) -> int:
"""Return the number of platforms in the group."""
return len(self.platforms)
@staticmethod
def _extract_platform_ids(other: Group | Iterable[Platform]) -> frozenset[str]:
"""Extract the platform IDs from ``other``."""
if isinstance(other, Group):
return other.platform_ids
return frozenset(p.id for p in other)
[docs]
def isdisjoint(self, other: Group | Iterable[Platform]) -> bool:
"""Return `True` if the group has no platforms in common with ``other``."""
return self.platform_ids.isdisjoint(self._extract_platform_ids(other))
[docs]
def fullyintersects(self, other: Group | Iterable[Platform]) -> bool:
"""Return `True` if the group has all platforms in common with ``other``.
We cannot just compare ``Groups`` with the ``==`` equality operator as the
latter takes all attributes into account, as per ``dataclass`` default behavior.
"""
return self.platform_ids == self._extract_platform_ids(other)
[docs]
def issubset(self, other: Group | Iterable[Platform]) -> bool:
return self.platform_ids.issubset(self._extract_platform_ids(other))
[docs]
def issuperset(self, other: Group | Iterable[Platform]) -> bool:
return self.platform_ids.issuperset(self._extract_platform_ids(other))
ALL_PLATFORMS: Group = Group(
"all_platforms",
"All platforms",
"π₯οΈ",
(
AIX,
ALTLINUX,
AMZN,
ANDROID,
ARCH,
BUILDROOT,
CENTOS,
CLOUDLINUX,
CYGWIN,
DEBIAN,
EXHERBO,
FEDORA,
FREEBSD,
GENTOO,
GUIX,
HURD,
IBM_POWERKVM,
KVMIBM,
LINUXMINT,
MACOS,
MAGEIA,
MANDRIVA,
MIDNIGHTBSD,
NETBSD,
OPENBSD,
OPENSUSE,
ORACLE,
PARALLELS,
PIDORA,
RASPBIAN,
RHEL,
ROCKY,
SCIENTIFIC,
SLACKWARE,
SLES,
SOLARIS,
SUNOS,
UBUNTU,
UNKNOWN_LINUX,
WINDOWS,
WSL1,
WSL2,
XENSERVER,
),
)
"""All recognized platforms."""
ANY_WINDOWS = Group("any_windows", "Any Windows", "πͺ", (WINDOWS,))
"""All Windows operating systems."""
UNIX = Group(
"unix",
"Any Unix",
"β¨·",
tuple(p for p in ALL_PLATFORMS.platforms if p not in ANY_WINDOWS),
)
"""All Unix-like operating systems and compatibility layers."""
UNIX_WITHOUT_MACOS = Group(
"unix_without_macos",
"Any Unix but macOS",
"β¨",
tuple(p for p in UNIX if p is not MACOS),
)
"""All Unix platforms, without macOS.
This is useful to avoid macOS-specific workarounds on Unix platforms.
"""
BSD = Group(
"bsd", "Any BSD", "π
±οΈ", (FREEBSD, MACOS, MIDNIGHTBSD, NETBSD, OPENBSD, SUNOS)
)
"""All BSD platforms.
.. note::
Are considered of this family (`according Wikipedia
<https://en.wikipedia.org/wiki/Template:Unix>`_):
- `386BSD` (`FreeBSD`, `NetBSD`, `OpenBSD`, `DragonFly BSD`)
- `NeXTSTEP`
- `Darwin` (`macOS`, `iOS`, `audioOS`, `iPadOS`, `tvOS`, `watchOS`, `bridgeOS`)
- `SunOS`
- `Ultrix`
"""
BSD_WITHOUT_MACOS = Group(
"bsd_without_macos",
"Any BSD but macOS",
"π
±οΈ",
tuple(p for p in BSD if p is not MACOS),
)
"""All BSD platforms, without macOS.
This is useful to avoid macOS-specific workarounds on BSD platforms.
"""
LINUX = Group(
"linux",
"Any Linux distribution",
"π§",
(
ALTLINUX,
AMZN,
ANDROID,
ARCH,
BUILDROOT,
CENTOS,
CLOUDLINUX,
DEBIAN,
EXHERBO,
FEDORA,
GENTOO,
GUIX,
IBM_POWERKVM,
KVMIBM,
LINUXMINT,
MAGEIA,
MANDRIVA,
OPENSUSE,
ORACLE,
PARALLELS,
PIDORA,
RASPBIAN,
RHEL,
ROCKY,
SCIENTIFIC,
SLACKWARE,
SLES,
UBUNTU,
UNKNOWN_LINUX,
XENSERVER,
),
)
"""All distributions based on a Linux kernel.
.. note::
Are considered of this family (`according Wikipedia
<https://en.wikipedia.org/wiki/Template:Unix>`_):
- `Android`
- `ChromeOS`
- any other distribution
"""
LINUX_LAYERS = Group(
"linux_layers", "Any Linux compatibility layers", "β", (WSL1, WSL2)
)
"""Interfaces that allows Linux binaries to run on a different host system.
.. note::
Are considered of this family (`according Wikipedia
<https://en.wikipedia.org/wiki/Template:Unix>`_):
- `Windows Subsystem for Linux`
"""
LINUX_LIKE = Group(
"linux_like",
"Any Linux and compatibility layers",
"π§+",
(*LINUX.platforms, *LINUX_LAYERS.platforms),
)
"""Sum of all Linux distributions and Linux compatibility layers."""
SYSTEM_V = Group(
"system_v", "Any Unix derived from AT&T System Five", "β
€", (AIX, SOLARIS)
)
"""All Unix platforms derived from AT&T System Five.
.. note::
Are considered of this family (`according Wikipedia
<https://en.wikipedia.org/wiki/Template:Unix>`_):
- `A/UX`
- `AIX`
- `HP-UX`
- `IRIX`
- `OpenServer`
- `Solaris`
- `OpenSolaris`
- `Illumos`
- `Tru64`
- `UNIX`
- `UnixWare`
"""
UNIX_LAYERS = Group(
"unix_layers",
"Any Unix compatibility layers",
"β",
(CYGWIN,),
)
"""Interfaces that allows Unix binaries to run on a different host system.
.. note::
Are considered of this family (`according Wikipedia
<https://en.wikipedia.org/wiki/Template:Unix>`_):
- `Cygwin`
- `Darling`
- `Eunice`
- `GNV`
- `Interix`
- `MachTen`
- `Microsoft POSIX subsystem`
- `MKS Toolkit`
- `PASE`
- `P.I.P.S.`
- `PWS/VSE-AF`
- `UNIX System Services`
- `UserLAnd Technologies`
- `Windows Services for UNIX`
"""
OTHER_UNIX = Group(
"other_unix",
"Any other Unix",
"β",
tuple(
p
for p in UNIX
if p
not in (
BSD.platforms
+ LINUX.platforms
+ LINUX_LAYERS.platforms
+ SYSTEM_V.platforms
+ UNIX_LAYERS.platforms
)
),
)
"""All other Unix platforms.
.. note::
Are considered of this family (`according Wikipedia
<https://en.wikipedia.org/wiki/Template:Unix>`_):
- `Coherent`
- `GNU/Hurd`
- `HarmonyOS`
- `LiteOS`
- `LynxOS`
- `Minix`
- `MOS`
- `OSF/1`
- `QNX`
- `BlackBerry 10`
- `Research Unix`
- `SerenityOS`
"""
NON_OVERLAPPING_GROUPS: frozenset[Group] = frozenset(
(
ANY_WINDOWS,
BSD,
LINUX,
LINUX_LAYERS,
SYSTEM_V,
UNIX_LAYERS,
OTHER_UNIX,
),
)
"""Non-overlapping groups."""
EXTRA_GROUPS: frozenset[Group] = frozenset(
(
ALL_PLATFORMS,
LINUX_LIKE,
UNIX,
UNIX_WITHOUT_MACOS,
BSD_WITHOUT_MACOS,
),
)
"""Overlapping groups, defined for convenience."""
ALL_GROUPS: frozenset[Group] = frozenset(NON_OVERLAPPING_GROUPS | EXTRA_GROUPS)
"""All groups."""
[docs]
def reduce(items: Iterable[Group | Platform]) -> set[Group | Platform]:
"""Reduce a collection of ``Group`` and ``Platform`` to a minimal set.
Returns a deduplicated set of ``Group`` and ``Platform`` that covers the same exact
platforms as the original input, but group as much platforms as possible, to reduce
the number of items.
.. hint::
Maybe this could be solved with some `Euler diagram
<https://en.wikipedia.org/wiki/Euler_diagram>`_ algorithms, like those
implemented in `eule <https://github.com/trouchet/eule>`_.
This is being discussed upstream at `trouchet/eule#120
<https://github.com/trouchet/eule/issues/120>`_.
.. todo::
Should we rename or alias this method to `collapse()`? Cannot decide if it is
more descriptive or not...
"""
# Collect all platforms.
platforms: set[Platform] = set()
for item in items:
if isinstance(item, Group):
platforms.update(item.platforms)
else:
platforms.add(item)
# List any group matching the platforms.
valid_groups: set[Group] = set()
for group in ALL_GROUPS:
if group.issubset(platforms):
valid_groups.add(group)
# Test all combination of groups to find the smallest set of groups + platforms.
min_items: int = 0
results: list[set[Group | Platform]] = []
# Serialize group sets for deterministic lookups. Sort them by platform count.
groups = tuple(sorted(valid_groups, key=len, reverse=True))
for subset_size in range(1, len(groups) + 1):
# If we already have a solution that involves less items than the current
# subset of groups we're going to evaluates, there is no point in continuing.
if min_items and subset_size > min_items:
break
for group_subset in combinations(groups, subset_size):
# If any group overlaps another, there is no point in exploring this subset.
if not all(g[0].isdisjoint(g[1]) for g in combinations(group_subset, 2)):
continue
# Remove all platforms covered by the groups.
ungrouped_platforms = platforms.copy()
for group in group_subset:
ungrouped_platforms.difference_update(group.platforms)
# Merge the groups and the remaining platforms.
reduction = ungrouped_platforms.union(group_subset)
reduction_size = len(reduction)
# Reset the results if we have a new solution that is better than the
# previous ones.
if not results or reduction_size < min_items:
results = [reduction]
min_items = reduction_size
# If the solution is as good as the previous one, add it to the results.
elif reduction_size == min_items:
results.append(reduction)
if len(results) > 1:
msg = f"Multiple solutions found: {results}"
raise RuntimeError(msg)
# If no reduced solution was found, return the original platforms.
if not results:
return platforms # type: ignore[return-value]
return results.pop()