diff --git a/CHANGELOG.md b/CHANGELOG.md index e29ef17..917dc39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Windows (x64) support (#91) +- Type stubs (#198) ### Changed diff --git a/README.md b/README.md index 42a4cbc..72e3d7a 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,12 @@ with Creator("test.zim") as creator: creator.add_item(item2) ``` +#### Type hints + +`libzim` being a binary extension, there is no Python source to provide types information. We provide them as type stub files. When using `pyright`, you would normally receive a warning when importing from `libzim` as there could be discrepencies between actual sources and the (manually crafted) stub files. + +You can disable the warning via `reportMissingModuleSource = "none"`. + ## Building `libzim` package building offers different behaviors via environment variables diff --git a/libzim/__init__.pyi b/libzim/__init__.pyi new file mode 100644 index 0000000..6a18645 --- /dev/null +++ b/libzim/__init__.pyi @@ -0,0 +1,7 @@ +from libzim import ( + 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] +) diff --git a/libzim/libwrapper.cpp b/libzim/libwrapper.cpp index b900b15..a763f94 100644 --- a/libzim/libwrapper.cpp +++ b/libzim/libwrapper.cpp @@ -224,9 +224,13 @@ WriterItemWrapper::getContentProvider() const std::shared_ptr WriterItemWrapper::getIndexData() const { + // Item without method defined (should not happen on proper subclass) if (!obj_has_attribute(m_obj, "get_indexdata")) { return zim::writer::Item::getIndexData(); } + if (method_is_none(m_obj, "get_indexdata")) { + return zim::writer::Item::getIndexData(); + } return callMethodOnObj>(m_obj, "get_indexdata"); } diff --git a/libzim/libzim.pyx b/libzim/libzim.pyx index 7d2900d..c309b13 100644 --- a/libzim/libzim.pyx +++ b/libzim/libzim.pyx @@ -88,6 +88,12 @@ cdef object call_method(object obj, string method): # object to the correct cpp type. # Will be used by cpp side to call python method. cdef public api: + + # this tells whether a method/property is none or not + bool method_is_none(object obj, string method) with gil: + func = getattr(obj, method.decode('UTF-8')) + return func is None + bool obj_has_attribute(object obj, string attribute) with gil: """Check if a object has a given attribute""" return hasattr(obj, attribute.decode('UTF-8')) @@ -537,6 +543,7 @@ class BaseWritingItem: def __init__(self): self._blob = None + get_indexdata = None def get_path(self) -> str: """Full path of item""" @@ -567,7 +574,7 @@ class BaseWritingItem: class Creator(_Creator): __module__ = writer_module_name - def config_compression(self, compression: Compression): + def config_compression(self, compression: Union[Compression, str]): if not isinstance(compression, Compression): compression = getattr(Compression, compression.lower()) return super().config_compression(compression) diff --git a/libzim/reader.pyi b/libzim/reader.pyi new file mode 100644 index 0000000..46e6609 --- /dev/null +++ b/libzim/reader.pyi @@ -0,0 +1,79 @@ +from __future__ import annotations + +import pathlib +from uuid import UUID + +class Item: + @property + def title(self) -> str: ... + @property + def path(self) -> str: ... + @property + def content(self) -> memoryview: ... + @property + def mimetype(self) -> str: ... + @property + def _index(self) -> int: ... + @property + def size(self) -> int: ... + def __repr__(self) -> str: ... + +class Entry: + @property + def title(self) -> str: ... + @property + def path(self) -> str: ... + @property + def _index(self) -> int: ... + @property + def is_redirect(self) -> bool: ... + def get_redirect_entry(self) -> Entry: ... + def get_item(self) -> Item: ... + def __repr__(self) -> str: ... + +class Archive: + def __init__(self, filename: pathlib.Path) -> None: ... + @property + def filename(self) -> pathlib.Path: ... + @property + def filesize(self) -> int: ... + def has_entry_by_path(self, path: str) -> bool: ... + def get_entry_by_path(self, path: str) -> Entry: ... + def has_entry_by_title(self, title: str) -> bool: ... + def get_entry_by_title(self, title: str) -> Entry: ... + @property + def metadata_keys(self) -> list[str]: ... + def get_metadata_item(self, name: str) -> Item: ... + def get_metadata(self, name: str) -> bytes: ... + def _get_entry_by_id(self, entry_id: int) -> Entry: ... + @property + def has_main_entry(self) -> bool: ... + @property + def main_entry(self) -> Entry: ... + @property + def uuid(self) -> UUID: ... + @property + def has_new_namespace_scheme(self) -> bool: ... + @property + def is_multipart(self) -> bool: ... + @property + def has_fulltext_index(self) -> bool: ... + @property + def has_title_index(self) -> bool: ... + @property + def has_checksum(self) -> str: ... + @property + def checksum(self) -> str: ... + def check(self) -> bool: ... + @property + def entry_count(self) -> int: ... + @property + def all_entry_count(self) -> int: ... + @property + def article_count(self) -> int: ... + @property + def media_count(self) -> int: ... + def get_illustration_sizes(self) -> set[int]: ... + def has_illustration(self, size: int | None = None) -> bool: ... + def get_illustration_item(self, size: int | None = None) -> Item: ... + def __repr__(self) -> str: ... diff --git a/libzim/search.pyi b/libzim/search.pyi new file mode 100644 index 0000000..b0d7136 --- /dev/null +++ b/libzim/search.pyi @@ -0,0 +1,20 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Self + +from libzim.reader import Archive + +class Query: + def set_query(self, query: str) -> Self: ... + +class SearchResultSet: + def __iter__(self) -> Iterator[str]: ... + +class Search: + def getEstimatedMatches(self) -> int: ... # noqa: N802 + def getResults(self, start: int, count: int) -> SearchResultSet: ... # noqa: N802 + +class Searcher: + def __init__(self, archive: Archive) -> None: ... + def search(self, query: Query) -> Search: ... diff --git a/libzim/suggestion.pyi b/libzim/suggestion.pyi new file mode 100644 index 0000000..ce537ea --- /dev/null +++ b/libzim/suggestion.pyi @@ -0,0 +1,18 @@ +from __future__ import annotations + +from collections.abc import Iterator + +from libzim.reader import Archive + +class SuggestionResultSet: + def __iter__(self) -> Iterator[str]: ... + +class SuggestionSearch: + def getEstimatedMatches(self) -> int: ... # noqa: N802 + def getResults( # noqa: N802 + self, start: int, count: int + ) -> SuggestionResultSet: ... + +class SuggestionSearcher: + def __init__(self, archive: Archive) -> None: ... + def suggest(self, query: str) -> SuggestionSearch: ... diff --git a/libzim/version.pyi b/libzim/version.pyi new file mode 100644 index 0000000..998212c --- /dev/null +++ b/libzim/version.pyi @@ -0,0 +1,9 @@ +from __future__ import annotations + +import sys +from collections import OrderedDict +from typing import TextIO + +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 new file mode 100644 index 0000000..5288e7a --- /dev/null +++ b/libzim/writer.pyi @@ -0,0 +1,97 @@ +from __future__ import annotations + +import datetime +import enum +import pathlib +import types +from collections.abc import Callable, Generator +from typing import Self + +class Compression(enum.Enum): + none: Self + zstd: Self + +class Hint(enum.Enum): + COMPRESS: Self + FRONT_ARTICLE: Self + +class Blob: + def __init__(self, content: str | bytes) -> None: ... + def size(self) -> int: ... + ref_content: bytes + +class ContentProvider: + def feed(self) -> Blob: ... + def get_size(self) -> int: ... + def gen_blob(self) -> Generator[Blob, None, None]: ... + + generator: Generator[Blob, None, None] + +class StringProvider(ContentProvider): + def __init__(self, content: str | bytes) -> None: ... + +class FileProvider(ContentProvider): + def __init__(self, filepath: pathlib.Path | str) -> None: ... + +class Item: + def get_path(self) -> str: ... + def get_title(self) -> str: ... + def get_mimetype(self) -> str: ... + def get_contentprovider(self) -> ContentProvider: ... + def get_hints(self) -> dict[Hint, int]: ... + def __repr__(self) -> str: ... + + get_indexdata: Callable[[], IndexData | None] | None + _blob: Blob + +class IndexData: + def has_indexdata(self) -> bool: ... + def get_title(self) -> str: ... + def get_content(self) -> str: ... + def get_keywords(self) -> str: ... + def get_wordcount(self) -> int: ... + def get_geoposition(self) -> tuple[float, float] | None: ... + +class Creator: + def __init__(self, filename: pathlib.Path) -> None: ... + def config_verbose(self, verbose: bool) -> Self: ... + def config_compression(self, compression: Compression | str) -> Self: ... + def config_clustersize(self, size: int) -> Self: ... + def config_indexing(self, indexing: bool, language: str) -> Self: ... + def config_nbworkers(self, nbWorkers: int) -> Self: ... # noqa: N803 + def set_mainpath(self, mainPath: str) -> Self: ... # noqa: N803 + def add_illustration(self, size: int, content: bytes) -> None: ... + def add_item(self, writer_item: Item) -> None: ... + def add_metadata( + self, + name: str, + content: str | bytes | datetime.date | datetime.datetime, + mimetype: str = "text/plain;charset=UTF-8", + ) -> None: ... + def add_redirection( + self, + path: str, + title: str, + targetPath: str, # noqa: N803 + hints: dict[Hint, int], + ) -> None: ... + def add_alias( + self, + path: str, + title: str, + targetPath: str, # noqa: N803 + hints: dict[Hint, int], + ) -> None: ... + def __enter__(self) -> Self: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: ... + @property + def filename(self) -> pathlib.Path: ... + def __repr__(self) -> str: ... + + _filename: pathlib.Path + _started: bool diff --git a/py.typed b/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 61a5034..ee9435b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,8 @@ packages = [ "libzim" ] [tool.setuptools.package-data] libzim = [ + "py.typed", + "*.pyi", "libzim.9.dylib", "libzim.so.9", "zim-9.dll", @@ -313,7 +315,7 @@ exclude_lines = [ [tool.pyright] include = ["libzim", "tests", "tasks.py"] -exclude = [".env/**", ".venv/**", "libzim/libzim.pyi"] +exclude = [".env/**", ".venv/**"] pythonVersion = "3.12" typeCheckingMode="basic" disableBytesTypePromotions = true diff --git a/tests/test_libzim_creator.py b/tests/test_libzim_creator.py index f29c4c4..5e89b3a 100644 --- a/tests/test_libzim_creator.py +++ b/tests/test_libzim_creator.py @@ -48,7 +48,7 @@ def get_mimetype(self) -> str: def get_contentprovider(self) -> libzim.writer.ContentProvider: if getattr(self, "filepath", None): - return FileProvider(filepath=self.filepath) + return FileProvider(filepath=getattr(self, "filepath", "")) return StringProvider(content=getattr(self, "content", "")) def get_hints(self) -> dict[Hint, int]: @@ -296,7 +296,7 @@ def test_creator_nocontext(fpath, lipsum_item): creator.__enter__() creator.add_item(lipsum_item) try: - creator.add_redirection("A", HOME_PATH) + creator.add_redirection("A", HOME_PATH) # pyright: ignore [reportCallIssue] except Exception: exc_type, exc_val, exc_tb = sys.exc_info() with pytest.raises(TypeError): @@ -368,11 +368,11 @@ def test_creator_additem(fpath, lipsum_item): with Creator(fpath) as c: c.add_item(lipsum_item) with pytest.raises(TypeError, match="must not be None"): - c.add_item(None) + c.add_item(None) # pyright: ignore [reportCallIssue, reportArgumentType] with pytest.raises(RuntimeError): - c.add_item("hello") + c.add_item("hello") # pyright: ignore [reportCallIssue, reportArgumentType] with pytest.raises(TypeError, match="takes exactly 1 positional argument"): - c.add_item(mimetype="text/html") + c.add_item(mimetype="text/html") # pyright: ignore [reportCallIssue] def test_creator_metadata(fpath, lipsum_item): @@ -403,7 +403,7 @@ def test_creator_metadata(fpath, lipsum_item): c = Creator(fpath) with pytest.raises(RuntimeError, match="not started"): key = next(iter(metadata.keys())) - c.add_metadata(key, metadata.get(key)) + c.add_metadata(key, metadata[key]) del c with Creator(fpath) as c: @@ -589,7 +589,7 @@ def get_hints(self): with Creator(fpath) as c: with pytest.raises(RuntimeError, match="ContentProvider is None"): - c.add_item(AnItem()) + c.add_item(AnItem()) # pyright: ignore [reportArgumentType] def test_missing_contentprovider(fpath): @@ -608,7 +608,7 @@ def get_hints(self): with Creator(fpath) as c: with pytest.raises(RuntimeError, match="has no attribute"): - c.add_item(AnItem()) + c.add_item(AnItem()) # pyright: ignore [reportArgumentType] def test_missing_hints(fpath): @@ -624,7 +624,7 @@ def get_mimetype(self): with Creator(fpath) as c: with pytest.raises(RuntimeError, match="has no attribute 'get_hints'"): - c.add_item(AnItem()) + c.add_item(AnItem()) # pyright: ignore [reportArgumentType] with pytest.raises(RuntimeError, match="must be implemented"): c.add_item(libzim.writer.Item()) @@ -636,7 +636,9 @@ def test_nondict_hints(fpath): c.add_item(StaticItem(path="1", title="", hints=1)) with pytest.raises(TypeError, match="hints"): - c.add_redirection("a", "", "b", hints=1) + c.add_redirection( + "a", "", "b", hints=1 # pyright: ignore [reportArgumentType] + ) def test_hints_values(fpath): @@ -665,11 +667,22 @@ def test_hints_values(fpath): # non-existent Hint with pytest.raises(AttributeError, match="YOLO"): - c.add_item(StaticItem(path="0", title="", hints={Hint.YOLO: True})) + c.add_item( + StaticItem( + path="0", + title="", + hints={ + Hint.YOLO: True # pyright: ignore [reportAttributeAccessIssue] + }, + ) + ) with pytest.raises(AttributeError, match="YOLO"): - c.add_redirection( - path="5", title="", target_path="0", hints={Hint.YOLO: True} + c.add_redirection( # pyright: ignore [reportCallIssue] + path="5", + title="", + targetPath="0", + hints={Hint.YOLO: True}, # pyright: ignore [reportAttributeAccessIssue] ) @@ -758,7 +771,7 @@ def feed(self): self.called = True return Blob("1") - class AnItem: + class AnItem(Item): def get_path(self): return "-" @@ -791,7 +804,7 @@ def get_size(self): def feed(self): return Blob("") - class AnItem: + class AnItem(Item): def get_path(self): return "" @@ -816,7 +829,7 @@ def test_creator_badfilename(tmpdir): if platform.system() != "Windows" and os.getuid() != 0: # lack of perm with pytest.raises(IOError): - Creator("/root/test.zim") + Creator(pathlib.Path("/root/test.zim")) # forward slash points to non-existing folder with pytest.raises(IOError):