From bdc6c756f58576849af52f3c91a62beaeb6d3d15 Mon Sep 17 00:00:00 2001 From: Adrien Brignon Date: Fri, 7 Jun 2024 22:38:54 +0200 Subject: [PATCH] feat: aggregating pages together - Fixed page numbers not being coherent - Improved concurrency and performance --- docs/assets/stylesheets/custom.css | 6 ++ docs/getting-started.md | 2 + docs/reference/configuration/index.md | 2 + mkdocs.yml | 4 +- mkdocs_exporter/config.py | 9 ++ mkdocs_exporter/formats/pdf/aggregator.py | 45 +++++---- mkdocs_exporter/formats/pdf/browser.py | 18 ++-- mkdocs_exporter/formats/pdf/plugin.py | 91 ++++++++++++------- mkdocs_exporter/formats/pdf/renderer.py | 4 +- .../pdf}/resources/js/__init__.py | 0 .../pdf}/resources/js/pagedjs.min.js | 0 .../{ => formats/pdf}/resources/js/pdf.js | 15 +++ mkdocs_exporter/helpers.py | 17 ++++ mkdocs_exporter/page.py | 1 + mkdocs_exporter/plugin.py | 20 +++- mkdocs_exporter/preprocessor.py | 7 ++ poetry.lock | 20 ++-- 17 files changed, 185 insertions(+), 76 deletions(-) rename mkdocs_exporter/{ => formats/pdf}/resources/js/__init__.py (100%) rename mkdocs_exporter/{ => formats/pdf}/resources/js/pagedjs.min.js (100%) rename mkdocs_exporter/{ => formats/pdf}/resources/js/pdf.js (51%) diff --git a/docs/assets/stylesheets/custom.css b/docs/assets/stylesheets/custom.css index 914fee6..e6d4a0f 100644 --- a/docs/assets/stylesheets/custom.css +++ b/docs/assets/stylesheets/custom.css @@ -13,6 +13,12 @@ } } +@page { + @bottom-center { + content: 'Page ' counter(page) ' of ' counter(pages); + } +} + .md-icon-spin { animation-name: spin; animation-duration: 3s; diff --git a/docs/getting-started.md b/docs/getting-started.md index ee8b763..4ae6de0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -3,6 +3,8 @@ hide: - navigation --- + + # Getting started ## Introduction diff --git a/docs/reference/configuration/index.md b/docs/reference/configuration/index.md index 6bf3308..b67982a 100644 --- a/docs/reference/configuration/index.md +++ b/docs/reference/configuration/index.md @@ -5,3 +5,5 @@ ::: mkdocs_exporter.config.ButtonConfig ::: mkdocs_exporter.config.FormatsConfig + +::: mkdocs_exporter.config.LoggingConfig diff --git a/mkdocs.yml b/mkdocs.yml index e751999..30c3507 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,8 +57,6 @@ plugins: show_source: true show_labels: false show_root_heading: true - - privacy: - log_level: error - search: lang: en - awesome-pages @@ -77,6 +75,8 @@ plugins: cards_layout_options: background_color: '#EA2027' - exporter: + logging: + level: debug formats: pdf: enabled: !ENV [MKDOCS_EXPORTER_PDF, true] diff --git a/mkdocs_exporter/config.py b/mkdocs_exporter/config.py index 2ffb520..4966182 100644 --- a/mkdocs_exporter/config.py +++ b/mkdocs_exporter/config.py @@ -28,6 +28,12 @@ class ButtonConfig(BaseConfig): """Some extra attributes to add to the button.""" +class LoggingConfig(BaseConfig): + """The logging configuration.""" + + level = c.Choice(['debug', 'info', 'warning', 'error', 'critical'], default='info') + + class Config(BaseConfig): """The plugin's configuration.""" @@ -39,3 +45,6 @@ class Config(BaseConfig): buttons = c.ListOfItems(c.SubConfig(ButtonConfig)) """The buttons to add.""" + + logging = c.SubConfig(LoggingConfig) + """The logging configuration.""" diff --git a/mkdocs_exporter/formats/pdf/aggregator.py b/mkdocs_exporter/formats/pdf/aggregator.py index b8e4555..8d9da94 100644 --- a/mkdocs_exporter/formats/pdf/aggregator.py +++ b/mkdocs_exporter/formats/pdf/aggregator.py @@ -2,13 +2,23 @@ import os -from pypdf import PdfReader, PdfWriter +from pypdf import PdfWriter + +from mkdocs_exporter.formats.pdf.renderer import Renderer +from mkdocs_exporter.formats.pdf.preprocessor import Preprocessor class Aggregator: """Aggregates PDF documents together.""" + def __init__(self, renderer: Renderer): + """The constructor.""" + + self.total_pages = 0 + self.renderer = renderer + + def open(self, path: str) -> Aggregator: """Opens the aggregator.""" @@ -16,32 +26,29 @@ def open(self, path: str) -> Aggregator: self.writer = PdfWriter() - def covers(self, mode: str) -> Aggregator: - """Defines the way of handling cover pages.""" + def increment_total_pages(self, total_pages: int) -> Aggregator: + """Increments the total pages count.""" - self._covers = mode + self.total_pages = self.total_pages + total_pages - def aggregate(self, pages: list) -> Aggregator: - """Aggregates pages together.""" + def preprocess(self, html: str, page_number: int = 1) -> str: + """Preprocesses the page.""" - for n, page in enumerate(pages): - if 'pdf' not in page.formats: - continue + preprocessor = Preprocessor() - bounds = None - pdf = page.formats['pdf']['path'] - total = len(PdfReader(pdf).pages) + preprocessor.preprocess(html) + preprocessor.metadata({'page': page_number, 'pages': self.total_pages}) - if 'covers' in page.formats['pdf']: - covers = page.formats['pdf']['covers'] + return preprocessor.done() - if self._covers == 'none': - bounds = (1 if covers['front'] else 0, (total - 1) if covers['back'] else total) - if self._covers == 'limits': - bounds = (1 if n != 0 and covers['front'] else 0, (total - 1) if n != (len(pages) - 1) and covers['back'] else total) - self.writer.append(pdf, pages=bounds) + def append(self, document: str) -> Aggregator: + """Appends a document to this one.""" + + self.writer.append(document) + + return self def save(self, metadata={}) -> Aggregator: diff --git a/mkdocs_exporter/formats/pdf/browser.py b/mkdocs_exporter/formats/pdf/browser.py index 93af7f9..b5cd103 100644 --- a/mkdocs_exporter/formats/pdf/browser.py +++ b/mkdocs_exporter/formats/pdf/browser.py @@ -84,28 +84,30 @@ async def close(self) -> Browser: return self - async def print(self, html: str) -> bytes: - """Prints some HTML to PDF.""" + async def print(self, html: str) -> tuple[bytes, int]: + """Prints some HTML to PDF and returns the PDF and the number of pages printed.""" - page = await self.context.new_page() + pages = 0 + context = await self.context.new_page() file = NamedTemporaryFile(suffix='.html', mode='w+', encoding='utf-8', delete=False) file.write(html) file.close() - await page.goto('file://' + file.name, wait_until='networkidle') - await page.locator('body[mkdocs-exporter="true"]').wait_for(timeout=self.timeout) + await context.goto('file://' + file.name, wait_until='networkidle') + await context.locator('body[mkdocs-exporter="true"]').wait_for(timeout=self.timeout) - pdf = await page.pdf(prefer_css_page_size=True, print_background=True, display_header_footer=False) + pages = int(await context.locator('body').get_attribute('mkdocs-exporter-pages') or 0) + pdf = await context.pdf(prefer_css_page_size=True, print_background=True, display_header_footer=False) try: os.unlink(file) except Exception: pass - await page.close() + await context.close() - return pdf + return (pdf, pages) async def log(self, msg): diff --git a/mkdocs_exporter/formats/pdf/plugin.py b/mkdocs_exporter/formats/pdf/plugin.py index c8b45e4..cd16be6 100644 --- a/mkdocs_exporter/formats/pdf/plugin.py +++ b/mkdocs_exporter/formats/pdf/plugin.py @@ -2,13 +2,14 @@ import types import asyncio -from typing import Optional, Coroutine, Sequence +from typing import Optional -from mkdocs.plugins import BasePlugin from mkdocs.plugins import event_priority from mkdocs.livereload import LiveReloadServer +from mkdocs.plugins import BasePlugin, CombinedEvent from mkdocs_exporter.page import Page +from mkdocs_exporter.helpers import concurrently from mkdocs_exporter.logging import logger from mkdocs_exporter.formats.pdf.config import Config from mkdocs_exporter.formats.pdf.renderer import Renderer @@ -25,7 +26,9 @@ def __init__(self): self.watch: list[str] = [] self.renderer: Optional[Renderer] = None self.tasks: list[types.CoroutineType] = [] + self.aggregator: Optional[Aggregator] = None self.loop: Optional[asyncio.AbstractEventLoopPolicy] = None + self.on_post_build = CombinedEvent(self._on_post_build_1, self._on_post_build_2, self._on_post_build_3) def on_config(self, config: dict) -> None: @@ -84,16 +87,16 @@ def on_page_markdown(self, markdown: str, page: Page, config: Config, **kwargs) def on_pre_build(self, **kwargs) -> None: """Invoked before the build process starts.""" - self.tasks.clear() - if not self._enabled(): return self.loop = asyncio.new_event_loop() + self.renderer = Renderer(options=self.config) - asyncio.set_event_loop(self.loop) + if self.config.aggregator.get('enabled'): + self.aggregator = Aggregator(renderer=self.renderer) - self.renderer = Renderer(options=self.config) + asyncio.set_event_loop(self.loop) for stylesheet in self.config.stylesheets: self.renderer.add_stylesheet(stylesheet) @@ -107,8 +110,6 @@ def on_pre_page(self, page: Page, config: dict, **kwargs): if not self._enabled(): return - if not hasattr(page, 'html'): - raise Exception('Missing `exporter` plugin or your plugins are not ordered properly!') directory = os.path.dirname(page.file.abs_dest_path) filename = os.path.splitext(os.path.basename(page.file.abs_dest_path))[0] + '.pdf' @@ -127,58 +128,85 @@ def on_post_page(self, html: str, page: Page, **kwargs) -> Optional[str]: if not self._enabled(page) and 'pdf' in page.formats: del page.formats['pdf'] - page.html = html - if 'pdf' in page.formats: async def render(page: Page) -> None: logger.info("[mkdocs-exporter.pdf] Rendering '%s'...", page.file.src_path) html = self.renderer.preprocess(page) - pdf = await self.renderer.render(html) + pdf, pages = await self.renderer.render(html) + + page.formats['pdf']['pages'] = pages with open(page.formats['pdf']['path'], 'wb+') as file: file.write(pdf) - logger.info("[mkdocs-exporter.pdf] File written to '%s'!", file.name) + + if self.aggregator: + self.aggregator.increment_total_pages(pages) self.tasks.append(render(page)) return page.html - @event_priority(-100) - def on_post_build(self, config: dict, **kwargs) -> None: + @event_priority(-90) + def _on_post_build_1(self, **kwargs) -> None: """Invoked after the build process.""" if not self._enabled(): return + while self.tasks: + self.loop.run_until_complete(asyncio.gather(*concurrently(self.tasks, max(1, self.config.concurrency or 1)))) + + + @event_priority(-95) + def _on_post_build_2(self, config: dict, **kwargs) -> None: + """Invoked after the build process.""" + + if not self._enabled() or not self.aggregator: + return - def concurrently(coroutines: Sequence[Coroutine], concurrency: int) -> Sequence[Coroutine]: - semaphore = asyncio.Semaphore(concurrency) + output = self.config['aggregator']['output'] + self.pages = [page for page in self.pages if 'pdf' in page.formats] - async def limit(coroutine: Coroutine) -> Coroutine: - async with semaphore: - return await asyncio.create_task(coroutine) + logger.info("[mkdocs-exporter.pdf] Aggregating %d pages from %d documents together as '%s'...", self.aggregator.total_pages, len(self.pages), output) - return [limit(coroutine) for coroutine in coroutines] + async def render(page: Page, page_number: int) -> None: + html = self.aggregator.preprocess(self.renderer.preprocess(page), page_number=page_number) + pdf, _ = await self.renderer.render(html) + + with open(page.formats['pdf']['path'] + '.aggregate', 'wb+') as file: + file.write(pdf) + + for n, page in enumerate(self.pages): + self.tasks.append(render(page, page_number=sum(page.formats['pdf']['pages'] for page in self.pages[:n]))) + while self.tasks: + self.loop.run_until_complete(asyncio.gather(*concurrently(self.tasks, max(1, self.config.concurrency or 1)))) + + self.aggregator.open(os.path.join(config['site_dir'], output)) + + for page in self.pages: + self.aggregator.append(page.formats['pdf']['path'] + '.aggregate') + os.unlink(page.formats['pdf']['path'] + '.aggregate') + + self.aggregator.save() + + + @event_priority(-100) + def _on_post_build_3(self, **kwargs) -> None: + """Invoked after the build process.""" + + if not self._enabled(): + return - self.loop.run_until_complete(asyncio.gather(*concurrently(self.tasks, max(1, self.config.concurrency or 1)))) self.loop.run_until_complete(self.renderer.dispose()) - self.tasks.clear() self.loop = None + self.pages = None self.renderer = None + self.aggregator = None asyncio.set_event_loop(self.loop) - if self.config.get('aggregator', {})['enabled']: - aggregator = Aggregator() - aggregator_config = self.config.get('aggregator', {}) - - aggregator.open(os.path.join(config['site_dir'], aggregator_config['output'])) - aggregator.covers(aggregator_config['covers']) - aggregator.aggregate(self.pages) - aggregator.save(metadata=aggregator_config['metadata']) - @event_priority(-100) def on_nav(self, nav, **kwargs): @@ -195,7 +223,6 @@ def flatten(items): return pages - self.nav = nav self.pages = flatten(nav) diff --git a/mkdocs_exporter/formats/pdf/renderer.py b/mkdocs_exporter/formats/pdf/renderer.py index 3787ea3..51f715c 100644 --- a/mkdocs_exporter/formats/pdf/renderer.py +++ b/mkdocs_exporter/formats/pdf/renderer.py @@ -6,7 +6,7 @@ from urllib.parse import unquote from mkdocs_exporter.page import Page -from mkdocs_exporter.resources import js +from mkdocs_exporter.formats.pdf.resources import js from mkdocs_exporter.formats.pdf.browser import Browser from mkdocs_exporter.renderer import Renderer as BaseRenderer from mkdocs_exporter.formats.pdf.preprocessor import Preprocessor @@ -78,7 +78,7 @@ def preprocess(self, page: Page) -> str: return preprocessor.done() - async def render(self, page: str | Page) -> bytes: + async def render(self, page: str | Page) -> tuple[bytes, int]: """Renders a page as a PDF document.""" if not self.browser.launched: diff --git a/mkdocs_exporter/resources/js/__init__.py b/mkdocs_exporter/formats/pdf/resources/js/__init__.py similarity index 100% rename from mkdocs_exporter/resources/js/__init__.py rename to mkdocs_exporter/formats/pdf/resources/js/__init__.py diff --git a/mkdocs_exporter/resources/js/pagedjs.min.js b/mkdocs_exporter/formats/pdf/resources/js/pagedjs.min.js similarity index 100% rename from mkdocs_exporter/resources/js/pagedjs.min.js rename to mkdocs_exporter/formats/pdf/resources/js/pagedjs.min.js diff --git a/mkdocs_exporter/resources/js/pdf.js b/mkdocs_exporter/formats/pdf/resources/js/pdf.js similarity index 51% rename from mkdocs_exporter/resources/js/pdf.js rename to mkdocs_exporter/formats/pdf/resources/js/pdf.js index d13bce6..e66328e 100644 --- a/mkdocs_exporter/resources/js/pdf.js +++ b/mkdocs_exporter/formats/pdf/resources/js/pdf.js @@ -26,6 +26,21 @@ window.PagedConfig = { * Invoked once all pages have been rendered. */ after: () => { + if ('__MKDOCS_EXPORTER__' in window) { + const pages = document.getElementsByClassName('pagedjs_pages')[0]; + + if (pages) { + if ('pages' in __MKDOCS_EXPORTER__) { + pages.style.setProperty('--pagedjs-page-count', __MKDOCS_EXPORTER__.pages); + } + + if ('page' in __MKDOCS_EXPORTER__ && pages.children[0]) { + pages.children[0].style.setProperty('counter-reset', `page ${__MKDOCS_EXPORTER__.page}`); + } + } + } + + document.body.setAttribute('mkdocs-exporter-pages', document.getElementsByClassName('pagedjs_page').length); document.body.setAttribute('mkdocs-exporter', 'true'); } diff --git a/mkdocs_exporter/helpers.py b/mkdocs_exporter/helpers.py index 187cb85..78d26b8 100644 --- a/mkdocs_exporter/helpers.py +++ b/mkdocs_exporter/helpers.py @@ -1,3 +1,6 @@ +import asyncio + +from typing import Coroutine, Sequence from collections import UserDict @@ -12,3 +15,17 @@ def resolve(object, *args, **kwargs): return {k: resolve(v, *args, **kwargs) for k, v in object.items()} return object + + +def concurrently(coroutines: Sequence[Coroutine], concurrency: int) -> Sequence[Coroutine]: + """Runs coroutines concurrently.""" + + semaphore = asyncio.Semaphore(concurrency) + + async def limit(coroutine: Coroutine) -> Coroutine: + async with semaphore: + coroutines.remove(coroutine) + + return await asyncio.create_task(coroutine) + + return [limit(coroutine) for coroutine in coroutines] diff --git a/mkdocs_exporter/page.py b/mkdocs_exporter/page.py index 7dd3207..3d356f0 100644 --- a/mkdocs_exporter/page.py +++ b/mkdocs_exporter/page.py @@ -1,4 +1,5 @@ from typing import Optional + from mkdocs_exporter.theme import Theme from mkdocs.structure.pages import Page as BasePage diff --git a/mkdocs_exporter/plugin.py b/mkdocs_exporter/plugin.py index d352355..a9974ec 100644 --- a/mkdocs_exporter/plugin.py +++ b/mkdocs_exporter/plugin.py @@ -1,11 +1,14 @@ +import logging + from typing import Type -from mkdocs.plugins import BasePlugin from mkdocs.plugins import event_priority from mkdocs.structure.files import File, Files +from mkdocs.plugins import BasePlugin, CombinedEvent from mkdocs_exporter.page import Page from mkdocs_exporter.config import Config +from mkdocs_exporter.logging import logger from mkdocs_exporter.helpers import resolve from mkdocs_exporter.preprocessor import Preprocessor from mkdocs_exporter.themes.factory import Factory as ThemeFactory @@ -20,6 +23,7 @@ def __init__(self) -> None: """The constructor.""" self.stylesheets: list[File] = [] + self.on_post_page = CombinedEvent(self._on_post_page_1, self._on_post_page_2) @event_priority(100) @@ -28,6 +32,9 @@ def on_config(self, config: dict, *args, **kwargs) -> None: self.theme = ThemeFactory.create(self.config.theme or config['theme']) + if 'level' in self.config.logging: + logger.setLevel(logging.getLevelName(self.config.logging.level.upper())) + def register(key, plugin: Type[Plugin], config_data: dict) -> Plugin: """Registers a MkDocs plugin dynamically.""" @@ -50,15 +57,22 @@ def on_pre_build(self, **kwargs) -> None: @event_priority(100) def on_pre_page(self, page: Page, **kwargs) -> None: - """Invoked after a page has been built.""" + """Invoked before a page has been built.""" page.html = None page.formats = {} page.theme = self.theme + @event_priority(100) + def _on_post_page_1(self, html: str, page: Page, **kwargs) -> str: + """Invoked after a page has been built (and before all other plugins).""" + + page.html = html + + @event_priority(-100) - def on_post_page(self, html: str, page: Page, **kwargs) -> str: + def _on_post_page_2(self, html: str, page: Page, **kwargs) -> str: """Invoked after a page has been built (and after all other plugins).""" preprocessor = Preprocessor(theme=page.theme) diff --git a/mkdocs_exporter/preprocessor.py b/mkdocs_exporter/preprocessor.py index 9360c6b..fcb695f 100644 --- a/mkdocs_exporter/preprocessor.py +++ b/mkdocs_exporter/preprocessor.py @@ -2,6 +2,7 @@ import os import sass +import json from typing import Union from sass import CompileError @@ -140,6 +141,12 @@ def update_links(self, base: str, root: str = None) -> Preprocessor: return self + def metadata(self, metadata: dict) -> Preprocessor: + """Inserts metadata.""" + + return self.script(f"window.__MKDOCS_EXPORTER__ = {json.dumps(metadata)};") + + def done(self) -> str: """End the preprocessing, returning the result.""" diff --git a/poetry.lock b/poetry.lock index 6030eba..1c86837 100644 --- a/poetry.lock +++ b/poetry.lock @@ -973,13 +973,13 @@ test = ["mkdocs-include-markdown-plugin", "mkdocs-macros-test", "mkdocs-material [[package]] name = "mkdocs-material" -version = "9.5.25" +version = "9.5.26" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.25-py3-none-any.whl", hash = "sha256:68fdab047a0b9bfbefe79ce267e8a7daaf5128bcf7867065fcd201ee335fece1"}, - {file = "mkdocs_material-9.5.25.tar.gz", hash = "sha256:d0662561efb725b712207e0ee01f035ca15633f29a64628e24f01ec99d7078f4"}, + {file = "mkdocs_material-9.5.26-py3-none-any.whl", hash = "sha256:5d01fb0aa1c7946a1e3ae8689aa2b11a030621ecb54894e35aabb74c21016312"}, + {file = "mkdocs_material-9.5.26.tar.gz", hash = "sha256:56aeb91d94cffa43b6296fa4fbf0eb7c840136e563eecfd12c2d9e92e50ba326"}, ] [package.dependencies] @@ -1638,13 +1638,13 @@ test = ["pytest", "ruff"] [[package]] name = "typing-extensions" -version = "4.12.1" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"}, - {file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -1735,18 +1735,18 @@ files = [ [[package]] name = "zipp" -version = "3.19.1" +version = "3.19.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.19.1-py3-none-any.whl", hash = "sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091"}, - {file = "zipp-3.19.1.tar.gz", hash = "sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f"}, + {file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"}, + {file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"}, ] [package.extras] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [metadata] lock-version = "2.0"