From 341e06dcd0251c307c025ac43ea893c1d25cbc90 Mon Sep 17 00:00:00 2001 From: benoit74 Date: Sun, 25 Oct 2026 21:15:35 +0000 Subject: [PATCH 1/3] Export SuggestionSearch and SuggestionResultSet, fix sys.modules pollution SuggestionSearch and SuggestionResultSet were declared in the type stubs but not exported by libzim.suggestion at runtime, so importing them failed. create_module reused its name argument as loop variable, so each submodule was also registered in sys.modules under its last member name (e.g. sys.modules['Searcher']). --- libzim/__init__.pyi | 14 ++++++++------ libzim/libzim.pyx | 12 +++++++----- tests/test_libzim_modules.py | 21 +++++++++++++++++++++ tests/test_libzim_reader.py | 4 ++++ 4 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 tests/test_libzim_modules.py diff --git a/libzim/__init__.pyi b/libzim/__init__.pyi index 3ffc7b29..812fae1b 100644 --- a/libzim/__init__.pyi +++ b/libzim/__init__.pyi @@ -1,8 +1,10 @@ from libzim import ( - illustration, # noqa: F401 # pyright: ignore[reportUnusedImport] - reader, # noqa: F401 # pyright: ignore[reportUnusedImport] - search, # noqa: F401 # pyright: ignore[reportUnusedImport] - suggestion, # noqa: F401 # pyright: ignore[reportUnusedImport] - version, # noqa: F401 # pyright: ignore[reportUnusedImport] - writer, # noqa: F401 # pyright: ignore[reportUnusedImport] + illustration, # pyright: ignore[reportUnusedImport] + reader, # pyright: ignore[reportUnusedImport] + search, # pyright: ignore[reportUnusedImport] + suggestion, # pyright: ignore[reportUnusedImport] + version, # pyright: ignore[reportUnusedImport] + writer, # pyright: ignore[reportUnusedImport] ) + +__all__ = ["illustration", "reader", "search", "suggestion", "version", "writer"] diff --git a/libzim/libzim.pyx b/libzim/libzim.pyx index 10c7d98c..56947f0f 100644 --- a/libzim/libzim.pyx +++ b/libzim/libzim.pyx @@ -69,12 +69,12 @@ def create_module(name, doc, members): _all = [] for obj in members: if isinstance(obj, tuple): - name = obj[0] + member_name = obj[0] obj = obj[1] else: - name = obj.__name__ - setattr(module, name, obj) - _all.append(name) + member_name = obj.__name__ + setattr(module, member_name, obj) + _all.append(member_name) module.__all__ = _all sys.modules[name] = module return module @@ -1978,7 +1978,9 @@ for path in suggestion.get_results(10, 10) # get result from 10 to 20 (10 result print(path, archive.get_entry_by_path(path).title) ```""" suggestion_public_objects = [ - SuggestionSearcher + SuggestionResultSet, + SuggestionSearch, + SuggestionSearcher, ] suggestion = create_module(suggestion_module_name, suggestion_module_doc, suggestion_public_objects) diff --git a/tests/test_libzim_modules.py b/tests/test_libzim_modules.py new file mode 100644 index 00000000..8b4fb8e5 --- /dev/null +++ b/tests/test_libzim_modules.py @@ -0,0 +1,21 @@ +import importlib +import sys + +import pytest + +import libzim + + +@pytest.mark.parametrize("module_name", libzim.__all__) +def test_submodule_exports(module_name: str): + module = importlib.import_module(f"libzim.{module_name}") + assert sys.modules[f"libzim.{module_name}"] is module + for name in module.__all__: + assert getattr(module, name) is not None + + +def test_submodules_not_registered_under_member_names(): + for module_name in libzim.__all__: + module = importlib.import_module(f"libzim.{module_name}") + for name in module.__all__: + assert sys.modules.get(name) is not module diff --git a/tests/test_libzim_reader.py b/tests/test_libzim_reader.py index e5fa7366..200fb7af 100644 --- a/tests/test_libzim_reader.py +++ b/tests/test_libzim_reader.py @@ -16,6 +16,8 @@ ) from libzim.search import Query, Searcher # pyright: ignore [reportMissingModuleSource] from libzim.suggestion import ( # pyright: ignore [reportMissingModuleSource] + SuggestionResultSet, + SuggestionSearch, SuggestionSearcher, ) @@ -492,6 +494,8 @@ def test_reader_suggest_search( if suggestion_string is not None: suggestion_searcher = SuggestionSearcher(zim) suggestion = suggestion_searcher.suggest(suggestion_string) + assert isinstance(suggestion, SuggestionSearch) + assert isinstance(suggestion.get_results(0, 1), SuggestionResultSet) assert suggestion.get_estimated_matches() == suggestion_count assert list(suggestion.get_results(0, suggestion_count)) == suggestion_result From 4b651cd00a90c0ab06f06872bca0236dd1c235d5 Mon Sep 17 00:00:00 2001 From: benoit74 Date: Sun, 25 Oct 2026 21:15:35 +0000 Subject: [PATCH 2/3] Fix drifts between type stubs and compiled module - set_cluster_cache_max_size parameter is size_in_bytes, not nb_clusters - IllustrationInfo accepts extra_attributes=None - declare enum members with values so type checkers see them as members - declare __all__ and mark Cython extension classes as disjoint bases - fix .pyx annotations: implicit Optional, and config methods returning Self --- libzim/illustration.pyi | 23 +++++++++++++++++------ libzim/libzim.pyx | 30 +++++++++++++++--------------- libzim/reader.pyi | 16 +++++++++++++++- libzim/search.pyi | 9 +++++++++ libzim/suggestion.pyi | 7 +++++++ libzim/version.pyi | 2 ++ libzim/writer.pyi | 23 +++++++++++++++++++---- tests/test_illustration_info.py | 5 +++++ tests/test_libzim_reader.py | 2 +- 9 files changed, 90 insertions(+), 27 deletions(-) diff --git a/libzim/illustration.pyi b/libzim/illustration.pyi index 8782bc1a..87d86fd7 100644 --- a/libzim/illustration.pyi +++ b/libzim/illustration.pyi @@ -1,15 +1,26 @@ from __future__ import annotations from collections.abc import Mapping -from typing import NamedTuple +from typing import NamedTuple, Self -class IllustrationInfo(NamedTuple): +__all__ = ["IllustrationInfo"] + +class _IllustrationInfoTuple(NamedTuple): + width: int + height: int + scale: float + extra_attributes: Mapping[str, str] + +class IllustrationInfo(_IllustrationInfoTuple): """Information about an illustration in a ZIM archive.""" - width: int = 0 - height: int = 0 - scale: float = 1.0 - extra_attributes: Mapping[str, str] = {} + def __new__( + cls, + width: int = 0, + height: int = 0, + scale: float = 1.0, + extra_attributes: Mapping[str, str] | None = None, + ) -> Self: ... @staticmethod def from_metadata_item_name(name: str) -> IllustrationInfo: ... def as_metadata_item_name(self) -> str: ... diff --git a/libzim/libzim.pyx b/libzim/libzim.pyx index 56947f0f..75720761 100644 --- a/libzim/libzim.pyx +++ b/libzim/libzim.pyx @@ -45,7 +45,7 @@ from collections import OrderedDict from collections import namedtuple as _namedtuple from collections.abc import Generator, Iterator, Mapping from types import MappingProxyType, ModuleType -from typing import TextIO +from typing import Self, TextIO from uuid import UUID from cpython.buffer cimport PyBUF_WRITABLE @@ -392,7 +392,7 @@ cdef class _Creator: def __init__(self, filename: pathlib.Path): pass - def config_verbose(self, bool verbose: bool) -> _Creator: + def config_verbose(self, bool verbose: bool) -> Self: """Set creator verbosity inside libzim (default: off). Args: @@ -406,7 +406,7 @@ cdef class _Creator: self.c_creator.configVerbose(verbose) return self - def config_compression(self, compression: Compression) -> _Creator: + def config_compression(self, compression: Compression) -> Self: """Set compression algorithm to use. Check libzim for default setting. (Fall 2021 default: zstd). @@ -422,7 +422,7 @@ cdef class _Creator: self.c_creator.configCompression(zim.comp_from_int(compression.value)) return self - def config_clustersize(self, int size: pyint) -> _Creator: + def config_clustersize(self, int size: pyint) -> Self: """Set size of created clusters. Check libzim for default setting. (Fall 2021 default: 2Mib). @@ -441,7 +441,7 @@ cdef class _Creator: self.c_creator.configClusterSize(size) return self - def config_indexing(self, bool indexing: bool, str language: str) -> _Creator: + def config_indexing(self, bool indexing: bool, str language: str) -> Self: """Configures the full-text indexing feature. Args: @@ -456,7 +456,7 @@ cdef class _Creator: self.c_creator.configIndexing(indexing, language.encode('UTF-8')) return self - def config_nbworkers(self, int nb_workers: pyint) -> _Creator: + def config_nbworkers(self, int nb_workers: pyint) -> Self: """Configures the number of threads to use for internal workers (default: 4). Args: @@ -470,7 +470,7 @@ cdef class _Creator: self.c_creator.configNbWorkers(nb_workers) return self - def set_mainpath(self, str main_path: str) -> _Creator: + def set_mainpath(self, str main_path: str) -> Self: """Set path of the main entry. Args: @@ -486,7 +486,7 @@ cdef class _Creator: self, content: bytes, width: pyint, - height: pyint = None, + height: pyint | None = None, scale: float = 1.0, ): """Add a PNG illustration to Archive. @@ -540,7 +540,7 @@ cdef class _Creator: cdef zim.IllustrationInfo c_ii = _illustration_info_to_c(info) self.c_creator.addIllustration(c_ii, _content) -# def set_uuid(self, uuid) -> _Creator: +# def set_uuid(self, uuid) -> Self: # self.c_creator.setUuid(uuid) def add_item(self, writer_item not None: BaseWritingItem): @@ -1477,7 +1477,7 @@ cdef class Archive: ) return self.c_archive.getIllustrationSizes() - def has_illustration(self, size: pyint = None) -> pybool: + def has_illustration(self, size: pyint | None = None) -> pybool: """Whether Archive has an illustration metadata for this size. Returns: @@ -1489,9 +1489,9 @@ cdef class Archive: def get_illustration_item( self, - width: pyint = None, - height: pyint = None, - scale: float = None, + width: pyint | None = None, + height: pyint | None = None, + scale: float | None = None, ) -> Item: """Get the illustration Metadata item of the archive. @@ -1556,8 +1556,8 @@ cdef class Archive: except RuntimeError as e: raise KeyError(str(e)) - def get_illustrations_info(self, width: pyint = None, height: pyint = None, - min_scale: float = None) -> list[IllustrationInfo]: + def get_illustrations_info(self, width: pyint | None = None, height: pyint | None = None, + min_scale: float | None = None) -> list[IllustrationInfo]: """Get information about available illustrations. Args: diff --git a/libzim/reader.pyi b/libzim/reader.pyi index 42761401..54ef4079 100644 --- a/libzim/reader.pyi +++ b/libzim/reader.pyi @@ -3,8 +3,20 @@ from __future__ import annotations import pathlib from uuid import UUID +from typing_extensions import disjoint_base + from libzim.illustration import IllustrationInfo +__all__ = [ + "Archive", + "Entry", + "Item", + "get_cluster_cache_current_size", + "get_cluster_cache_max_size", + "set_cluster_cache_max_size", +] + +@disjoint_base class Item: @property def title(self) -> str: ... @@ -20,6 +32,7 @@ class Item: def size(self) -> int: ... def __repr__(self) -> str: ... +@disjoint_base class Entry: @property def title(self) -> str: ... @@ -33,6 +46,7 @@ class Entry: def get_item(self) -> Item: ... def __repr__(self) -> str: ... +@disjoint_base class Archive: def __init__(self, filename: pathlib.Path) -> None: ... @property @@ -100,5 +114,5 @@ class Archive: def __repr__(self) -> str: ... def get_cluster_cache_max_size() -> int: ... -def set_cluster_cache_max_size(nb_clusters: int): ... +def set_cluster_cache_max_size(size_in_bytes: int): ... def get_cluster_cache_current_size() -> int: ... diff --git a/libzim/search.pyi b/libzim/search.pyi index 25e0a639..d97f40df 100644 --- a/libzim/search.pyi +++ b/libzim/search.pyi @@ -3,11 +3,17 @@ from __future__ import annotations from collections.abc import Iterator from typing import Self +from typing_extensions import disjoint_base + from libzim.reader import Archive +__all__ = ["Query", "Search", "SearchResult", "SearchResultSet", "Searcher"] + +@disjoint_base class Query: def set_query(self, query: str) -> Self: ... +@disjoint_base class SearchResult: archive: Archive path: str @@ -17,13 +23,16 @@ class SearchResult: self, archive: Archive, path: str, title: str, snippet: str ) -> None: ... +@disjoint_base class SearchResultSet: def __iter__(self) -> Iterator[SearchResult]: ... +@disjoint_base class Search: def get_estimated_matches(self) -> int: ... def get_results(self, start: int, count: int) -> SearchResultSet: ... +@disjoint_base class Searcher: def __init__( self, diff --git a/libzim/suggestion.pyi b/libzim/suggestion.pyi index f09b8161..bbcf11d7 100644 --- a/libzim/suggestion.pyi +++ b/libzim/suggestion.pyi @@ -2,15 +2,22 @@ from __future__ import annotations from collections.abc import Iterator +from typing_extensions import disjoint_base + from libzim.reader import Archive +__all__ = ["SuggestionResultSet", "SuggestionSearch", "SuggestionSearcher"] + +@disjoint_base class SuggestionResultSet: def __iter__(self) -> Iterator[str]: ... +@disjoint_base class SuggestionSearch: def get_estimated_matches(self) -> int: ... def get_results(self, start: int, count: int) -> SuggestionResultSet: ... +@disjoint_base class SuggestionSearcher: def __init__(self, archive: Archive) -> None: ... def suggest(self, query: str) -> SuggestionSearch: ... diff --git a/libzim/version.pyi b/libzim/version.pyi index 998212c9..feceba47 100644 --- a/libzim/version.pyi +++ b/libzim/version.pyi @@ -4,6 +4,8 @@ import sys from collections import OrderedDict from typing import TextIO +__all__ = ["get_libzim_version", "get_versions", "print_versions"] + def print_versions(out: TextIO = sys.stdout) -> None: ... def get_versions() -> OrderedDict[str, str]: ... def get_libzim_version() -> str: ... diff --git a/libzim/writer.pyi b/libzim/writer.pyi index 4bff0f2d..3fc055db 100644 --- a/libzim/writer.pyi +++ b/libzim/writer.pyi @@ -6,16 +6,31 @@ import types from collections.abc import Callable, Generator from typing import Self +from typing_extensions import disjoint_base + from libzim.illustration import IllustrationInfo +__all__ = [ + "Blob", + "Compression", + "ContentProvider", + "Creator", + "FileProvider", + "Hint", + "IndexData", + "Item", + "StringProvider", +] + class Compression(enum.Enum): - none: Self - zstd: Self + none = ... + zstd = ... class Hint(enum.Enum): - COMPRESS: Self - FRONT_ARTICLE: Self + COMPRESS = ... + FRONT_ARTICLE = ... +@disjoint_base class Blob: def __init__(self, content: str | bytes) -> None: ... def size(self) -> int: ... diff --git a/tests/test_illustration_info.py b/tests/test_illustration_info.py index cfb4a422..3727e15f 100644 --- a/tests/test_illustration_info.py +++ b/tests/test_illustration_info.py @@ -44,6 +44,11 @@ def test_immutable(self): "foo": "bar" } + def test_extra_attributes_none(self): + """Test that extra_attributes=None means no extra attributes.""" + info = IllustrationInfo(48, 48, 1.0, extra_attributes=None) + assert info.extra_attributes == {} + def test_extra_attributes_contruction(self): """Test setting extra attributes in constructor.""" info = IllustrationInfo(48, 48, 1.0, {"foo": "bar", "baz": "qux"}) diff --git a/tests/test_libzim_reader.py b/tests/test_libzim_reader.py index 200fb7af..dd015356 100644 --- a/tests/test_libzim_reader.py +++ b/tests/test_libzim_reader.py @@ -651,7 +651,7 @@ def test_cluster_cache(all_zims, filename): assert get_cluster_cache_current_size() <= new_value # restore default value for next tests - set_cluster_cache_max_size(default_value) + set_cluster_cache_max_size(size_in_bytes=default_value) @skip_if_offline From 22e17c452c3ee4c291d38e8390c2b74f3a325d91 Mon Sep 17 00:00:00 2001 From: benoit74 Date: Sun, 25 Oct 2026 21:15:35 +0000 Subject: [PATCH 3/3] Check type stubs against compiled module in QA - mypy.stubtest checks names, parameters and defaults - check_stub_annotations compares stubs annotations with the ones Cython keeps on compiled functions, which stubtest does not check --- .github/workflows/QA.yaml | 6 + pyproject.toml | 3 +- scripts/check_stub_annotations.py | 185 ++++++++++++++++++++++++++++++ scripts/stubtest-allowlist.txt | 12 ++ tasks.py | 21 +++- 5 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 scripts/check_stub_annotations.py create mode 100644 scripts/stubtest-allowlist.txt diff --git a/.github/workflows/QA.yaml b/.github/workflows/QA.yaml index 3624908a..f0d3afb8 100644 --- a/.github/workflows/QA.yaml +++ b/.github/workflows/QA.yaml @@ -34,3 +34,9 @@ jobs: - name: Check pyright run: inv check-pyright + + - name: Check stubs with stubtest + run: inv check-stubtest + + - name: Check stubs annotations + run: inv check-stub-annotations diff --git a/pyproject.toml b/pyproject.toml index 594af0f5..3ee3ee16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ lint = [ ] check = [ "pyright==1.1.414", + "mypy==2.4.0", "libzim", "libzim[build]", "libzim[test]", @@ -331,7 +332,7 @@ exclude_lines = [ ] [tool.pyright] -include = ["libzim", "tests", "tasks.py"] +include = ["libzim", "tests", "tasks.py", "scripts"] exclude = [".env/**", ".venv/**"] pythonVersion = "3.15" typeCheckingMode="basic" diff --git a/scripts/check_stub_annotations.py b/scripts/check_stub_annotations.py new file mode 100644 index 00000000..355a4401 --- /dev/null +++ b/scripts/check_stub_annotations.py @@ -0,0 +1,185 @@ +"""Check that type stubs annotations match the compiled module ones + +Cython keeps the annotations of the `.pyx` source on the compiled functions +(as strings, in `__annotations__`). This script compares them with the ones +declared in the `libzim/*.pyi` stubs, for every public object of every libzim +submodule, and reports mismatches. + +It complements `mypy.stubtest`, which checks names, parameters and defaults +but not annotation types. + +Annotations missing on either side are not reported: only conflicting ones. +""" + +import ast +import importlib +import inspect +import sys +from collections.abc import Callable, Iterator +from pathlib import Path +from types import ModuleType +from typing import Any + +import libzim + +STUBS_DIR = Path(__file__).resolve().parent.parent / "libzim" + +# runtime names which are aliases of a stub name ; completed at runtime with +# objects exported under another name, like `("Blob", WritingBlob)` +ALIASES = { + "pybool": "bool", # `pybool = type(True)` in libzim.pyx + "pyint": "int", # `pyint = type(1)` in libzim.pyx +} + + +class Normalizer(ast.NodeTransformer): + """Normalize an annotation AST so that equivalent spellings compare equal""" + + def __init__(self, self_type: str | None): + self.self_type = self_type + + def visit_Constant(self, node: ast.Constant) -> ast.AST: + # forward reference: "Archive" -> Archive + if isinstance(node.value, str): + return self.visit(ast.parse(node.value, mode="eval").body) + return node + + def visit_Attribute(self, node: ast.Attribute) -> ast.AST: + # pathlib.Path -> Path ; typing.Self -> Self + return self.visit_Name(ast.Name(id=node.attr, ctx=ast.Load())) + + def visit_Subscript(self, node: ast.Subscript) -> ast.AST: + node = self.generic_visit(node) # pyright: ignore[reportAssignmentType] + # Generator[X, None, None] -> Generator[X] (default type parameters) + if ( + isinstance(node.value, ast.Name) + and node.value.id == "Generator" + and isinstance(node.slice, ast.Tuple) + ): + elts = list(node.slice.elts) + while len(elts) > 1 and ast.unparse(elts[-1]) == "None": + elts.pop() + node.slice = elts[0] if len(elts) == 1 else ast.Tuple(elts, ast.Load()) + return node + + def visit_Name(self, node: ast.Name) -> ast.AST: + name = ALIASES.get(node.id, node.id) + if name == "Self" and self.self_type: + name = self.self_type + return ast.Name(id=name, ctx=ast.Load()) + + +def normalize(annotation: str | ast.expr, self_type: str | None) -> str: + """Normalized textual representation of an annotation""" + if isinstance(annotation, str): + annotation = ast.parse(annotation, mode="eval").body + return ast.unparse(Normalizer(self_type).visit(annotation)) + + +def stub_annotations(func: ast.FunctionDef) -> dict[str, ast.expr]: + """Annotations of a stub function, keyed like `__annotations__`""" + args = func.args + annotations: dict[str, ast.expr] = {} + for arg in [*args.posonlyargs, *args.args, *args.kwonlyargs]: + if arg.annotation is not None: + annotations[arg.arg] = arg.annotation + for arg in (args.vararg, args.kwarg): + if arg is not None and arg.annotation is not None: + annotations[arg.arg] = arg.annotation + if func.returns is not None: + annotations["return"] = func.returns + return annotations + + +def runtime_callable(obj: Any, func: ast.FunctionDef) -> Callable[..., Any] | None: + """The runtime callable matching a stub function definition, if any""" + decorators = { + d.id if isinstance(d, ast.Name) else ast.unparse(d) for d in func.decorator_list + } + if "property" in decorators: + # Cython properties are getset descriptors, without annotations + fget = getattr(obj, "fget", None) + return fget if callable(fget) else None + if any(d.endswith((".setter", ".deleter")) for d in decorators): + return None + return obj if callable(obj) else None + + +def compare( + qualname: str, + func: ast.FunctionDef, + runtime: Callable[..., Any], + self_type: str | None, +) -> Iterator[str]: + """Mismatches between a stub function and its runtime counterpart""" + try: + runtime_annotations: dict[str, Any] = inspect.get_annotations(runtime) + except TypeError: + return + for name, stub_annotation in stub_annotations(func).items(): + runtime_annotation = runtime_annotations.get(name) + if not isinstance(runtime_annotation, str): + continue + stub_value = normalize(stub_annotation, self_type) + runtime_value = normalize(runtime_annotation, self_type) + if stub_value != runtime_value: + where = "return" if name == "return" else f"parameter {name!r}" + yield ( + f"{qualname} {where}: stub has `{stub_value}`, " + f"runtime has `{runtime_value}`" + ) + + +def check_module(module: ModuleType) -> Iterator[str]: + stub_path = STUBS_DIR / f"{module.__name__.rsplit('.', 1)[-1]}.pyi" + stub = ast.parse(stub_path.read_text(), filename=str(stub_path)) + for node in stub.body: + if not isinstance(node, (ast.FunctionDef, ast.ClassDef)): + continue + runtime = getattr(module, node.name, None) + if node.name.startswith("_") or runtime is None: + continue # missing objects are reported by stubtest + qualname = f"{module.__name__}.{node.name}" + if isinstance(node, ast.FunctionDef): + yield from compare(qualname, node, runtime, None) + elif isinstance(node, ast.ClassDef): + for member in node.body: + if not isinstance(member, ast.FunctionDef): + continue + try: + runtime_member = inspect.getattr_static(runtime, member.name) + except AttributeError: + continue # reported by stubtest + if isinstance(runtime_member, (staticmethod, classmethod)): + runtime_member = runtime_member.__func__ + func = runtime_callable(runtime_member, member) + if func is not None: + yield from compare( + f"{qualname}.{member.name}", member, func, node.name + ) + + +def main() -> int: + modules = [ + importlib.import_module(f"libzim.{name}") + for name in libzim.__all__ # pyright: ignore[reportAttributeAccessIssue] + ] + for module in modules: + for name in module.__all__: + runtime_name = getattr(getattr(module, name), "__name__", name) + if runtime_name != name: + ALIASES[runtime_name] = name + + errors: list[str] = [] + for module in modules: + errors.extend(check_module(module)) + for error in errors: + print(f"error: {error}") # noqa: T201 + print( # noqa: T201 + f"Found {len(errors)} annotation mismatch(es) between stubs and runtime" + ) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/stubtest-allowlist.txt b/scripts/stubtest-allowlist.txt new file mode 100644 index 00000000..25d26e8d --- /dev/null +++ b/scripts/stubtest-allowlist.txt @@ -0,0 +1,12 @@ +# mypy.stubtest allowlist: runtime objects intentionally absent from the stubs + +# Cython extension types take their constructor arguments in __cinit__: the +# runtime signature is not introspectable (`(self, /, *args, **kwargs)`) +libzim.reader.Archive.__init__ +libzim.search.Searcher.__init__ +libzim.suggestion.SuggestionSearcher.__init__ +libzim.writer.Blob.__init__ + +# Cython auto-generated pickling support +libzim\..*\.__reduce_cython__ +libzim\..*\.__setstate_cython__ diff --git a/tasks.py b/tasks.py index 361fd162..e0474fb0 100644 --- a/tasks.py +++ b/tasks.py @@ -96,10 +96,29 @@ def check_pyright(ctx: Context, args: str = ""): ctx.run(f"pyright {args}", pty=use_pty) +@task(optional=["args"], help={"args": "stubtest additional arguments"}) +def check_stubtest(ctx: Context, args: str = ""): + """check type stubs against the compiled module with mypy's stubtest""" + ctx.run("python -m mypy --version") + ctx.run( + "python -m mypy.stubtest libzim --allowlist scripts/stubtest-allowlist.txt " + f"{args}", + pty=use_pty, + ) + + +@task +def check_stub_annotations(ctx: Context): + """check type stubs annotations against the compiled module ones""" + ctx.run("python -m scripts.check_stub_annotations", pty=use_pty) + + @task(optional=["args"], help={"args": "check tools (pyright) additional arguments"}) def checkall(ctx: Context, args: str = ""): - """check static types""" + """check static types and type stubs""" check_pyright(ctx, args) + check_stubtest(ctx) + check_stub_annotations(ctx) @task(optional=["args"], help={"args": "black additional arguments"})