From 0e2f8c2e63bbee10413829f15372cfa5364b6aaf Mon Sep 17 00:00:00 2001 From: Taras Ivashchenko Date: Sat, 25 Nov 2023 21:51:47 +0100 Subject: [PATCH] Add more type hints --- Makefile | 5 + config.ini-example | 1 + lib2opds/app.py | 2 +- lib2opds/config.py | 6 + lib2opds/opds.py | 218 ++++++++++++++---------- lib2opds/templates/acquisition-feed.xml | 2 +- poetry.lock | 21 ++- pyproject.toml | 8 + 8 files changed, 165 insertions(+), 98 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8fdf426 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +.PHONY: lint +PYTHON := $(shell poetry env info -p)/bin/python + +lint: + mypy --python-executable="$(PYTHON)" lib2opds diff --git a/config.ini-example b/config.ini-example index 159d269..f5fbb9a 100644 --- a/config.ini-example +++ b/config.ini-example @@ -3,6 +3,7 @@ library_dir = opds_dir = library_base_uri = /library/ opds_base_uri = /opds/ +root_filename = index.xml library_title = My Library cover_width = 500 cover_height = 500 diff --git a/lib2opds/app.py b/lib2opds/app.py index bdd09b9..4ce6b62 100644 --- a/lib2opds/app.py +++ b/lib2opds/app.py @@ -29,7 +29,7 @@ def get_utime_dir(dpath: Path) -> datetime: return updated -def cli(): +def cli() -> None: parser = argparse.ArgumentParser( description="Generate OPDS catalog for local e-book library" ) diff --git a/lib2opds/config.py b/lib2opds/config.py index c0b4e7e..b42cf40 100644 --- a/lib2opds/config.py +++ b/lib2opds/config.py @@ -18,6 +18,11 @@ class Config: clear_opds_dir: bool = True feeds_dir: Path = field(default_factory=Path) publication_freshness_days = 14 + root_filename: str = "index.xml" + feed_by_directory_title: str = "Folders" + feed_new_publications_title: str = "New Books" + feed_all_publications_title: str = "All Books" + feed_by_author_title: str = "Authors" def load_from_file(self, config_path: Path) -> bool: if not config_path.exists(): @@ -35,6 +40,7 @@ def load_from_file(self, config_path: Path) -> bool: self.publication_freshness_days = config["General"].getint( "publication_freshness_days", 14 ) + self.root_filename = config["General"].get("root_filename") return True diff --git a/lib2opds/opds.py b/lib2opds/opds.py index 675a43f..7dbbfa0 100644 --- a/lib2opds/opds.py +++ b/lib2opds/opds.py @@ -14,7 +14,7 @@ env = Environment(loader=PackageLoader("lib2opds"), autoescape=select_autoescape()) -def get_urn(): +def get_urn() -> str: return uuid.uuid4().urn @@ -29,12 +29,12 @@ class AtomFeed: id: str = get_urn() updated: str = datetime.now().isoformat(timespec="seconds") - def export_as_xml(self, recursive=True): + def export_as_xml(self, recursive: bool = True) -> bool: raise NotImplementedError( "AtomFeed.export_as_xml should be implement in child class" ) - def is_root(self): + def is_root(self) -> bool: return self.link_self_href == self.link_start_href def get_title(self) -> str: @@ -52,18 +52,28 @@ def get_all_publications(self) -> list[Publication]: @dataclass -class OPDSCatalogEntry: - publication: Publication - acquisition_link: str - id: str = get_urn() +class AcquisitionFeed(AtomFeed): + publications: list[Publication] = field(default_factory=list) + kind: str = "acquisition" + + def export_as_xml(self, recursive: bool = False) -> bool: + template = env.get_template("acquisition-feed.xml") + data = template.render(feed=self) + self.local_path.parent.mkdir(parents=True, exist_ok=True) + with self.local_path.open(mode="w") as f: + f.write(data) + return True + + def get_all_publications(self) -> list[Publication]: + return self.publications @dataclass class NavigationFeed(AtomFeed): - entries: list[AtomFeed] = field(default_factory=list) + entries: list[AcquisitionFeed | Self] = field(default_factory=list) kind: str = "navigation" - def export_as_xml(self, recursive=True): + def export_as_xml(self, recursive: bool = True) -> bool: template = env.get_template("navigation-feed.xml") data = template.render(feed=self) self.local_path.parent.mkdir(parents=True, exist_ok=True) @@ -72,6 +82,7 @@ def export_as_xml(self, recursive=True): if recursive: for entry in self.entries: entry.export_as_xml(recursive) + return True def get_all_publications(self) -> list[Publication]: result = [] @@ -80,25 +91,7 @@ def get_all_publications(self) -> list[Publication]: return result -@dataclass -class AcquisitionFeed(AtomFeed): - entries: list[OPDSCatalogEntry] = field(default_factory=list) - kind: str = "acquisition" - - def export_as_xml(self, recursive=False): - template = env.get_template("acquisition-feed.xml") - data = template.render(feed=self) - self.local_path.parent.mkdir(parents=True, exist_ok=True) - with self.local_path.open(mode="w") as f: - f.write(data) - - def get_all_publications(self) -> list[Publication]: - # for p in self.entries: - # p.save_cover(self.config) - return self.entries - - -def get_dir_contents(dirpath): +def get_dir_contents(dirpath: Path) -> tuple[list[str], list[str]]: dirnames = [] filenames = [] with os.scandir(dirpath) as it: @@ -110,8 +103,8 @@ def get_dir_contents(dirpath): return (dirnames, filenames) -def get_authors_from_publications(publications): - result = set() +def get_authors_from_publications(publications: list[Publication]) -> set[str]: + result: set[str] = set() for p in publications: for author in p.authors: result.add(author) @@ -119,8 +112,10 @@ def get_authors_from_publications(publications): def dir2odps( - config: Config, dirpath: Path, parent, root + config: Config, dirpath: Path, parent: AtomFeed, root: AtomFeed ) -> NavigationFeed | AcquisitionFeed: + dirnames: list[str] + filenames: list[str] dirnames, filenames = get_dir_contents(dirpath) last_updated = datetime.fromtimestamp(dirpath.stat().st_mtime) title = dirpath.name.capitalize() @@ -129,6 +124,7 @@ def dir2odps( str(config.opds_base_uri), str(local_path.relative_to(config.opds_dir)) ) + feed: AtomFeed if dirnames: feed = NavigationFeed( config, @@ -139,8 +135,10 @@ def dir2odps( local_path, ) for d in dirnames: - f = dir2odps(config, Path(d), feed, root) - feed.entries.append(f) + dir_feed: NavigationFeed | AcquisitionFeed = dir2odps( + config, Path(d), feed, root + ) + feed.entries.append(dir_feed) elif filenames: feed = AcquisitionFeed( config, @@ -157,7 +155,7 @@ def dir2odps( if p: p.load_metadata(config) p.save_cover(config) - feed.entries.append(p) + feed.publications.append(p) return feed else: raise Exception("Mixed dir {}".format(dirpath)) @@ -170,8 +168,8 @@ def author_to_first_letters(author: str) -> set[str]: return set([l[0].upper() for l in chunks if len(l) > 1]) -def generate_first_letters(all_authors: set) -> set: - result = set() +def generate_first_letters(all_authors: set[str]) -> set[str]: + result: set[str] = set() for author in all_authors: chunks = [c.strip() for c in author.split(" ") if c.strip()] letters = [l[0].upper() for l in chunks if len(l) > 1] @@ -181,37 +179,20 @@ def generate_first_letters(all_authors: set) -> set: return result -def get_first_letter_feeds( - feed_by_author: list[NavigationFeed], author: str -) -> list[NavigationFeed]: - result = [] +def is_feed_sutable_to_author(feed: AtomFeed, author: str) -> bool: letters = author_to_first_letters(author) - for feed in feed_by_author: - if feed.title.upper() in letters: - result.append(feed) - return result + if feed.title.upper() in letters: + return True + else: + return False -def lib2odps( - config: Config, dirpath: Path -) -> tuple[NavigationFeed | AcquisitionFeed, datetime]: - title = config.library_title - local_path = config.opds_dir / "index.xml" - - link_start_href = link_up_href = link_self_href = urljoin( - str(config.opds_base_uri), str(local_path.relative_to(config.opds_dir)) +def get_feed_all_publications( + config: Config, feed_root: NavigationFeed, all_publications: list[Publication] +) -> AcquisitionFeed: + local_path: Path = ( + config.opds_dir / config.feeds_dir / Path(str(uuid.uuid4()) + ".xml") ) - feed_root = NavigationFeed( - config, link_self_href, link_start_href, link_up_href, title, local_path - ) - - # By directory - feed_by_directory = dir2odps(config, config.library_dir, feed_root, feed_root) - feed_by_directory.title = "Folders" - feed_root.entries.append(feed_by_directory) - - # All publications - local_path = config.opds_dir / config.feeds_dir / Path(str(uuid.uuid4()) + ".xml") link_self_href = urljoin( str(config.opds_base_uri), str(local_path.relative_to(config.opds_dir)) ) @@ -220,56 +201,64 @@ def lib2odps( link_self_href, feed_root.link_self_href, feed_root.link_self_href, - "All Books", + config.feed_all_publications_title, local_path, ) - all_publications = feed_by_directory.get_all_publications() for p in all_publications: - feed_all_publications.entries.append(p) - feed_root.entries.append(feed_all_publications) + feed_all_publications.publications.append(p) + return feed_all_publications - # By author - all_authors = get_authors_from_publications(all_publications) - local_path = config.opds_dir / config.feeds_dir / Path(str(uuid.uuid4()) + ".xml") - link_self_href = urljoin( + +def get_feed_by_author( + config: Config, feed_root: NavigationFeed, all_publications: list[Publication] +) -> NavigationFeed: + all_authors: set[str] = get_authors_from_publications(all_publications) + local_path: Path = ( + config.opds_dir / config.feeds_dir / Path(str(uuid.uuid4()) + ".xml") + ) + link_self_href: str = urljoin( str(config.opds_base_uri), str(local_path.relative_to(config.opds_dir)) ) - feed_by_author = NavigationFeed( + result: NavigationFeed = NavigationFeed( config, link_self_href, feed_root.link_self_href, feed_root.link_self_href, - "Authors", + config.feed_by_author_title, local_path, ) - first_letters = generate_first_letters(all_authors) + # A, B, C ... Z + first_letters: set[str] = generate_first_letters(all_authors) + + # feed_by_author -> [A, B, C ... Z] for first_letter in first_letters: local_path = config.opds_dir / config.feeds_dir / Path(str(uuid.uuid4()) + ".xml") link_self_href = urljoin( str(config.opds_base_uri), str(local_path.relative_to(config.opds_dir)) ) - feed_by_author_first_letter = NavigationFeed( + feed_by_author_first_letter: NavigationFeed = NavigationFeed( config, link_self_href, feed_root.link_self_href, - feed_by_author.link_self_href, + result.link_self_href, first_letter, local_path, ) - feed_by_author.entries.append(feed_by_author_first_letter) - + result.entries.append(feed_by_author_first_letter) + # A -> [Author1, Author2], B -> ... for author in all_authors: - local_path = config.opds_dir / config.feeds_dir / Path(str(uuid.uuid4()) + ".xml") - link_self_href = urljoin( - str(config.opds_base_uri), str(local_path.relative_to(config.opds_dir)) - ) + for feed in result.entries: + if not is_feed_sutable_to_author(feed, author): + continue - feeds_by_author_first_letter = get_first_letter_feeds( - feed_by_author.entries, author - ) - for feed in feeds_by_author_first_letter: - author_publications = AcquisitionFeed( + local_path = ( + config.opds_dir / config.feeds_dir / Path(str(uuid.uuid4()) + ".xml") + ) + link_self_href = urljoin( + str(config.opds_base_uri), str(local_path.relative_to(config.opds_dir)) + ) + author_publications: AcquisitionFeed = AcquisitionFeed( config, link_self_href, feed_root.link_self_href, @@ -279,12 +268,18 @@ def lib2odps( ) for p in all_publications: if author in p.authors: - author_publications.entries.append(p) - feed.entries.append(author_publications) - feed_root.entries.append(feed_by_author) + author_publications.publications.append(p) + if isinstance(feed, NavigationFeed): + feed.entries.append(author_publications) + return result - # New - local_path = config.opds_dir / config.feeds_dir / Path(str(uuid.uuid4()) + ".xml") + +def get_feed_new_publications( + config: Config, feed_root: NavigationFeed, all_publications: list[Publication] +) -> AcquisitionFeed: + local_path: Path = ( + config.opds_dir / config.feeds_dir / Path(str(uuid.uuid4()) + ".xml") + ) link_self_href = urljoin( str(config.opds_base_uri), str(local_path.relative_to(config.opds_dir)) ) @@ -293,13 +288,48 @@ def lib2odps( link_self_href, feed_root.link_self_href, feed_root.link_self_href, - "New Books", + config.feed_new_publications_title, local_path, ) for p in all_publications: updated = datetime.fromtimestamp(p.fpath.stat().st_mtime) if (datetime.now() - updated).days < config.publication_freshness_days: - feed_new_publications.entries.append(p) + feed_new_publications.publications.append(p) + return feed_new_publications + + +def lib2odps(config: Config, dirpath: Path) -> AtomFeed: + title = config.library_title + local_path: Path = config.opds_dir / config.root_filename + link_start_href = link_up_href = link_self_href = urljoin( + str(config.opds_base_uri), str(local_path.relative_to(config.opds_dir)) + ) + feed_root = NavigationFeed( + config, link_self_href, link_start_href, link_up_href, title, local_path + ) + + # By directory + feed_by_directory = dir2odps(config, config.library_dir, feed_root, feed_root) + feed_by_directory.title = config.feed_by_directory_title + feed_root.entries.append(feed_by_directory) + + # All publications + all_publications: list[Publication] = feed_by_directory.get_all_publications() + feed_all_publications: AcquisitionFeed = get_feed_all_publications( + config, feed_root, all_publications + ) + feed_root.entries.append(feed_all_publications) + + # By author + feed_by_author: NavigationFeed = get_feed_by_author( + config, feed_root, all_publications + ) + feed_root.entries.append(feed_by_author) + + # New + feed_new_publications: AcquisitionFeed = get_feed_new_publications( + config, feed_root, all_publications + ) feed_root.entries.append(feed_new_publications) return feed_root diff --git a/lib2opds/templates/acquisition-feed.xml b/lib2opds/templates/acquisition-feed.xml index 2071ba7..61a844d 100644 --- a/lib2opds/templates/acquisition-feed.xml +++ b/lib2opds/templates/acquisition-feed.xml @@ -12,7 +12,7 @@ lib2opds generator http://opds-spec.org - {% for publication in feed.entries | sort(attribute="title") %} + {% for publication in feed.publications | sort(attribute="title") %} {{ publication.title }} {{ publication.id }} diff --git a/poetry.lock b/poetry.lock index 36ce59e..29df26d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry and should not be changed by hand. [[package]] name = "defusedxml" version = "0.7.1" description = "XML bomb protection for Python stdlib modules" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -15,6 +16,7 @@ files = [ name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -32,6 +34,7 @@ i18n = ["Babel (>=2.7)"] name = "markupsafe" version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -101,6 +104,7 @@ files = [ name = "pillow" version = "10.1.0" description = "Python Imaging Library (Fork)" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -168,6 +172,7 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa name = "pypdf" version = "3.17.0" description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -182,7 +187,19 @@ docs = ["myst_parser", "sphinx", "sphinx_rtd_theme"] full = ["Pillow (>=8.0.0)", "PyCryptodome", "cryptography"] image = ["Pillow (>=8.0.0)"] +[[package]] +name = "types-pillow" +version = "10.1.0.2" +description = "Typing stubs for Pillow" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "types-Pillow-10.1.0.2.tar.gz", hash = "sha256:525c1c5ee67b0ac1721c40d2bc618226ef2123c347e527e14e05b920721a13b9"}, + {file = "types_Pillow-10.1.0.2-py3-none-any.whl", hash = "sha256:131078ffa547bf9a201d39ffcdc65633e108148085f4f1b07d4647fcfec6e923"}, +] + [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "bd152e5f9aa122c2c108502adc04c75ed1f2b89a015b64ccaa72dac5a137e9ba" +content-hash = "a1a7292169745269223fe06d3133e0964bd6fcca185ee6a8b80114203652b27e" diff --git a/pyproject.toml b/pyproject.toml index 5ee0e3f..256230a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,9 @@ pillow = "^10.0.1" pypdf = "^3.17.0" +[tool.poetry.group.dev.dependencies] +types-pillow = "^10.1.0.2" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" @@ -30,3 +33,8 @@ line_length = 90 [tool.mypy] disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = true