Source code for category

#!/usr/bin/env python3
"""
Classes and functions for defining categories.

.. _enum.Enum: https://docs.python.org/3/library/enum.html
.. _enum.EnumMeta: https://docs.python.org/3/library/enum.html#how-are-enums-different
.. _portion.interval.Interval: https://pypi.org/project/portion/#documentation--usage
.. _`semantic versioning`: https://semver.org/
"""

import collections
import enum
import os
import re
from typing import (
    Any,
    Callable,
    Dict,
    Iterable,
    List,
    NamedTuple,
    Optional,
    Tuple,
    Union,
)

import babelwrap
import portion as P
import toml

CONFIG_PATH = "../../pyproject.toml"

_version: Optional[Tuple[Union[int, str], ...]] = None


[docs]def parse_version( name: Optional[str] = None, min_parts: int = 3 ) -> Tuple[Union[int, str], ...]: """Yields sortable tuples for a version name. E.g.: >>> parse_version("1.0.1-alpha") (1, 0, 1, 'alpha') Args: name (str): dot/hyphen-delimited version name min_parts (int): The minimum parts in the tuple. Default is 3. Returns: A tuple with one member per part of the name padded with as many zeros as necessary to achieve min_parts. The numeric parts are integers, so the tuples sort correctly. To support `semantic versioning`_, omits any leading "v", and appends an extra "~" part to versions with no "-". E.g.: >>> parse_version("v1.0.0-alpha") < parse_version("1.0") True """ if not name: return () if name[0] == "v": name = name[1:] parts = re.split(r"-|\.", name) parts.extend(["0"] * (min_parts - len(parts))) if "-" not in name: parts.append("~") return tuple(int(part) if part.isnumeric() else part for part in parts)
[docs]def from_version(start: str, to: Optional[str] = None) -> Iterable: """The simple interval starting with a certain version. E.g.: >>> from_version("1.5.0") [(1, 5, 0, '~'),+inf) Args: start (str): The starting version to (str): If set, the (excluded) last version. If None, there is no end version. Default to None. Returns: The portion.interval.Interval_ """ end = parse_version(to) or P.inf return P.closedopen(parse_version(start), end)
ALL: Iterable = P.open(-P.inf, P.inf) """Iterable: A shortcut for the portion.interval.Interval_ that contains all (e.g. versions)."""
[docs]def setvers(name: Optional[str] = None) -> Tuple[Union[int, str], ...]: """Get or set the version. E.g.:: setvers() # to get the currenty set version setlang("1.1.0") # to set a version (e.g. for testing) setlang("") # to restore the default from pyproject.toml Args: name (str): The name of the version to set. Default to ``None``. Returns: The currently set version as a tuple. """ global _version if _version and name is None: return _version if not name and os.path.exists(CONFIG_PATH): config = toml.load(CONFIG_PATH) if isinstance(config, dict): name = config.get("tool").get("poetry").get("version") # type: ignore[union-attr] _version = parse_version(name) or parse_version("1.0.0") return _version
setvers()
[docs]class Category(enum.EnumMeta): """MetaClass for `Categorized`_ (not for public use). References: enum.EnumMeta_ """ _catbases_: List["Category"] = [] def __contains__(self, item): # Check if item is in self return ( isinstance(item, enum.Enum) and hasattr(self, item.name) and item.value == self[item.name].value and self[item.name] ) def __and__(self, other): # Intersection if not isinstance(other, collections.abc.Iterable): other = [other] return ctg(member for member in other if member in self) def __rand__(self, other): # Intersection (from right) return self & other def __or__(self, other): # Union if not isinstance(other, collections.abc.Iterable): other = [other] union = list(self) for member in other: if isinstance(member, Categorized) and member not in self: union.append(member) return ctg(union) def __ror__(self, other): # Union (from right) return self | other def __sub__(self, other): # Difference return ctg(x for x in self if x not in (self & other)) def __xor__(self, other): # Symmetric difference return (self | other) - (self & other) def __rxor__(self, other): # Symmetric difference (from right) return self ^ other def __str__(self): return babelwrap.format_list(list(self)) def __repr__(self): return f"<category {self.__name__}>" def __iter__(self): # filter version in list return filter(bool, enum.EnumMeta.__iter__(self)) def __dir__(self): # filter version in dir return [ name for name in enum.EnumMeta.__dir__(self) if name[0] == "_" or self[name] ] def __getitem__(self, index): if isinstance(index, (int, slice)): return list(self)[index] else: return enum.EnumMeta.__getitem__(self, index) def __bool__(self): return len(self) > 0 def __hash__(self): return hash(repr(self)) def __eq__(self, other): # Equality if isinstance(other, Category): return self >= other and other >= self else: return enum.EnumMeta.__eq__(self, other) def __neq__(self, other): # Inequality return not (self == other) def __ge__(self, other): # Check if all of other are in self if not isinstance(other, collections.abc.Iterable): other = [other] return all(member in self for member in other) def __le__(self, other): # Check if all of self are in other if not isinstance(other, collections.abc.Iterable): other = [other] return all(member in other for member in self) def __lt__(self, other): # Is proper subset return self <= other and not self >= other def __gt__(self, other): # Is proper superset return self >= other and not self <= other
[docs]class Categorized(enum.Enum, metaclass=Category): """ Derive from this class to define a new category. e.g.:: class _BoardOption(NamedTuple): STR: str AX: Callable[[matplotlib.figure.Figure, tuple], matplotlib.axes.Axes] VERSIONS: Iterable = ALL class BoardOption(Categorized): HASH = _BoardOption(STR = _("a hash"), AX = hash_board) SQUARES = _BoardOption( STR = _("squares"), AX = squares_board, VERSIONS = from_version("1.5.0"), ) Raises: AttributeError: Upon attempt to add, delete or change member. The above example assumes the existence of functions named ``hash_board`` and ``squares_board``. It creates a `Category`_ named ``BoardOption`` with two members, ``BoardOption.HASH`` and ``BoardOption.SQUARES``, each of which has three attributes: ``STR``, ``AX`` and ``VERSIONS``. >>> isinstance(BoardOption, Category) True >>> isinstance(BoardOption.HASH, Categorized) True A dropdown is a classic example of a category because different values should be available in different versions and all values typically should display differently in different languages. A member with an attribute named "VERSIONS", will appear only for those versions. If a member has an attribute named "STR", then that's how that member will print (use functions from :doc:`babelwrap`). For example, the following would yield a dropdown that contains only the local language translation of "a hash" in version 1.0.0, but translations of both "a hash" and "squares" in version 1.5.0 and above:: ipywidgets.Dropdown(options=BoardOption) This will work even if the dropdown is declared *before* calling `setvers()`_ and :ref:`setlang()<setlang>`. A member evaluates to False if not in the set version: >>> setvers("1.0.0") (1,0,0) >>> bool(BoardOption.HASH) True >>> bool(BoardOption.SQUARES) False If a member has an attribute named "CALL", then the value of that attribute will be invoked when that member is called. If the CALL is a tuple-class (e.g. ``NamedTuple``), then that member is a "factory member", and calling it will return a new `Categorized`_ with the attributes of that tuple-class (initialized with the called parameters). For example:: class _Jump(NamedTuple): FROM: Tuple[int, ...] TO: Tuple[int, ...] VERSIONS: Iterable = ALL def __str__(self) -> str: return _("{origin} to {destination}").format( origin=self.FROM, destination=self.TO ) class _Move(NamedTuple): STR: str CALL: Optional[Callable] = None VERSIONS: Iterable = ALL class Move(Categorized): PASS = _Move(STR=_("Pass")) JUMP = _Move(STR=_("Reposition"), CALL=_Jump) jumps = (Move.JUMP(FROM=(1,2), TO=dest) for dest in ((3,1), (3,3), (2,4))) CurrentLegal = ctg(*jumps, name="CurentLegal", uniquify=True) | Move.PASS In this example, the ``Move`` `Category`_ has two members, ``Move.PASS`` and ``Move.JUMP``, both of which have ``STR``, ``CALL``, and ``VERSIONS`` attributes. >>> str(Move) 'Pass and Reposition' ``Move.JUMP`` is a factory member used in the second-to-last line to create three new instances of `Categorized`_. They do not become members of any category until the last line which creates the ``CurrentLegal`` category from them unioned with ``Move.PASS``. Then the members of ``CurrentLegal`` are ``CurrentLegal.JUMP``, ``CurrentLegal.JUMP1``, ``CurrentLegal.JUMP2``, and ``CurrentLegal.PASS`` (the names "JUMP1" and "JUMP2" are constructed by ``ctg()`` to avoid name-collision). >>> str(CurrentLegal) '(1,2) to (3,1), (1,2) to (3,3), (1,2) to (2,4) and Pass' Each of the "JUMP" members of ``CurrentLegal`` has ``FROM``, ``TO``, and ``VERSIONS`` attributes, but ``CurrentLegal.PASS`` has the same attributes as ``Move.PASS``. It is the same entity, so it evaluates as ``==`` and is ``in`` both categories: >>> CurrentLegal.PASS == Move.PASS True >>> CurrentLegal.PASS in Move True >>> Move.PASS in CurrentLegal True >>> CurrentLegal.JUMP in Move False The only difference between the entities is context: >>> str(type(Move.PASS)) 'Pass and Reposition' >>> str(type(CurrentLegal.PASS)) '(1,2) to (3,1), (1,2) to (3,3), (1,2) to (2,4) and Pass' Categories support set operations, so you can get a new category containing all members that are in both categories (i.e. the set intersection): >>> str(CurrentLegal & Move) 'Pass' ...set difference: >>> str(CurrentLegal - Move) '(1,2) to (3,1), (1,2) to (3,3) and (1,2) to (2,4)' ...set union: >>> str(CurrentLegal | Move) '(1,2) to (3,1), (1,2) to (3,3), (1,2) to (2,4), Pass and Reposition' ...and set symmetric difference: >>> str(CurrentLegal ^ Move) '(1,2) to (3,1), (1,2) to (3,3), (1,2) to (2,4) and Reposition' You can also test for containment of entire categories: >>> CurrentLegal >= (Move - Move.JUMP) True ...and for proper superset (or subset): >>> CurrentLegal > (Move - Move.JUMP) True Tip: The ``_()`` function should be used in categories to designate messages that need to be translated, and that function will be applied when categories are being initialized. To permit changing language after initialization, keep the initialized messages in the original language (the one for which you have translations) by setting this before setting the categories:: def _(message: str) -> str: return message ... then set ``_()`` to the actual translation function after setting the categories. References: enum.Enum_ """ def __getattr__(self, name): if ( name == "_value_" or not hasattr(self, "_value_") or not hasattr(self._value_, name) ): return enum.Enum.__getattribute__(self, name) else: return babelwrap._(getattr(self._value_, name)) def __setattr__(self, name, new_value): if ( name == "_value_" or not hasattr(self, "_value_") or not hasattr(self._value_, name) ): enum.Enum.__setattr__(self, name, new_value) else: raise AttributeError( "Can't change attribute (name: {name}) " "of {enum}".format(name=name, enum=repr(self)) ) def __delattr__(self, name): if hasattr(self, "_value_") and hasattr(self._value_, name): raise AttributeError( "Can't delete attribute (name: {name}) " "of {enum}".format(name=name, enum=repr(self)) ) else: enum.Enum.__delattr__(self, name) def __dir__(self): result = enum.Enum.__dir__(self) for name in dir(self._value_): if name not in result and name[0] != "_": result.append(name) return sorted(result) def __bool__(self): return not hasattr(self, "VERSIONS") or _version in self.VERSIONS def __str__(self): return self.STR if hasattr(self, "STR") else babelwrap._(str(self.value)) def __hash__(self): return hash(self.name) def __reduce_ex__(self): return enum.Enum.__reduce_ex__(self) def __call__(self, *args, **kwargs): if hasattr(self, "CALL"): if hasattr(self.CALL, "__base__") and self.CALL.__base__ == tuple: obj = object.__new__(self.__class__) obj._value_ = self.CALL(*args, **kwargs) obj._name_ = self.name return obj else: return self.CALL(self, *args, **kwargs) else: return self def __eq__(self, other): """To support call that creates new instance""" return (self.name == other.name) and (self.value == other.value) def __neq__(self, other): """To support call that creates new instance""" return not (self == other) def __int__(self): return list(type(self)).index(self) def __or__(self, other): # Union return ctg(self) | other
def _uniquify(name: str, collection: Iterable[str]) -> str: """Make name unique by adding small int to end, E.g.: >>> _uniquify("JUMP1", ["JUMP1"]) 'JUMP2' Arg: name (str): The name to be made unique collection: The collection in which to be unique Return: A name that is not already in the collection and that ends in the smallest positive integer suffix required to make it unique. """ counter = 1 while name in collection: name = re.fullmatch(r"(\w+\D)(\d*)", name).group(1) + str(counter) # type: ignore[union-attr] counter += 1 return name
[docs]def ctg( *members: Iterable, name: str = "Categorized", uniquify: bool = False, ) -> type: """Generate a new `Category`_ from one or more `Categorized`_. E.g.:: ctg(Color.BLACK, Marker.CIRCLE) Args: *members: The members for the new `Category`_. name (str): The name of the new `Category`_. Defaults to "Categorized" uniquify (bool): If ``True``, name collisions will be resolved by altering member names. Useful with factory members. Defaults to ``False``. Returns: The new `Category`_. Raises: TypeError: If attempt to combine non-equal members having the same name without setting ``uniquify`` to ``True``. """ if len(members) == 1: if isinstance(members[0], Iterable): members = tuple(members[0]) else: member = (members[0],) classdict = Category.__prepare__(name, (Categorized,)) for member in members: if not isinstance(member, Categorized): raise TypeError( """'{member}' object cannot be interpreted as a Categorized""".format( member=type(member).__name__ ) ) elif uniquify: classdict[_uniquify(str(member.name), classdict)] = member.value # type: ignore[index] elif member.name in classdict: raise TypeError(f"""Attemped to reuse key: '{member.name}'""") else: classdict[member.name] = member.value # type: ignore[index] category = Category.__new__(Category, name, (Categorized,), classdict) # type: ignore[call-overload] category.__module__ = __name__ category.__qualname__ = Categorized.__qualname__ bases: List[type] = [] for member in reversed(members): member_bases = getattr(member, "_catbases_", [member.__class__]) for base in member_bases: if base not in bases: bases.insert(0, base) names = [item.name for item in list(base)] for attr in list(base.__dict__): if attr not in enum.Enum.__dict__ and attr not in names: setattr(category, attr, getattr(base, attr)) catbases = bases if name == "Categorized" else [category] setattr(category, "_catbases_", catbases) if len(bases) == 1: category.__doc__ = bases[0].__doc__ else: base_list = babelwrap.format_list([base.__name__ for base in catbases]) category.__doc__ = """A Category derived from {bases}""".format( bases=base_list ) return category