diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..4603c7c --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,64 @@ +# Read the Docs configuration file for Sphinx projects + +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + + +# Required + +version: 2 + + +# Set the OS, Python version and other tools you might need + +build: + + os: ubuntu-22.04 + + tools: + + python: "3.12" + + # You can also specify other tool versions: + + # nodejs: "20" + + # rust: "1.70" + + # golang: "1.20" + + +# Build documentation in the "docs/" directory with Sphinx + +sphinx: + + configuration: docs/conf.py + + # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs + + # builder: "dirhtml" + + # Fail on all warnings to avoid broken references + + # fail_on_warning: true + + +# Optionally build your docs in additional formats such as PDF and ePub + +# formats: + +# - pdf + +# - epub + + +# Optional but recommended, declare the Python requirements required + +# to build your documentation + +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html + +python: + + install: + + - requirements: docs/requirements.txt diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0c235c5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "workbench.colorTheme": "Default Dark Modern", + "esbonio.sphinx.confDir": "${workspaceFolder}\\docs" +} \ No newline at end of file diff --git a/autodoc_typehints.py b/autodoc_typehints.py new file mode 100644 index 0000000..2875a67 --- /dev/null +++ b/autodoc_typehints.py @@ -0,0 +1,380 @@ +r""" + Autodoc extension dealing with local type references and function signatures. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +import inspect +import re +from types import FunctionType, ModuleType +from typing import Any, Optional, Union +from typing_extensions import Literal +from sphinx.application import Sphinx + +@dataclass(frozen=True) +class ParsedType: + r""" Dataclass for a parsed type. """ + name: str + args: Union[None, str, tuple[ParsedType, ...]] = None + variadic: bool = False + + def crossref(self, globalns: Optional[Mapping[str, Any]] = None) -> str: + r""" Generates Sphinx cross-reference link for the given type, using local names. """ + # pylint: disable = eval-used + if globalns is None: + globalns = {} + name, args, variadic = self.name, self.args, self.variadic + role = "obj" + if name in globalns: + obj = globalns[name] + if isinstance(obj, ModuleType): + role = "mod" + elif isinstance(obj, property): + role = "attr" + elif isinstance(obj, type): + role = "class" + elif isinstance(obj, FunctionType): + role = "func" + name_crossref = f":{role}:`{name}`" + if args is None: + return name_crossref + if isinstance(args, str): + _args = eval(f"({args}, )") + arg_crossrefs = ", ".join(f"``{repr(arg)}``" for arg in _args) + else: + arg_crossrefs = ", ".join((arg.crossref(globalns) for arg in args)) + if variadic: + arg_crossrefs += ", ..." + return fr"{name_crossref}\ [{arg_crossrefs}]" + +def _find_closing_idx(s: str, c_open: str, c_close: str, idx_open: int = 0) -> int: + r""" Finds the index where a bracketed/quoted range ends. """ + assert len(c_open) == 1, c_open + assert len(c_close) == 1, c_close + assert idx_open < len(s), (idx_open, len(s)) + assert s[idx_open] == c_open, (idx_open, s[idx_open]) + lvl = 1 + idx_close: Optional[int] = None + for idx in range(idx_open+1, len(s)): + if s[idx] == c_close: + lvl -= 1 + elif s[idx] == c_open: + lvl += 1 + if lvl == 0: + idx_close = idx + break + if idx_close is None: + error_msg = f"Unbalanced opening symbol found while searching for first outermost {c_open}...{c_close} in {repr(s)}." + raise ValueError(error_msg) + return idx_close + +def _parse_type(annotation: str) -> tuple[Union[ParsedType, Literal["..."]], str]: + r""" + Parses an annotation into the first type appearing in it, together with the unparsed remainder of the annotation. + """ + quote_close_idx = None + if annotation.startswith("'"): + quote_close_idx = _find_closing_idx(annotation, "'", "'", 0) + elif annotation.startswith('"'): + quote_close_idx = _find_closing_idx(annotation, '"', '"', 0) + if quote_close_idx is not None: + if quote_close_idx == 1: + raise ValueError(f"Cannot parse empty forward reference at start of annotation: annotation = {annotation}") + annotation = annotation[1:quote_close_idx]+annotation[quote_close_idx+1:] + try: + b_open: Optional[int] = annotation.index("[") + except ValueError: + b_open = None + try: + c_idx = annotation.index(",") + except ValueError: + c_idx = None + if b_open is not None and (c_idx is None or b_open < c_idx): + b_close = _find_closing_idx(annotation, "[", "]", b_open) + name = annotation[:b_open] + args_str = annotation[b_open+1:b_close] + res = annotation[b_close+1:] + assert name, (name, args_str, res) + # FIXME: this doesn't work with Callable[[...], ...] + if name.split(".")[-1] == "Literal": + return ParsedType(name, args_str), res + args: list[ParsedType] = [] + variadic = False + while args_str: + arg, _args_str = _parse_type(args_str) + if isinstance(arg, ParsedType): + args.append(arg) + else: + assert arg == "...", arg + variadic = True + if _args_str: + raise ValueError(f"Ellipsis argument encountered in parametric type, but not at the end of args lis: annotation = {annotation}, args_str = {args_str}") + if not _args_str: + break + if not _args_str.startswith(", "): + raise ValueError(f"Multiple type parameters must be separated by ', ': annotation = {annotation}, args_str = {args_str}, arg = {arg} _args_str = {_args_str}") + args_str = _args_str[2:] + return ParsedType(name, tuple(args), variadic), res + if c_idx is not None: + name = annotation[:c_idx] + res = annotation[c_idx:] + if name == "...": + raise ValueError(f"Found ellipsis followed by comma: annotation = {annotation}") + return ParsedType(name), res + if "]" in annotation: + raise ValueError(f"Encountered closing bracket ']' without any opening bracket: annotation = {annotation} ") + name = annotation + if name == "...": + return "...", "" + return ParsedType(name), "" + +def parse_type(annotation: str) -> ParsedType: + r""" Parses an annotation into a type. """ + # Handle top-level unions: + # FIXME: this doesn't handle nested unions. + if "|" in annotation: + member_types = [ + parse_type(member_annotation.strip()) + for member_annotation in annotation.split("|") + ] + return ParsedType("typing.Union", tuple(member_types)) + parsed_type, residual_string = _parse_type(annotation) + if residual_string: + raise ValueError(f"Annotation was not entirely consumed by parsing: annotation = {annotation!r}, parsed_type = {parsed_type}, residual_string = {residual_string!r}") + if not isinstance(parsed_type, ParsedType): + raise ValueError(f"Cannot parse ellipsis on its own: annotation = {annotation}") + return parsed_type + +def sigdoc(fun: FunctionType, lines: list[str]) -> None: + r""" + Returns doclines documenting the parameter and return type of the given function + """ + # pylint: disable = too-many-branches + doc = "\n".join(lines) + lines.append("") + # FIXME: if an :rtype: line already exists, remove it here and re-append it after all param type lines. + globalns = fun.__globals__ + sig = inspect.signature(fun) + for p in sig.parameters.values(): + annotation = p.annotation + if annotation == p.empty: + continue + if not isinstance(annotation, str): + print(f"WARNING! Found non-string annotation: {repr(annotation)}. Did you forget to import annotation from __future__?.") + annotation = str(annotation) + t = parse_type(annotation) + tx = t.crossref(globalns) + default = p.default if p.default != p.empty else None + is_args = p.kind == p.VAR_POSITIONAL + is_kwargs = p.kind == p.VAR_KEYWORD + if is_args: + extra_info = "variadic positional" + elif is_kwargs: + extra_info = "variadic keyword" + elif default is not None: + default_str = default.__qualname__ if isinstance(default, FunctionType) else repr(default) + extra_info = f"default = ``{default_str}``" + else: + extra_info = None + if extra_info is None: + line = f":type {p.name}: {tx}" + else: + line = f":type {p.name}: {tx}; {extra_info}" + if f":param {p.name}:" not in doc: + lines.append(f":param {p.name}:") + if f":type {p.name}:" not in doc: + lines.append(line) + if sig.return_annotation == sig.empty: + return + t = parse_type(sig.return_annotation) + tx = t.crossref(globalns) + line = f":rtype: {tx}" + if ":rtype:" not in doc: + lines.append(line) + +def sigdoc_handler(app: Sphinx, what: str, fullname: str, obj: Any, options: Any, lines: list[str]) -> None: + r""" + Handler for Sphinx Autodoc's event + `autodoc-process-docstring`_ + which replaces cross-references specified in terms of module globals with their fully qualified version. + """ + # pylint: disable = too-many-arguments + if what not in ("function", "method", "property"): + return + if what == "property": + fun: FunctionType = obj.fget + else: + fun = obj + sigdoc(fun, lines) + +def simple_crossref_pattern(name: str) -> re.Pattern[str]: + r""" + Pattern for simple imports: + + .. code-block :: python + + f":{role}:`{name}`" # e.g. ":class:`MyClass`" + f":{role}:`~{name}`" # e.g. ":class:`~MyClass`" + f":{role}:`{name}{tail}`" # e.g. ":attr:`MyClass.my_property.my_subproperty`" + f":{role}:`~{name}{tail}`" # e.g. ":attr:`~MyClass.my_property.my_subproperty`" + + """ + return re.compile(rf":([a-z]+):`(~)?{name}(\.[\.a-zA-Z0-9_]+)?`") + +def simple_crossref_repl(name: str, fullname: str) -> Callable[[re.Match[str]], str]: + r""" + Replacement function for the pattern generated by :func:`simple_crossref_pattern`: + + .. code-block :: python + + f":{role}:`~{fullname}`" # e.g. ":class:`~mymod.mysubmod.MyClass`" + f":{role}:`~{fullname}`" # e.g. ":class:`~mymod.mysubmod.MyClass`" + f":{role}:`{name}{tail}<{fullname}{tail}>`" # e.g. ":attr:`MyClass.my_property.my_subproperty`" + f":{role}:`~{fullname}{tail}`" # e.g. ":attr:`~mymod.mysubmod.MyClass.my_property.my_subproperty`" + + """ + def repl(match: re.Match[str]) -> str: + role = match[1] + short = match[2] is not None + tail = match[3] + if tail is None: + return f":{role}:`~{fullname}`" + if short: + return f":{role}:`~{fullname}{tail}`" + return f":{role}:`{name}{tail}<{fullname}{tail}>`" + return repl + +def labelled_crossref_pattern(name: str) -> re.Pattern[str]: + r""" + Pattern for labelled imports: + + .. code-block :: python + + f":{role}:`{label}<{name}>`" # e.g. ":class:`my class`" + f":{role}:`{label}<{name}{tail}>`" # e.g. ":attr:`my_property`" + + """ + return re.compile(rf":([a-z]+):`([\.a-zA-Z0-9_]+)<{name}(\.[\.a-zA-Z0-9_]+)?>`") + +def labelled_crossref_repl(name: str, fullname: str) -> Callable[[re.Match[str]], str]: + r""" + Replacement function for the pattern generated by :func:`labelled_crossref_pattern`: + + .. code-block :: python + + f":{role}:`{label}<{fullname}>`" # e.g. ":class:`my class`" + f":{role}:`{label}<{fullname}{tail}>`" # e.g. ":attr:`my_property`" + + """ + def repl(match: re.Match[str]) -> str: + role = match[1] + label = match[2] + tail = match[3] + if tail is None: + return f":{role}:`{label}<{fullname}>`" + return f":{role}:`{label}<{fullname}{tail}>`" + return repl + +_crossref_subs: list[tuple[Callable[[str], re.Pattern[str]], + Callable[[str, str], Callable[[re.Match[str]], str]]]] = [ + (simple_crossref_pattern, simple_crossref_repl), + (labelled_crossref_pattern, labelled_crossref_repl), +] +r""" + Substitution patterns and replacement functions for various kinds of cross-reference scenarios. +""" + +def _get_module_by_name(modname: str) -> ModuleType: + r""" + Gathers a module object by name. + """ + # pylint: disable = exec-used, eval-used + exec(f"import {modname.split('.')[0]}") + mod: ModuleType = eval(modname) + if not isinstance(mod, ModuleType): + return None + return mod + +def _get_obj_mod(app: Sphinx, what: str, fullname: str, obj: Any) -> Optional[ModuleType]: + r""" + Gathers the containing module for the given ``obj``. + """ + autodoc_type_aliases = app.config.__dict__.get("autodoc_type_aliases") + name = fullname.split(".")[-1] + obj_mod: Optional[ModuleType] + if autodoc_type_aliases is not None: + if name in autodoc_type_aliases and fullname == autodoc_type_aliases[name]: + modname = ".".join(fullname.split(".")[:-1]) + obj_mod = _get_module_by_name(modname) + return obj_mod + if what == "module": + obj_mod = obj + elif what in ("function", "class", "method"): + obj_mod = inspect.getmodule(obj) + elif what == "property": + obj_mod = inspect.getmodule(obj.fget) + elif what == "data": + modname = ".".join(fullname.split(".")[:-1]) + obj_mod = _get_module_by_name(modname) + elif what == "attribute": + modname = ".".join(fullname.split(".")[:-2]) + obj_mod = _get_module_by_name(modname) + else: + print(f"WARNING! Encountered unexpected value for what = {what} at fullname = {fullname}") + obj_mod = None + return obj_mod + +def _build_fullname_dict(app: Sphinx, fullname: str, obj_mod: Optional[ModuleType], ) -> dict[str, str]: + r""" + Builds a dictionary of substitutions from module global names to their fully qualified names, + based on :func:`inspect.getmodule` and `autodoc_type_aliases` (if specified in the Sphinx app config). + """ + autodoc_type_aliases = app.config.__dict__.get("autodoc_type_aliases") + fullname_dict: dict[str, str] = {} + if obj_mod is not None: + globalns = obj_mod.__dict__ + for g_name, g_obj in globalns.items(): + if isinstance(g_obj, (FunctionType, type)): + g_mod = inspect.getmodule(g_obj) + elif isinstance(g_obj, ModuleType): + g_mod = g_obj + else: + g_mod = inspect.getmodule(g_obj) + if g_mod is None or g_mod == obj_mod: + continue + if g_name not in g_mod.__dict__: + continue + g_modname = g_mod.__name__ + fullname_dict[g_name] = f"{g_modname}.{g_name}" + if autodoc_type_aliases is not None: + for a_name, a_fullname in autodoc_type_aliases.items(): + if a_name not in fullname_dict: + fullname_dict[a_name] = a_fullname + return fullname_dict + +def local_crossref_handler(app: Sphinx, what: str, fullname: str, obj: Any, options: Any, lines: list[str]) -> None: + r""" + Handler for Sphinx Autodoc's event + `autodoc-process-docstring`_ + which replaces cross-references specified in terms of module globals with their fully qualified version. + """ + # pylint: disable = too-many-arguments, too-many-locals + obj_mod = _get_obj_mod(app, what, fullname, obj) + fullname_dict = _build_fullname_dict(app, fullname, obj_mod) + for sub_name, sub_fullname in fullname_dict.items(): + for idx, line in enumerate(lines): + for pattern_fun, repl_fun in _crossref_subs: + pattern = pattern_fun(sub_name) + repl = repl_fun(sub_name, sub_fullname) + line = re.sub(pattern, repl, line) + lines[idx] = line + +def setup(app: Sphinx) -> None: + r""" + Registers handlers for Sphinx Autodoc's event + `autodoc-process-docstring`_ + """ + app.connect("autodoc-process-docstring", sigdoc_handler) + app.connect("autodoc-process-docstring", local_crossref_handler) diff --git a/bases/__init__.py b/bases/__init__.py index af8240e..97f6183 100644 --- a/bases/__init__.py +++ b/bases/__init__.py @@ -35,7 +35,9 @@ """ -__version__ = "0.2.1" +from __future__ import annotations + +__version__ = "0.3.0" from . import encoding as encoding from . import alphabet as alphabet diff --git a/bases/alphabet/__init__.py b/bases/alphabet/__init__.py index 73a4b99..2716adf 100644 --- a/bases/alphabet/__init__.py +++ b/bases/alphabet/__init__.py @@ -2,6 +2,8 @@ Module containing classes for alphabets. """ +from __future__ import annotations + import re from typing import Collection, Dict, Iterator, Optional, overload, Tuple, Union from typing_extensions import Literal diff --git a/bases/alphabet/abstract.py b/bases/alphabet/abstract.py index 4dbb1ef..9173bad 100644 --- a/bases/alphabet/abstract.py +++ b/bases/alphabet/abstract.py @@ -2,13 +2,13 @@ Abstract alphabets. """ +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Any, Mapping, Optional, overload, Sequence, TypeVar, Union +from typing import Any, Mapping, Optional, overload, Sequence, Union +from typing_extensions import Self from typing_validation import validate -AlphabetSubclass = TypeVar("AlphabetSubclass", bound="Alphabet") -""" Type variable for subclasses of :class:`Alphabet`. """ - class Alphabet(ABC, Sequence[str]): """ Abstract superclass for alphabets with specified case sensitivity. @@ -132,10 +132,9 @@ def revdir(self) -> Mapping[str, int]: 'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15}) """ - ... @abstractmethod - def with_case_sensitivity(self: AlphabetSubclass, case_sensitive: bool) -> AlphabetSubclass: + def with_case_sensitivity(self, case_sensitive: bool) -> Self: """ Returns a new alphabet with the same characters as this one but with specified case sensitivity. @@ -149,12 +148,10 @@ def with_case_sensitivity(self: AlphabetSubclass, case_sensitive: bool) -> Alpha :param case_sensitive: whether the new alphabet is case-sensitive :type case_sensitive: :obj:`bool`, *optional* - :rtype: :obj:`AlphabetSubclass` """ - ... @abstractmethod - def upper(self) -> "Alphabet": + def upper(self) -> Alphabet: """ Returns a new alphabet with all cased characters turned to uppercase. @@ -168,10 +165,9 @@ def upper(self) -> "Alphabet": case_sensitive=False) """ - ... @abstractmethod - def lower(self) -> "Alphabet": + def lower(self) -> Alphabet: """ Returns a new alphabet with all cased characters turned to lowercase. @@ -183,7 +179,6 @@ def lower(self) -> "Alphabet": StringAlphabet('0123456789abcdef') """ - ... @abstractmethod def __len__(self) -> int: @@ -194,9 +189,10 @@ def __getitem__(self, idx: int) -> str: ... @overload - def __getitem__(self, idx: slice) -> "Alphabet": + def __getitem__(self, idx: slice) -> Alphabet: ... @abstractmethod - def __getitem__(self, idx: Union[int, slice]) -> Union[str, "Alphabet"]: #type: ignore + def __getitem__(self, idx: Union[int, slice]) -> Union[str, Alphabet]: ... + diff --git a/bases/alphabet/range_alphabet.py b/bases/alphabet/range_alphabet.py index 0efbe11..0a95df9 100644 --- a/bases/alphabet/range_alphabet.py +++ b/bases/alphabet/range_alphabet.py @@ -2,6 +2,8 @@ Alphabets implicitly specified by Unicode codepoint range. """ +from __future__ import annotations + from typing import Any, Iterator, Mapping, overload, Union from typing_validation import validate diff --git a/bases/alphabet/string_alphabet.py b/bases/alphabet/string_alphabet.py index 55dfe82..4d1466d 100644 --- a/bases/alphabet/string_alphabet.py +++ b/bases/alphabet/string_alphabet.py @@ -2,6 +2,8 @@ Alphabets explicitly specified by strings. """ +from __future__ import annotations + from types import MappingProxyType from typing import Any, Mapping, overload, Union from typing_validation import validate diff --git a/bases/encoding/__init__.py b/bases/encoding/__init__.py index 9ebedfe..95e76f5 100644 --- a/bases/encoding/__init__.py +++ b/bases/encoding/__init__.py @@ -3,6 +3,8 @@ """ +from __future__ import annotations + import re from typing import Any, cast, Collection, Dict, Iterator, Mapping, Optional, overload, Tuple, Type, Union from typing_extensions import Literal @@ -165,11 +167,13 @@ def make(chars: Union[str, range, Alphabet], *, kind: Literal["simple-enc"], nam @overload def make(chars: Union[str, range, Alphabet], *, kind: Literal["zeropad-enc"], name: Optional[str] = None, case_sensitive: Optional[bool] = None, block_nbytes: int = 1, block_nchars: int = 1) -> ZeropadBaseEncoding: + # pylint: disable = too-many-arguments ... @overload def make(chars: Union[str, range, Alphabet, BaseEncoding], *, kind: Literal["block-enc"], name: Optional[str] = None, case_sensitive: Optional[bool] = None, block_size: Union[int, Mapping[int, int]], sep_char: str = "") -> BlockBaseEncoding: + # pylint: disable = too-many-arguments ... @overload diff --git a/bases/encoding/base.py b/bases/encoding/base.py index bbf73d6..822e204 100644 --- a/bases/encoding/base.py +++ b/bases/encoding/base.py @@ -2,6 +2,8 @@ Abstract base encodings. """ +from __future__ import annotations + from abc import ABC, abstractmethod from typing import Any, Mapping, Optional, TypeVar, Union from typing_extensions import Final @@ -274,7 +276,6 @@ def canonical_string(self, s: str) -> str: return self.encode(self.decode(s)) def _validate_bytes(self, b: BytesLike) -> memoryview: - # pylint: disable = no-self-use validate(b, BytesLike) return memoryview(b) @@ -310,7 +311,6 @@ def options(self, skip_defaults: bool = False) -> Mapping[str, Any]: :type skip_defaults: :obj:`bool`, *optional* """ - ... def __eq__(self, other: Any) -> bool: if not isinstance(other, BaseEncoding): diff --git a/bases/encoding/block.py b/bases/encoding/block.py index e7535b5..412a021 100644 --- a/bases/encoding/block.py +++ b/bases/encoding/block.py @@ -64,6 +64,8 @@ """ +from __future__ import annotations + import math from types import MappingProxyType from typing import Any, Dict, List, Mapping, Optional, Union, TypeVar @@ -116,6 +118,7 @@ def __init__(self, encoding: Union[str, range, Alphabet, BaseEncoding], *, block_size: Union[int, Mapping[int, int]], sep_char: str = "", reverse_blocks: bool = False): + # pylint: disable = too-many-arguments validate(encoding, Union[str, range, Alphabet, BaseEncoding]) validate(block_size, Union[int, Mapping[int, int]]) validate(sep_char, str) diff --git a/bases/encoding/errors.py b/bases/encoding/errors.py index 9d593e5..9335cc3 100644 --- a/bases/encoding/errors.py +++ b/bases/encoding/errors.py @@ -2,6 +2,8 @@ Encoding error classes. """ +from __future__ import annotations + import binascii from typing_validation import validate @@ -11,13 +13,11 @@ class Error(binascii.Error): """ Generic encoding or decoding error. """ - ... class EncodingError(Error): """ Generic encoding error. """ - ... class InvalidDigitError(EncodingError): """ @@ -42,13 +42,11 @@ class InvalidByteBlockError(EncodingError): """ Encoding error raised by block encodings when a byte block is invalid. """ - ... class DecodingError(Error): """ Generic decoding error. """ - ... class NonAlphabeticCharError(DecodingError): """ @@ -94,4 +92,3 @@ class InvalidCharBlockError(DecodingError): """ Decoding error raised by block encodings when a char block is invalid. """ - ... diff --git a/bases/encoding/fixchar.py b/bases/encoding/fixchar.py index c4dab48..1f56fdf 100644 --- a/bases/encoding/fixchar.py +++ b/bases/encoding/fixchar.py @@ -55,6 +55,8 @@ 8. converts ``i`` to its minimal byte representation (big-endian), then zero-pad on the left to reach ``original_nbytes`` bytes """ +from __future__ import annotations + import math from typing import Any, Dict, List, Mapping, Optional, Union from typing_extensions import Literal @@ -103,6 +105,7 @@ def __init__(self, alphabet: Union[str, range, Alphabet], *, char_nbits: Union[int, Literal["auto"]] = "auto", pad_char: Optional[str] = None, padding: PaddingOptions = "ignore"): + # pylint: disable = too-many-arguments validate(char_nbits, Union[int, Literal["auto"]]) validate(pad_char, Optional[str]) validate(padding, PaddingOptions) @@ -232,7 +235,7 @@ def pad(self, require: bool = False) -> "FixcharBaseEncoding": :type require: :obj:`bool` """ validate(require, bool) - options = dict(padding="require" if require else "include") + options = {"padding": 'require' if require else 'include'} return self.with_options(**options) def nopad(self, allow: bool = True) -> "FixcharBaseEncoding": @@ -261,7 +264,10 @@ def nopad(self, allow: bool = True) -> "FixcharBaseEncoding": :type allow: :obj:`bool` """ validate(allow, bool) - options = dict(padding="ignore", pad_char=self.pad_char if allow else None) + options = { + "padding": "ignore", + "pad_char": self.pad_char if allow else None + } return self.with_options(**options) def with_pad_char(self, pad_char: Optional[str]) -> "FixcharBaseEncoding": @@ -286,7 +292,7 @@ def with_pad_char(self, pad_char: Optional[str]) -> "FixcharBaseEncoding": :type pad_char: :obj:`str` or :obj:`None` """ validate(pad_char, Optional[str]) - options: Dict[str, Any] = dict(pad_char=pad_char) + options: Dict[str, Any] = {"pad_char": pad_char} if pad_char is None: options["padding"] = "ignore" return self.with_options(**options) diff --git a/bases/encoding/simple.py b/bases/encoding/simple.py index c516474..ff2dc34 100644 --- a/bases/encoding/simple.py +++ b/bases/encoding/simple.py @@ -14,6 +14,8 @@ 3. converts ``i`` to its minimal byte representation (big-endian) """ +from __future__ import annotations + from typing import Any, List, Mapping, Optional, Union from typing_validation import validate diff --git a/bases/encoding/zeropad.py b/bases/encoding/zeropad.py index f68d302..d7bad53 100644 --- a/bases/encoding/zeropad.py +++ b/bases/encoding/zeropad.py @@ -30,6 +30,8 @@ 4. prepend Z zero byte blocks to the encoded string """ +from __future__ import annotations + import math from typing import Any, Dict, Mapping, Optional, Union from typing_validation import validate diff --git a/bases/random.py b/bases/random.py index 636f899..0ca1f53 100644 --- a/bases/random.py +++ b/bases/random.py @@ -1,6 +1,9 @@ """ Functions to generate random data. """ + +from __future__ import annotations + # pylint: disable = global-statement from contextlib import contextmanager @@ -73,9 +76,9 @@ def options(*, validate(arg, Optional[int]) global _options global _rand + _old_options = _options + _old_rand = _rand try: - _old_options = _options - _old_rand = _rand set_options(seed=seed, min_bytes=min_bytes, max_bytes=max_bytes, min_chars=min_chars, max_chars=max_chars,) diff --git a/docs/api/bases.alphabet.abstract.rst b/docs/api/bases.alphabet.abstract.rst index dd31dac..b33b298 100644 --- a/docs/api/bases.alphabet.abstract.rst +++ b/docs/api/bases.alphabet.abstract.rst @@ -7,9 +7,5 @@ Alphabet -------- .. autoclass:: bases.alphabet.abstract.Alphabet + :show-inheritance: :members: - -AlphabetSubclass ----------------- - -.. autodata:: bases.alphabet.abstract.AlphabetSubclass diff --git a/docs/api/bases.alphabet.range_alphabet.rst b/docs/api/bases.alphabet.range_alphabet.rst index 6fca33c..58327d4 100644 --- a/docs/api/bases.alphabet.range_alphabet.rst +++ b/docs/api/bases.alphabet.range_alphabet.rst @@ -7,4 +7,5 @@ RangeAlphabet ------------- .. autoclass:: bases.alphabet.range_alphabet.RangeAlphabet + :show-inheritance: :members: diff --git a/docs/api/bases.alphabet.string_alphabet.rst b/docs/api/bases.alphabet.string_alphabet.rst index 798ceb7..89473f9 100644 --- a/docs/api/bases.alphabet.string_alphabet.rst +++ b/docs/api/bases.alphabet.string_alphabet.rst @@ -7,4 +7,5 @@ StringAlphabet -------------- .. autoclass:: bases.alphabet.string_alphabet.StringAlphabet + :show-inheritance: :members: diff --git a/docs/api/bases.encoding.base.rst b/docs/api/bases.encoding.base.rst index 4e5edf5..4b458c4 100644 --- a/docs/api/bases.encoding.base.rst +++ b/docs/api/bases.encoding.base.rst @@ -7,6 +7,7 @@ BaseEncoding ------------ .. autoclass:: bases.encoding.base.BaseEncoding + :show-inheritance: :members: BaseEncodingSubclass diff --git a/docs/api/bases.encoding.block.rst b/docs/api/bases.encoding.block.rst index 4122569..143c76d 100644 --- a/docs/api/bases.encoding.block.rst +++ b/docs/api/bases.encoding.block.rst @@ -7,6 +7,7 @@ BlockBaseEncoding ----------------- .. autoclass:: bases.encoding.block.BlockBaseEncoding + :show-inheritance: :members: BlockBaseEncodingSubclass diff --git a/docs/api/bases.encoding.errors.rst b/docs/api/bases.encoding.errors.rst index 31d03ef..bf09a33 100644 --- a/docs/api/bases.encoding.errors.rst +++ b/docs/api/bases.encoding.errors.rst @@ -7,46 +7,54 @@ DecodingError ------------- .. autoclass:: bases.encoding.errors.DecodingError + :show-inheritance: :members: EncodingError ------------- .. autoclass:: bases.encoding.errors.EncodingError + :show-inheritance: :members: Error ----- .. autoclass:: bases.encoding.errors.Error + :show-inheritance: :members: InvalidByteBlockError --------------------- .. autoclass:: bases.encoding.errors.InvalidByteBlockError + :show-inheritance: :members: InvalidCharBlockError --------------------- .. autoclass:: bases.encoding.errors.InvalidCharBlockError + :show-inheritance: :members: InvalidDigitError ----------------- .. autoclass:: bases.encoding.errors.InvalidDigitError + :show-inheritance: :members: NonAlphabeticCharError ---------------------- .. autoclass:: bases.encoding.errors.NonAlphabeticCharError + :show-inheritance: :members: PaddingError ------------ .. autoclass:: bases.encoding.errors.PaddingError + :show-inheritance: :members: diff --git a/docs/api/bases.encoding.fixchar.rst b/docs/api/bases.encoding.fixchar.rst index 0060092..b4e0c18 100644 --- a/docs/api/bases.encoding.fixchar.rst +++ b/docs/api/bases.encoding.fixchar.rst @@ -7,6 +7,7 @@ FixcharBaseEncoding ------------------- .. autoclass:: bases.encoding.fixchar.FixcharBaseEncoding + :show-inheritance: :members: PaddingOptions diff --git a/docs/api/bases.encoding.simple.rst b/docs/api/bases.encoding.simple.rst index 51472f3..cd6a375 100644 --- a/docs/api/bases.encoding.simple.rst +++ b/docs/api/bases.encoding.simple.rst @@ -7,4 +7,5 @@ SimpleBaseEncoding ------------------ .. autoclass:: bases.encoding.simple.SimpleBaseEncoding + :show-inheritance: :members: diff --git a/docs/api/bases.encoding.zeropad.rst b/docs/api/bases.encoding.zeropad.rst index 643f028..2bec6c6 100644 --- a/docs/api/bases.encoding.zeropad.rst +++ b/docs/api/bases.encoding.zeropad.rst @@ -7,4 +7,5 @@ ZeropadBaseEncoding ------------------- .. autoclass:: bases.encoding.zeropad.ZeropadBaseEncoding + :show-inheritance: :members: diff --git a/docs/autodoc-type-aliases.json b/docs/autodoc-type-aliases.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/docs/autodoc-type-aliases.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/docs/conf.py b/docs/conf.py index 704b4ef..28369ff 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,6 +11,11 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # + +from __future__ import annotations + +import inspect +import json import os import sys sys.path.insert(0, os.path.abspath('..')) @@ -19,7 +24,7 @@ # -- Project information ----------------------------------------------------- project = 'bases' -copyright = '2021, Hashberg' +copyright = '2023, Hashberg' author = 'Hashberg' @@ -28,9 +33,9 @@ # built documents. # # The full version, including alpha/beta/rc tags. -release = "0.2.1" +release = "0.3.0" # The short X.Y version. -version = "0.2.1" +version = "0.3.0" # -- General configuration --------------------------------------------------- @@ -43,17 +48,28 @@ 'sphinx.ext.mathjax', 'sphinx.ext.intersphinx', 'sphinx_rtd_theme', - 'sphinx_autodoc_typehints', - 'sphinx.ext.viewcode' + 'sphinx.ext.viewcode', + 'autodoc_typehints', ] -add_function_parentheses = False -set_type_checking_flag = True -typehints_fully_qualified = False -typehints_document_rtype = True -simplify_optional_unions = True -add_module_names = False -intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} +nitpicky = True # warn about every broken reference +add_function_parentheses = False # no parentheses after function names +add_module_names = False # no module names at start of classes +set_type_checking_flag = False # setting to True creates issues when dealing with circular dependencies introduced for static typechecking +autodoc_typehints = "none" # don't document type hints, extension 'autodoc_typehints' will take care of it + +with open("autodoc-type-aliases.json", "r") as f: + autodoc_type_aliases = json.load(f) # load type aliases generated by make-api.py + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', "./intersphinx/python-objects.inv"), + 'typing_validation': ('https://typing-validation.readthedocs.io/en/latest', "./intersphinx/typing-validation-objects.inv"), + 'numpy': ('https://numpy.org/doc/stable/', "./intersphinx/numpy-objects.inv"), + 'scipy': ('https://scipy.github.io/devdocs/', "./intersphinx/scipy-objects.inv"), + 'pandas': ('https://pandas.pydata.org/docs/', "./intersphinx/pandas-objects.inv"), + 'networkx': ('https://networkx.org/documentation/stable/', "./intersphinx/networkx-objects.inv"), + 'matplotlib': ('https://matplotlib.org/stable/', "./intersphinx/matplotlib-objects.inv"), +} # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -79,3 +95,21 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] + +from sphinx.application import Sphinx +from sphinx.environment import BuildEnvironment +from sphinx.addnodes import pending_xref +from typing import Any + +skip_missing_references: set[str] = { + "BytesLike" +} + +def on_missing_reference(app: Sphinx, env: BuildEnvironment, node: pending_xref, contnode: Any) -> Any: + if node['reftarget'] in skip_missing_references: + return contnode + else: + return None + +def setup(app: Sphinx) -> None: + app.connect('missing-reference', on_missing_reference) diff --git a/docs/intersphinx/matplotlib-objects.inv b/docs/intersphinx/matplotlib-objects.inv new file mode 100644 index 0000000..2291829 Binary files /dev/null and b/docs/intersphinx/matplotlib-objects.inv differ diff --git a/docs/intersphinx/networkx-objects.inv b/docs/intersphinx/networkx-objects.inv new file mode 100644 index 0000000..4d38bd3 Binary files /dev/null and b/docs/intersphinx/networkx-objects.inv differ diff --git a/docs/intersphinx/numpy-objects.inv b/docs/intersphinx/numpy-objects.inv new file mode 100644 index 0000000..e5909cf Binary files /dev/null and b/docs/intersphinx/numpy-objects.inv differ diff --git a/docs/intersphinx/pandas-objects.inv b/docs/intersphinx/pandas-objects.inv new file mode 100644 index 0000000..d915a1b Binary files /dev/null and b/docs/intersphinx/pandas-objects.inv differ diff --git a/docs/intersphinx/python-objects.inv b/docs/intersphinx/python-objects.inv new file mode 100644 index 0000000..45b7141 Binary files /dev/null and b/docs/intersphinx/python-objects.inv differ diff --git a/docs/intersphinx/scipy-objects.inv b/docs/intersphinx/scipy-objects.inv new file mode 100644 index 0000000..2f8d818 Binary files /dev/null and b/docs/intersphinx/scipy-objects.inv differ diff --git a/docs/intersphinx/sympy-objects.inv b/docs/intersphinx/sympy-objects.inv new file mode 100644 index 0000000..e77acdc Binary files /dev/null and b/docs/intersphinx/sympy-objects.inv differ diff --git a/docs/intersphinx/typing-validation-objects.inv b/docs/intersphinx/typing-validation-objects.inv new file mode 100644 index 0000000..76d7404 Binary files /dev/null and b/docs/intersphinx/typing-validation-objects.inv differ diff --git a/docs/make-api.py b/docs/make-api.py index dacb992..268372f 100644 --- a/docs/make-api.py +++ b/docs/make-api.py @@ -24,6 +24,18 @@ def _list_package_contents(pkg_name: str) -> List[str]: modules.append(submod_fullname) return modules +SPECIAL_CLASS_MEMBERS_REL = ("__eq__", "__gt__", "__ge__", "__lt__", "__le__", "__ne__", ) +SPECIAL_CLASS_MEMBERS_UNOP = ("__abs__", "__not__", "__inv__", "__invert__", "__neg__", "__pos__", ) +SPECIAL_CLASS_MEMBERS_BINOP = ("__add__", "__and__", "__concat__", "__floordiv__", "__lshift__", "__mod__", "__mul__", + "__matmul__", "__or__", "__pow__", "__rshift__", "__sub__", "__truediv__", "__xor__", ) +SPECIAL_CLASS_MEMBERS_BINOP_I = tuple(f"__i{name[2:]}" for name in SPECIAL_CLASS_MEMBERS_BINOP) +SPECIAL_CLASS_MEMBERS_BINOP_R = tuple(f"__r{name[2:]}" for name in SPECIAL_CLASS_MEMBERS_BINOP) +SPECIAL_CLASS_MEMBERS_CAST = ("__bool__", "__int__", "__float__", "__complex__", "__bytes__", "__str__") +SPECIAL_CLASS_MEMBERS_OTHER = ("__init__", "__new__", "__call__", "__repr__", "__index__", "__contains__", + "__delitem__", "__getitem__", "__setitem__", "__getattr__", "__setattr__", "__delattr__", "__set_name__", "__set__", "__get__") +SPECIAL_CLASS_MEMBERS = (SPECIAL_CLASS_MEMBERS_REL+SPECIAL_CLASS_MEMBERS_UNOP+SPECIAL_CLASS_MEMBERS_BINOP+SPECIAL_CLASS_MEMBERS_BINOP_I + +SPECIAL_CLASS_MEMBERS_BINOP_R+SPECIAL_CLASS_MEMBERS_CAST+SPECIAL_CLASS_MEMBERS_OTHER) + def make_apidocs() -> None: """ A script to generate .rst files for API documentation. @@ -33,11 +45,14 @@ def make_apidocs() -> None: "pkg_name": str, "apidocs_folder": str, "pkg_path": str, - "toc_filename": Optional[str], + "toc_filename": str, + "type_alias_dict_filename": Optional[str], "include_members": Dict[str, List[str]], + "type_aliases": Dict[str, List[str]], "exclude_members": Dict[str, List[str]], "exclude_modules": List[str], "member_fullnames": Dict[str, Dict[str, str]], + "special_class_members": Dict[str, List[str]], } Set "toc_filename" to null to avoid generating a table of contents file. @@ -53,23 +68,33 @@ def make_apidocs() -> None: apidocs_folder = config.get("apidocs_folder", None) validate(apidocs_folder, str) toc_filename = config.get("toc_filename", None) - validate(toc_filename, Optional[str]) - include_members = config.get("include_members", None) + validate(toc_filename, str) + type_alias_dict_filename = config.get("type_alias_dict_filename", None) + validate(type_alias_dict_filename, Optional[str]) + include_members = config.get("include_members", {}) validate(include_members, Dict[str, List[str]]) - exclude_members = config.get("exclude_members", None) + type_aliases = config.get("type_aliases", {}) + validate(type_aliases, Dict[str, List[str]]) + exclude_members = config.get("exclude_members", {}) validate(exclude_members, Dict[str, List[str]]) - include_modules = config.get("include_modules", None) + include_modules = config.get("include_modules", []) validate(include_modules, List[str]) - exclude_modules = config.get("exclude_modules", None) + exclude_modules = config.get("exclude_modules", []) validate(exclude_modules, List[str]) - member_fullnames = config.get("member_fullnames", None) + member_fullnames = config.get("member_fullnames", {}) validate(member_fullnames, Dict[str, Dict[str, str]]) + special_class_members = config.get("special_class_members", {}) + validate(special_class_members, Dict[str, List[str]]) except FileNotFoundError: print(err_msg) sys.exit(1) except TypeError: print(err_msg) sys.exit(1) + for mod_name, type_alias_members in type_aliases.items(): + if mod_name not in include_members: + include_members[mod_name] = [] + include_members[mod_name].extend(type_alias_members) cwd = os.getcwd() os.chdir(pkg_path) @@ -90,6 +115,22 @@ def make_apidocs() -> None: os.remove(apidoc_file) print() + type_alias_fullnames: dict[str, str] = {} + + print("Pre-processing type aliases:") + for mod_name, mod_type_aliases in type_aliases.items(): + if mod_name in exclude_modules: + continue + for member_name in mod_type_aliases: + member_fullname = f"{mod_name}.{member_name}" + if member_name in type_alias_fullnames: + print(f" WARNING! Skipping type alias {member_name} -> {member_fullname}") + print(f" Existing type alias {member_name} -> {type_alias_fullnames[member_name]}") + else: + type_alias_fullnames[member_name] = member_fullname + print(f" {member_name} -> {member_fullname}") + print() + for mod_name, mod in modules_dict.items(): if mod_name in exclude_modules: continue @@ -104,8 +145,13 @@ def make_apidocs() -> None: ] mod__all__ = getattr(mod, "__all__", []) reexported_members: List[Tuple[str, str]] = [] - for member_name in sorted(name for name in dir(mod) if not name.startswith("_")): - if mod_name in exclude_members and member_name in exclude_members[mod_name]: + for member_name in sorted(name for name in dir(mod)): + to_include = mod_name in include_members and member_name in include_members[mod_name] + to_exclude = mod_name in exclude_members and member_name in exclude_members[mod_name] + if to_exclude: + continue + member = getattr(mod, member_name) + if member_name.startswith("_") and not to_include: continue member = getattr(mod, member_name) member_module = inspect.getmodule(member) @@ -113,7 +159,9 @@ def make_apidocs() -> None: imported_member = member_module is not None and member_module != mod if mod_name in include_members and member_name in include_members[mod_name]: imported_member = False - if mod_name in member_fullnames and member_name in member_fullnames[mod_name]: + if member_name in type_alias_fullnames: + member_fullname = type_alias_fullnames[member_name] + elif mod_name in member_fullnames and member_name in member_fullnames[mod_name]: member_fullname = member_fullnames[mod_name][member_name] elif imported_member: if inspect.ismodule(member): @@ -138,9 +186,28 @@ def make_apidocs() -> None: f".. auto{member_kind}:: {member_fullname}", ] if member_kind == "class": + member_lines.append(" :show-inheritance:") member_lines.append(" :members:") + _special_class_submembers: list[str] = [] + if member_fullname in special_class_members and special_class_members[member_fullname]: + _special_class_submembers.extend(special_class_members[member_fullname]) + for submember_name in SPECIAL_CLASS_MEMBERS: + if not hasattr(member, submember_name): + continue + submember = getattr(member, submember_name) + if not hasattr(submember, "__doc__") or submember.__doc__ is None: + continue + if not ":meta public:" in submember.__doc__: + continue + if submember_name not in _special_class_submembers: + _special_class_submembers.append(submember_name) + if _special_class_submembers: + member_lines.append(f" :special-members: {', '.join(_special_class_submembers)}") member_lines.append("") - print(f" {member_kind} {member_name}") + if member_name in type_alias_fullnames: + print(f" {member_kind} {member_name} -> {type_alias_fullnames[member_name]} (type alias)") + else: + print(f" {member_kind} {member_name}") lines.extend(member_lines) elif member_name in mod__all__: reexported_members.append((member_fullname, member_kind)) @@ -177,6 +244,8 @@ def make_apidocs() -> None: ] print(f"Writing TOC for API docfiles at {toc_filename}") for mod_name in modules_dict: + if mod_name in exclude_modules: + continue line = f" {apidocs_folder}/{mod_name}" toctable_lines.append(line) print(line) @@ -186,5 +255,13 @@ def make_apidocs() -> None: with open(toc_filename, "w") as f: f.write("\n".join(toctable_lines)) + if type_alias_dict_filename is not None: + print(f"Writing type alias dictionary: {type_alias_dict_filename}") + for name, fullname in type_alias_fullnames.items(): + print(f" {name} -> {fullname}") + print() + with open(type_alias_dict_filename, "w") as f: + json.dump(type_alias_fullnames, f, indent=4) + if __name__ == "__main__": make_apidocs() diff --git a/setup.cfg b/setup.cfg index 32d9724..9707673 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,6 +15,8 @@ project_urls = # see https://pypi.org/classifiers/ classifiers = Development Status :: 4 - Beta + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.8 @@ -27,8 +29,8 @@ classifiers = packages = find: python_requires = >=3.7 install_requires = - typing-extensions - typing-validation + typing-extensions>=4.6.0 + typing-validation>=1.1.0 [options.package_data] * = py.typed diff --git a/tox.ini b/tox.ini index c0ca378..d663055 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,16 @@ # content of: tox.ini, put in same dir as setup.py [tox] -envlist = py37, py38, py39, py310 +envlist = py37, py38, py39, py310, py311, py312 isolated_build = True [testenv] deps = - -rrequirements.txt mypy pylint pytest pytest-cov base58 + rich # optional dependency of typing_validation setenv = PYTHONPATH = {toxinidir} commands =