diff --git a/AGENTS.md b/AGENTS.md index b59d9ac..0dcfdf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,11 +22,15 @@ redirects.json ────────────────────┘ 1. `nav.py` 直接提供 MkDocs 原生 nav 数据结构,不再生成或解析 YAML。 1. `index.py` 与 `contributing.py` 在 `on_page_markdown` 阶段转换页面,不写源目录副本。 1. `redirects.py` 与 `llms.py` 通过 MkDocs 1.6 虚拟文件 API 提供 `redirects.js` 和 `llms.txt`。 -1. 非栅格资源由 MkDocs 直接从 `assets/` 复制;`image_pipeline.py` 并行优化 PNG/JPEG 并直接写入最终 `site/assets/`。dirty build 会跳过目标较新的图片。 +1. 非栅格资源由 MkDocs 直接从 `assets/` 复制;`image_pipeline.py` 并行优化 PNG/JPEG 并直接写入最终 `site/assets/`。编码结果按内容哈希缓存在 `.cache/images/`,只有源图或编码参数变化时才重新编码。 1. 生产 build 的 `on_post_build` 直接把处理后的 Markdown 版本写到 `site/` 同路径,供 `/llms.txt`、“复制 Markdown”和 AI 菜单使用。 开发模式只使用 MkDocs 原生 `--dirtyreload` 与插件生命周期,没有第二套 watcher。`docs_dir` 固定为 `docs`,不允许重新引入 `cache/`、`generated/` 或生成式 `mkdocs.yml`。 +**构建缓存**:`.cache/`(已 gitignore)存放 `cache.py` 约定的内容寻址产物——`images/` 放栅格编码结果,`html/` 放压缩后的页面。缓存必须位于 `site/` 之外:MkDocs 在每次非 dirty 构建前清空 `site/`,放在其中的缓存永远不可能命中。缓存目录**不能**加进 `config.watch`,否则 `dev` 每次写缓存都会触发重建。 + +**HTML 压缩**:`minify.py` 在 `on_post_page` 与 `on_post_template`(覆盖 `404.html`)上压缩并缓存结果。**不得换成会丢弃空属性的压缩器**(如 `minify-html`):MkDocs Material 的导航依赖 `label[tabindex]` 选择器,而主题对可折叠区块渲染的正是 `tabindex=""`,属性一旦消失,`aria-expanded` 就不再更新。 + **图片管线**:`image_pipeline.py` 直接在最终输出中产出同名 `.webp` 兄弟文件;`markdown_images.py`(注册在 `mkdocs.yml`)把本地栅格图 `` 改写为 WebP-first ``。外部 URL 不下载不镜像。Markdown 中仍写普通图片语法。 ## Key Directories @@ -42,6 +46,7 @@ redirects.json ────────────────────┘ | `overrides/` | mkdocs `custom_dir`:`main.html` 覆写 site_meta 移除主题版本号;`partials/actions.html` 追加 Fumadocs 风格文章操作区(复制 Markdown + GitHub / Markdown / Perplexity / Grok / ChatGPT / Claude Web / Claude Desktop / Claude Code / OpenAI Codex / Cursor 打开菜单);`.icons/ai/` 存放菜单品牌图标 | | `scripts/` | 三平台薄启动器(`nmteam.sh` / `nmteam.ps1` / `nmteam.bat`) | | `tests/` | pytest 行为与生命周期测试 | +| `benchmarks/` | `pytest-benchmark` 性能基准(编码参数、缓存命中、压缩开销、整构建冷/热);显式运行,**不计入** `nmteam check` | | `mkdocs.yml` | 受版本控制的 MkDocs 单一配置源;nav 由插件在内存中设置 | | `site/` | 唯一生成目录,勿手改勿提交 | @@ -56,6 +61,7 @@ uv run nmteam check # 全部质量检查(见下) uv run nmteam redirects list|add "/old/" "/new/"|remove "/old/" uv run nmteam --help uv run nmteam --verbose build # 显示详细 MkDocs 日志 +uv run pytest benchmarks/ # 性能基准(不计入 nmteam check) ``` 平台启动器(定位仓库根后原样透传参数,无业务逻辑):`scripts/nmteam.sh dev`、`.\scripts\nmteam.ps1 dev`、`scripts\nmteam.bat dev`。 @@ -102,12 +108,14 @@ Markdown 文档(`docs/`): | `src/nmteam_support/nav.py` | 生成 MkDocs 原生 nav 数据结构 | | `src/nmteam_support/contributing.py` | 非 index.md 注入贡献提示 admonition | | `src/nmteam_support/redirects.py` | redirects.json 管理(损坏保护)+ redirects.js 生成 | -| `src/nmteam_support/image_pipeline.py` + `markdown_images.py` | 并行、增量地直写图片变体;MkDocs 扩展输出 WebP-first `` | +| `src/nmteam_support/cache.py` | 内容寻址缓存的公共约定:`.cache/` 位置、`content_digest()`(含长度前缀)、`staged_path()` 原子写入辅助 | +| `src/nmteam_support/minify.py` | `render_minified_html()`:htmlmin2 压缩页面与 HTML 模板,并按输入内容缓存;**选项必须保留属性引号与空属性** | +| `src/nmteam_support/image_pipeline.py` + `markdown_images.py` | 内容寻址地并行产出图片变体(`.cache/images/` 命中则直接复制);MkDocs 扩展输出 WebP-first `` | | `src/nmteam_support/llms.py` | `render_llms_txt()` 从扫描树生成 `/llms.txt`(llmstxt.org 规范;链接指向各页 `.md` 版本) | | `src/nmteam_support/models.py` | `PageMetadata`/`DocEntry` frozen dataclass | | `pyproject.toml` | 包元数据、依赖、入口、pytest/ruff/hatchling 配置 | -| `uv.lock` | 锁定依赖(mkdocs 1.6.1、mkdocs-material 9.7.7、mkdocs-minify-plugin 0.8.0、pillow 12.3.0、typer 0.27.1、pytest 9.1.1、ruff 0.16.2、mdformat 1.0.0、mdformat-footnote 0.1.3 等) | -| `mkdocs.yml` | MkDocs 单一配置源(direct docs_dir、nmteam-support、Material、minify、Markdown 扩展) | +| `uv.lock` | 锁定依赖(mkdocs 1.6.1、mkdocs-material 9.7.7、htmlmin2 0.1.13、pillow 12.3.0、typer 0.27.1、pytest 9.1.1、ruff 0.16.2、mdformat 1.0.0、mdformat-footnote 0.1.3 等) | +| `mkdocs.yml` | MkDocs 单一配置源(direct docs_dir、nmteam-support、Material、Markdown 扩展) | | `redirects.json` | 顶层 `redirects` 对象:`{旧路径带斜杠: 新路径}` | | `.github/workflows/ci.yml` | 三 OS 矩阵 CI(push main/dev + PR):uv sync --frozen → nmteam check → 验证三个启动器 | | `.mdformat.toml` | mdformat 配置(wrap=keep、LF) | @@ -132,4 +140,7 @@ Markdown 文档(`docs/`): - 输入构造分级:conftest 的 `docs_dir` fixture(tmp_path 构造最小 docs 树)→ 插件事件测试 → 真实 MkDocs 生命周期集成。**不依赖真实 docs/ 内容**。 - CLI 测试用 `typer.testing.CliRunner` + monkeypatch;启动器测试用 subprocess + 假 uv 脚本。 - **无覆盖率门槛**(无 pytest-cov、CI 无 coverage 步骤)——新增功能时给模块补 `test_.py` 行为测试即可。 +- **性能基准**在 `benchmarks/`(`pytest-benchmark`),**不进入** `nmteam check`:`testpaths = ["tests"]` 将它隔离,只有显式 `uv run pytest benchmarks/` 才跑。基准必须针对仓库真实资源而非合成输入;新增可调参数时同时补一个 `benchmark.pedantic(setup=...)` 场景——测试体只会执行一次,需要每轮重置的状态必须放 `setup`。 +- **改动构建产物时先做字节级回归**:拿 `main` 开一个干净 worktree,两边各构建一次,逐文件比 SHA-256;除有意变更(例如 WebP 档位)外应当全等。这类对比能揭出测试覆盖不到的「某类产物被漏处理」;`404.html` 漏压缩就是这么发现的。 +- **基准表里写倍率,不写含糊的百分比**:`旧 ÷ 新` 得到的是「是原来的 X%」,不是「提升 X%」(4.41 s → 2.33 s 是**快 1.89 倍 / 耗时 -47.2%**,写成「提升 189%」会被当成算错)。min 只对 min、mean 只对 mean;数字要注明测量方式,因为跑真实 CLI 与进程内测量能差出解释器启动那 0.2s。 - CI 在 ubuntu/macos/windows 三平台跑全量 check;提交前本地至少跑 `uv run nmteam check`。 diff --git a/README.md b/README.md index fea8b78..b9b2d5a 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,11 @@ uv run nmteam build 构建结果输出到 `site/` 目录。 MkDocs 插件会在一次构建中完成动态导航、目录页、贡献提示、`llms.txt`、 -重定向脚本和图片优化。图片直接并行写入 `site/`,不会创建 `cache/` 或 -`generated/` 副本。 +重定向脚本、HTML 压缩和图片优化。图片与压缩后的 HTML 存放在 `.cache/` +(可随时删除,仅会多付一次冷构建),不会创建 `cache/` 或 `generated/` +副本。 -最终 HTML 会由 `mkdocs-minify-plugin` 压缩;生成器元标签仅保留 MkDocs +最终 HTML 由插件自行压缩并缓存;生成器元标签仅保留 MkDocs 版本,不暴露主题及其版本。 ## 静态资源 @@ -54,7 +55,7 @@ MkDocs 插件会在一次构建中完成动态导航、目录页、贡献提示 文档使用 `/assets/...` 引用这些资源。生成文档时,每张 PNG 或 JPEG 图片会同时产生: -- WebP 优先版本:质量 80 +- WebP 优先版本:质量 80,编码 effort 5 - 原格式 fallback:JPEG 使用质量 80;PNG 使用 256 色有损量化 Markdown 中仍使用普通图片语法,构建工具会自动输出 WebP @@ -134,6 +135,29 @@ uv run nmteam build 以上检查由 CI(`.github/workflows/ci.yml`)自动执行。 +## 性能基准 + +构建性能的测量在 `benchmarks/`,使用 `pytest-benchmark`。它们不计入 +`pytest` 与 `nmteam check`(后者只看 `tests/`),需要显式运行: + +```bash +uv run pytest benchmarks/ +uv run pytest benchmarks/ --benchmark-sort=name +uv run pytest benchmarks/test_bench_image_pipeline.py -k method +``` + +与保存的基线对比,用于判断一次改动是否真的更快: + +```bash +uv run pytest benchmarks/ --benchmark-json=/tmp/base.json +# 改动之后 +uv run pytest benchmarks/ --benchmark-compare=/tmp/base.json --benchmark-compare-fail=min:5% +``` + +`test_bench_build.py` 在独立进程中运行真实的 `nmteam build`,因此也把 +解释器启动计入结果;其余文件在进程内测量编码参数、缓存命中路径与 +压缩开销。所有基准都针对仓库自身的真实资源,而不是合成输入。 + ## 重定向管理 ```bash diff --git a/benchmarks/conftest.py b/benchmarks/conftest.py new file mode 100644 index 0000000..8ec4515 --- /dev/null +++ b/benchmarks/conftest.py @@ -0,0 +1,66 @@ +"""Shared fixtures for the performance suite. + +These benchmarks intentionally run against the real repository: the encoder settings, +cache layout and minifier choices they compare were made for this site's actual mix of +screenshots, diagrams and rendered pages, so synthetic inputs would measure the wrong +work. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from mkdocs.commands.build import build +from mkdocs.config import load_config + +from nmteam_support import plugin as plugin_module +from nmteam_support.image_pipeline import RASTER_SUFFIXES + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture(scope="session") +def repo_root() -> Path: + return REPO_ROOT + + +@pytest.fixture(scope="session") +def assets_dir() -> Path: + return REPO_ROOT / "assets" + + +@pytest.fixture(scope="session") +def raster_paths() -> list[Path]: + assets = REPO_ROOT / "assets" + return [ + path + for path in sorted(assets.rglob("*")) + if path.is_file() and path.suffix.lower() in RASTER_SUFFIXES + ] + + +@pytest.fixture(scope="session") +def raster_payloads(raster_paths: list[Path]) -> list[bytes]: + """Every raster's bytes, read once so no benchmark measures filesystem reads.""" + return [path.read_bytes() for path in raster_paths] + + +@pytest.fixture(scope="session") +def page_html(tmp_path_factory: pytest.TempPathFactory) -> list[str]: + """The HTML the site hands to the minifier, captured from a real build. + + The build runs with the minify hook replaced by the identity function, so these are + the minifier's inputs rather than its outputs. + """ + site_dir = tmp_path_factory.mktemp("unminified-site") + config = load_config(config_file=str(REPO_ROOT / "mkdocs.yml"), strict=True) + config.site_dir = str(site_dir) + original = plugin_module.render_minified_html + plugin_module.render_minified_html = lambda html, *, cache_dir: html + try: + config.plugins.on_startup(command="build", dirty=False) + build(config) + finally: + plugin_module.render_minified_html = original + return [path.read_text(encoding="utf-8") for path in sorted(site_dir.rglob("*.html"))] diff --git a/benchmarks/test_bench_build.py b/benchmarks/test_bench_build.py new file mode 100644 index 0000000..78c5795 --- /dev/null +++ b/benchmarks/test_bench_build.py @@ -0,0 +1,38 @@ +"""End-to-end timings for the shipped command. + +Unlike the other benchmarks these measure the real entry point in its own process, so +the numbers include interpreter start-up and are directly comparable to what a developer +sees. They build into the repository's own ``site/`` and reuse its ``.cache/``. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + + +def _build(repo_root: Path) -> None: + subprocess.run( + [sys.executable, "-m", "nmteam_support", "build"], + cwd=repo_root, + capture_output=True, + check=True, + ) + + +def test_build_cold_cache(benchmark, repo_root: Path): + """A clean checkout, or CI: every raster is encoded and every page minified.""" + benchmark.pedantic( + lambda: _build(repo_root), + setup=lambda: shutil.rmtree(repo_root / ".cache", ignore_errors=True), + rounds=3, + iterations=1, + ) + + +def test_build_warm_cache(benchmark, repo_root: Path): + """The common local loop, where only the edited page has changed.""" + _build(repo_root) + benchmark.pedantic(lambda: _build(repo_root), rounds=5, iterations=1) diff --git a/benchmarks/test_bench_cache.py b/benchmarks/test_bench_cache.py new file mode 100644 index 0000000..9aa7a3f --- /dev/null +++ b/benchmarks/test_bench_cache.py @@ -0,0 +1,35 @@ +"""Cache addressing and the cost of the warm publish path.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from nmteam_support.cache import content_digest +from nmteam_support.image_pipeline import optimize_assets + + +def test_content_digest_over_every_raster(benchmark, raster_payloads: list[bytes]): + """Hashing is what a warm build pays for every asset it does not re-encode.""" + benchmark(lambda: [content_digest(b"image", payload) for payload in raster_payloads]) + + +def test_content_digest_over_a_rendered_page(benchmark, page_html: list[str]): + benchmark(lambda: [content_digest(b"html", html.encode("utf-8")) for html in page_html]) + + +def test_warm_publish_path(benchmark, assets_dir: Path, tmp_path: Path): + """A warm build: hash every raster, then copy the cached variants into ``site/``.""" + target = tmp_path / "site" + cache_dir = tmp_path / "cache" + optimize_assets(assets_dir, target, cache_dir=cache_dir) + + def wipe_site() -> None: + shutil.rmtree(target, ignore_errors=True) + + benchmark.pedantic( + lambda: optimize_assets(assets_dir, target, cache_dir=cache_dir), + setup=wipe_site, + rounds=5, + iterations=1, + ) diff --git a/benchmarks/test_bench_image_pipeline.py b/benchmarks/test_bench_image_pipeline.py new file mode 100644 index 0000000..2c7f0e7 --- /dev/null +++ b/benchmarks/test_bench_image_pipeline.py @@ -0,0 +1,44 @@ +"""Encoder effort and concurrency, measured on the site's real rasters.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +import nmteam_support.image_pipeline as image_pipeline + + +@pytest.mark.parametrize("method", [4, 5, 6]) +def test_cold_encode_pass_by_webp_method( + benchmark, monkeypatch, assets_dir: Path, tmp_path: Path, method: int +): + """What ``WEBP_METHOD`` buys: encode effort against bytes and wall clock.""" + cache_dir = tmp_path / f"cache-m{method}" + target = tmp_path / f"site-m{method}" + monkeypatch.setattr(image_pipeline, "WEBP_METHOD", method) + + benchmark.pedantic( + lambda: image_pipeline.optimize_assets(assets_dir, target, cache_dir=cache_dir), + setup=lambda: shutil.rmtree(cache_dir, ignore_errors=True), + rounds=3, + iterations=1, + ) + + +@pytest.mark.parametrize("workers", [1, 4, 8]) +def test_cold_encode_pass_by_worker_count( + benchmark, monkeypatch, assets_dir: Path, tmp_path: Path, workers: int +): + """Whether the thread pool actually scales: Pillow's encoder releases the GIL.""" + cache_dir = tmp_path / f"cache-w{workers}" + target = tmp_path / f"site-w{workers}" + monkeypatch.setattr(image_pipeline, "MAX_IMAGE_WORKERS", workers) + + benchmark.pedantic( + lambda: image_pipeline.optimize_assets(assets_dir, target, cache_dir=cache_dir), + setup=lambda: shutil.rmtree(cache_dir, ignore_errors=True), + rounds=3, + iterations=1, + ) diff --git a/benchmarks/test_bench_minify.py b/benchmarks/test_bench_minify.py new file mode 100644 index 0000000..e06d7d1 --- /dev/null +++ b/benchmarks/test_bench_minify.py @@ -0,0 +1,42 @@ +"""Minification cost, cold against cached.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from nmteam_support.minify import render_minified_html + + +def test_minify_every_page_cold(benchmark, page_html: list[str], tmp_path: Path): + """The full-price pass, which is what a clean checkout or CI pays.""" + cache_dir = tmp_path / "cache" + + benchmark.pedantic( + lambda: [render_minified_html(html, cache_dir=cache_dir) for html in page_html], + setup=lambda: shutil.rmtree(cache_dir, ignore_errors=True), + rounds=3, + iterations=1, + ) + + +def test_minify_every_page_warm(benchmark, page_html: list[str], tmp_path: Path): + """The same pass once the cache holds every page, which is the common local build.""" + cache_dir = tmp_path / "cache" + for html in page_html: + render_minified_html(html, cache_dir=cache_dir) + + benchmark(lambda: [render_minified_html(html, cache_dir=cache_dir) for html in page_html]) + + +def test_minify_single_page_cold(benchmark, page_html: list[str], tmp_path: Path): + """One page re-rendered: the only work a single-page edit should ever repeat.""" + cache_dir = tmp_path / "cache" + page = max(page_html, key=len) + + benchmark.pedantic( + lambda: render_minified_html(page, cache_dir=cache_dir), + setup=lambda: shutil.rmtree(cache_dir, ignore_errors=True), + rounds=5, + iterations=1, + ) diff --git a/mkdocs.yml b/mkdocs.yml index 3b79975..69ff5b0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,12 +3,6 @@ site_url: https://support.nmteam.xyz plugins: - nmteam-support - search - - minify: - minify_html: true - htmlmin_opts: - remove_comments: true - remove_optional_attribute_quotes: false - reduce_empty_attributes: false theme: name: material custom_dir: overrides diff --git a/pyproject.toml b/pyproject.toml index a2f2089..9017ec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ version = "0.1.0" description = "nmTeam Support 文档站构建工具链" requires-python = ">=3.14" dependencies = [ + "htmlmin2>=0.1.13", "markdown>=3.10", "mkdocs==1.6.1", "mkdocs-material==9.7.7", - "mkdocs-minify-plugin>=0.8.0", "pillow>=12.1.0", "pyyaml>=6.0.2", "typer>=0.21.1", @@ -26,6 +26,7 @@ dev = [ "mdformat-front-matters>=2.0", "mdformat-mkdocs>=5.2.2", "pytest>=9.0", + "pytest-benchmark>=5.3.0", "ruff>=0.16", ] diff --git a/src/nmteam_support/cache.py b/src/nmteam_support/cache.py new file mode 100644 index 0000000..e415944 --- /dev/null +++ b/src/nmteam_support/cache.py @@ -0,0 +1,39 @@ +"""Content-addressed cache shared by the build's expensive, deterministic transforms.""" + +from __future__ import annotations + +import os +from hashlib import blake2b +from pathlib import Path + +# Cache root, relative to the project root. It deliberately lives outside ``site/``: +# MkDocs wipes the site directory on every non-dirty build, so a cache placed there +# would never survive long enough to be hit. The directory is disposable — deleting it +# only costs one cold build. +CACHE_DIR_NAME = ".cache" + +_DIGEST_SIZE = 20 +_LENGTH_SIZE = 8 + + +def content_digest(*parts: bytes) -> str: + """Address one cache entry by the exact inputs that determine its output. + + Callers must pass everything the output depends on, including the parameters of + the transform, so that tuning it invalidates the entries produced by the old one. + Parts are length-prefixed, so splitting an input differently cannot collide. + """ + digest = blake2b(digest_size=_DIGEST_SIZE) + for part in parts: + digest.update(len(part).to_bytes(_LENGTH_SIZE, "big")) + digest.update(part) + return digest.hexdigest() + + +def staged_path(path: Path) -> Path: + """A process-unique sibling used to publish a cache entry without partial content. + + Two builds of this checkout can overlap, so entries are written to a private name + and moved into place, making a half-written file impossible to observe. + """ + return path.with_name(f"{path.name}.{os.getpid()}.part") diff --git a/src/nmteam_support/image_pipeline.py b/src/nmteam_support/image_pipeline.py index 71cc074..14f18ce 100644 --- a/src/nmteam_support/image_pipeline.py +++ b/src/nmteam_support/image_pipeline.py @@ -1,54 +1,115 @@ -"""Direct-to-site raster optimization.""" +"""Content-addressed raster optimization with a persistent build cache.""" from __future__ import annotations import os +import shutil from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass from pathlib import Path from PIL import Image +from nmteam_support.cache import content_digest, staged_path + RASTER_QUALITY = 80 RASTER_SUFFIXES = frozenset({".jpg", ".jpeg", ".png"}) -MAX_IMAGE_WORKERS = 4 +PNG_COLORS = 256 + +# Pillow's WebP ``method`` trades encoding effort for a few percent of bytes. Measured on +# this repository's rasters, dropping from ``6`` to ``5`` costs 1.7% more bytes and takes +# roughly a quarter of the encode time — see ``benchmarks/test_bench_image_pipeline.py``. +WEBP_METHOD = 5 + +# Encoding runs in Pillow's C encoder, which releases the GIL, so threads scale with cores. +# The same benchmark measures the full pass at 2.7s / 1.0s / 0.7s for 1 / 4 / 8 workers. +MAX_IMAGE_WORKERS = 8 + +# Bump when the encoder pipeline changes in a way ``_ENCODER`` cannot express, otherwise +# the parameters below would keep matching stale cache entries. +CACHE_VERSION = 1 + +_ENCODER = ( + f"v{CACHE_VERSION}|webp:{RASTER_QUALITY}:{WEBP_METHOD}" + f"|png:{PNG_COLORS}|jpeg:{RASTER_QUALITY}:progressive" +).encode() + + +@dataclass(frozen=True) +class RasterJob: + """One source raster plus the cache and output paths derived from its content.""" + + source: Path + target: Path + cache_dir: Path + digest: str + + @property + def outputs(self) -> tuple[tuple[Path, Path], ...]: + """``(cached, published)`` pairs: WebP first, original-format fallback second.""" + return ( + (self.cache_dir / f"{self.digest}.webp", self.target.with_suffix(".webp")), + (self.cache_dir / f"{self.digest}{self.target.suffix.lower()}", self.target), + ) + + +def optimize_assets(source_dir: Path, target_dir: Path, *, cache_dir: Path) -> int: + """Publish optimized rasters into ``target_dir`` and return how many were encoded. + + A raster is only encoded when its cache entry is missing, so a warm build is a + content hash plus a file copy per asset. The return value lets callers and tests + tell a cold build from a warm one. + """ + jobs = _collect(source_dir, target_dir, cache_dir) + misses = [job for job in jobs if not _is_cached(job)] + if misses: + with ThreadPoolExecutor(max_workers=_worker_count(len(misses))) as executor: + list(executor.map(_encode, misses)) + for job in jobs: + for cached, published in job.outputs: + published.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(cached, published) + return len(misses) -def optimize_assets(source_dir: Path, target_dir: Path, *, incremental: bool = False) -> int: - """Write raster variants directly to ``target_dir`` and return the processed count.""" - tasks = [ - (source, target_dir / source.relative_to(source_dir)) +def _collect(source_dir: Path, target_dir: Path, cache_dir: Path) -> list[RasterJob]: + return [ + RasterJob( + source=source, + target=target_dir / source.relative_to(source_dir), + cache_dir=cache_dir, + digest=_digest(source), + ) for source in sorted(source_dir.rglob("*")) if source.is_file() and source.suffix.lower() in RASTER_SUFFIXES ] - if incremental: - tasks = [(source, target) for source, target in tasks if _is_stale(source, target)] - if not tasks: - return 0 - worker_count = min(MAX_IMAGE_WORKERS, len(tasks), os.cpu_count() or 1) - with ThreadPoolExecutor(max_workers=worker_count) as executor: - list(executor.map(lambda paths: _optimize_raster(*paths), tasks)) - return len(tasks) - - -def _is_stale(source: Path, fallback: Path) -> bool: - webp = fallback.with_suffix(".webp") - if not fallback.exists() or not webp.exists(): - return True - source_mtime = source.stat().st_mtime_ns - return fallback.stat().st_mtime_ns < source_mtime or webp.stat().st_mtime_ns < source_mtime - - -def _optimize_raster(source: Path, fallback: Path) -> None: - fallback.parent.mkdir(parents=True, exist_ok=True) - with Image.open(source) as image: + + +def _digest(source: Path) -> str: + """Content address over the source bytes and the encoder parameters.""" + return content_digest(_ENCODER, source.read_bytes()) + + +def _is_cached(job: RasterJob) -> bool: + return all(cached.is_file() for cached, _ in job.outputs) + + +def _worker_count(pending: int) -> int: + return max(1, min(MAX_IMAGE_WORKERS, pending, (os.cpu_count() or 1) - 1)) + + +def _encode(job: RasterJob) -> None: + """Encode one source into the cache; published files are always copies of it.""" + job.cache_dir.mkdir(parents=True, exist_ok=True) + webp, fallback = (cached for cached, _ in job.outputs) + with Image.open(job.source) as image: image.load() - webp = fallback.with_suffix(".webp") - image.save(webp, "WEBP", quality=RASTER_QUALITY, method=6) - if fallback.suffix.lower() == ".png": - _quantize_png(image).save(fallback, "PNG", optimize=True) + _save(image, webp, "WEBP", quality=RASTER_QUALITY, method=WEBP_METHOD) + if fallback.suffix == ".png": + _save(_quantize_png(image), fallback, "PNG", optimize=True) else: - jpeg = image if image.mode in {"RGB", "L", "CMYK"} else image.convert("RGB") - jpeg.save( + _save( + _jpeg_ready(image), fallback, "JPEG", quality=RASTER_QUALITY, @@ -57,13 +118,24 @@ def _optimize_raster(source: Path, fallback: Path) -> None: ) +def _save(image: Image.Image, path: Path, image_format: str, **options: bool | int) -> None: + """Stage through a process-unique file so a parallel build never reads a partial image.""" + staged = staged_path(path) + image.save(staged, image_format, **options) + staged.replace(path) + + +def _jpeg_ready(image: Image.Image) -> Image.Image: + return image if image.mode in {"RGB", "L", "CMYK"} else image.convert("RGB") + + def _quantize_png(image: Image.Image) -> Image.Image: if image.mode in {"RGBA", "LA"} or "transparency" in image.info: return image.convert("RGBA").quantize( - colors=256, + colors=PNG_COLORS, method=Image.Quantize.FASTOCTREE, ) return image.convert("RGB").quantize( - colors=256, + colors=PNG_COLORS, method=Image.Quantize.MEDIANCUT, ) diff --git a/src/nmteam_support/minify.py b/src/nmteam_support/minify.py new file mode 100644 index 0000000..0f5cf48 --- /dev/null +++ b/src/nmteam_support/minify.py @@ -0,0 +1,45 @@ +"""HTML minification of rendered pages, cached by the exact input that produced it.""" + +from __future__ import annotations + +from pathlib import Path + +import htmlmin + +from nmteam_support.cache import content_digest, staged_path + +# The options the site has always minified with. Quoting and empty attributes are +# deliberately preserved: MkDocs Material's navigation binds its behaviour to +# ``label[tabindex]``, so a minifier that drops empty attributes would silently break it. +_OPTIONS: dict[str, bool] = { + "remove_comments": True, + "remove_optional_attribute_quotes": False, + "reduce_empty_attributes": False, +} + +# Bump when the minifier output changes in a way ``_OPTIONS`` cannot express. +_CACHE_VERSION = 1 +_MINIFIER = f"v{_CACHE_VERSION}|htmlmin2|{sorted(_OPTIONS.items())}".encode() + + +def render_minified_html(html: str, *, cache_dir: Path) -> str: + """Minify one rendered page, reusing the cached result of an identical input. + + htmlmin is pure Python and costs about 0.7s for the whole site, while the pages it + receives repeat byte for byte across builds whenever the documentation tree is + unchanged — see ``benchmarks/test_bench_minify.py``. + """ + digest = content_digest(_MINIFIER, html.encode("utf-8")) + cached = cache_dir / digest + if cached.is_file(): + return cached.read_text(encoding="utf-8") + minified = _minify(html) + cache_dir.mkdir(parents=True, exist_ok=True) + staged = staged_path(cached) + staged.write_text(minified, encoding="utf-8", newline="\n") + staged.replace(cached) + return minified + + +def _minify(html: str) -> str: + return htmlmin.minify(html, **_OPTIONS) diff --git a/src/nmteam_support/plugin.py b/src/nmteam_support/plugin.py index ef7caee..0dda8bd 100644 --- a/src/nmteam_support/plugin.py +++ b/src/nmteam_support/plugin.py @@ -11,6 +11,7 @@ from mkdocs.structure.files import File, Files from mkdocs.structure.pages import Page +from nmteam_support.cache import CACHE_DIR_NAME from nmteam_support.contributing import ( render_doc_body, render_doc_file, @@ -19,6 +20,7 @@ from nmteam_support.image_pipeline import RASTER_SUFFIXES, optimize_assets from nmteam_support.index import render_index_body, render_index_page from nmteam_support.llms import render_llms_txt +from nmteam_support.minify import render_minified_html from nmteam_support.models import DocEntry from nmteam_support.nav import build_nav, is_renderable from nmteam_support.redirects import read_redirects, render_redirects_js @@ -37,11 +39,10 @@ def __init__(self) -> None: self._directories: dict[str, ScannedDir] = {} self._entries: dict[str, DocEntry] = {} self._command: Literal["build", "gh-deploy", "serve"] = "build" - self._dirty = False def on_startup(self, *, command: Literal["build", "gh-deploy", "serve"], dirty: bool) -> None: + del dirty self._command = command - self._dirty = dirty def on_config(self, config: MkDocsConfig) -> MkDocsConfig: root = _project_root(config) @@ -122,6 +123,17 @@ def on_page_markdown( return markdown return render_doc_body(markdown, path) + def on_post_page(self, output: str, /, *, page: Page, config: MkDocsConfig) -> str: + del page + return render_minified_html(output, cache_dir=_cache_dir(config, "html")) + + def on_post_template( + self, output_content: str, /, *, template_name: str, config: MkDocsConfig + ) -> str: + if not template_name.endswith(".html"): + return output_content + return render_minified_html(output_content, cache_dir=_cache_dir(config, "html")) + def on_post_build(self, *, config: MkDocsConfig) -> None: root = _project_root(config) assets_dir = root / "assets" @@ -129,7 +141,7 @@ def on_post_build(self, *, config: MkDocsConfig) -> None: optimize_assets( assets_dir, Path(config.site_dir) / "assets", - incremental=self._dirty, + cache_dir=_cache_dir(config, "images"), ) if self._command != "serve" and self._catalog is not None: _write_markdown_copies(self._catalog, self._directories, self._entries, config) @@ -141,6 +153,10 @@ def _project_root(config: MkDocsConfig) -> Path: return Path.cwd() +def _cache_dir(config: MkDocsConfig, namespace: str) -> Path: + return _project_root(config) / CACHE_DIR_NAME / namespace + + def _directory_map(root: ScannedDir) -> dict[str, ScannedDir]: result: dict[str, ScannedDir] = {} diff --git a/tests/test_build_config.py b/tests/test_build_config.py index 7388e49..7bc06f5 100644 --- a/tests/test_build_config.py +++ b/tests/test_build_config.py @@ -1,29 +1,12 @@ -"""Production HTML minification configuration tests.""" +"""Production build configuration tests.""" from pathlib import Path -import htmlmin import yaml from markdown import markdown from mkdocs.config import load_config -def test_html_minification_preserves_attribute_quotes_and_removes_comments(): - repo_root = Path(__file__).resolve().parents[1] - config = yaml.safe_load((repo_root / "mkdocs.yml").read_text(encoding="utf-8")) - minify = next(plugin["minify"] for plugin in config["plugins"] if "minify" in plugin) - source = ( - '
Copyright
' - ) - - output = htmlmin.minify(source, **minify["htmlmin_opts"]) - - assert 'class="md-footer"' in output - assert 'data-empty=""' in output - assert "internal" not in output - assert len(output) < len(source) - - def test_mkdocs_reads_sources_directly_through_support_plugin(): repo_root = Path(__file__).resolve().parents[1] config = yaml.safe_load((repo_root / "mkdocs.yml").read_text(encoding="utf-8")) diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..ff550a1 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,30 @@ +"""Shared build cache tests.""" + +from __future__ import annotations + +from pathlib import Path + +from nmteam_support.cache import content_digest, staged_path + + +def test_content_digest_is_stable_for_the_same_inputs(): + assert content_digest(b"encoder", b"payload") == content_digest(b"encoder", b"payload") + + +def test_content_digest_changes_with_every_part_it_is_given(): + baseline = content_digest(b"encoder", b"payload") + + assert content_digest(b"other-encoder", b"payload") != baseline + assert content_digest(b"encoder", b"other-payload") != baseline + assert content_digest(b"encoderpayload") != baseline + + +def test_staged_path_is_a_unique_sibling_of_the_entry(tmp_path: Path): + entry = tmp_path / "abc123" + + staged = staged_path(entry) + + assert staged.parent == entry.parent + assert staged != entry + assert staged.name.startswith("abc123.") + assert staged.name.endswith(".part") diff --git a/tests/test_image_pipeline.py b/tests/test_image_pipeline.py index b16242c..db44c12 100644 --- a/tests/test_image_pipeline.py +++ b/tests/test_image_pipeline.py @@ -1,8 +1,13 @@ """Raster asset optimization tests.""" +from __future__ import annotations + +import os +import shutil from io import BytesIO from pathlib import Path +import pytest from PIL import Image import nmteam_support.image_pipeline as image_pipeline @@ -17,33 +22,42 @@ def _gradient(mode: str = "RGB") -> Image.Image: return image -def test_optimize_assets_emits_quality_80_webp_and_jpeg_fallback(tmp_path: Path): - assert hasattr(image_pipeline, "optimize_assets") +@pytest.fixture +def cache_dir(tmp_path: Path) -> Path: + return tmp_path / "cache" / "images" + + +def test_optimize_assets_emits_webp_and_jpeg_fallback(tmp_path: Path, cache_dir: Path): source = tmp_path / "source" target = tmp_path / "target" source.mkdir() - image = _gradient() - image.save(source / "photo.jpg", quality=95) + _gradient().save(source / "photo.jpg", quality=95) - assert image_pipeline.optimize_assets(source, target) == 1 + assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1 with Image.open(source / "photo.jpg") as decoded: expected_jpeg = BytesIO() decoded.save(expected_jpeg, "JPEG", quality=80, optimize=True, progressive=True) expected_webp = BytesIO() - decoded.save(expected_webp, "WEBP", quality=80, method=6) + decoded.save( + expected_webp, + "WEBP", + quality=image_pipeline.RASTER_QUALITY, + method=image_pipeline.WEBP_METHOD, + ) assert (target / "photo.jpg").read_bytes() == expected_jpeg.getvalue() assert (target / "photo.webp").read_bytes() == expected_webp.getvalue() -def test_optimize_assets_quantizes_png_fallback_and_preserves_alpha_in_webp(tmp_path: Path): - assert hasattr(image_pipeline, "optimize_assets") +def test_optimize_assets_quantizes_png_fallback_and_preserves_alpha_in_webp( + tmp_path: Path, cache_dir: Path +): source = tmp_path / "source" target = tmp_path / "target" source.mkdir() _gradient("RGBA").save(source / "diagram.png") - assert image_pipeline.optimize_assets(source, target) == 1 + assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1 with Image.open(target / "diagram.png") as fallback: assert fallback.mode == "P" @@ -53,18 +67,80 @@ def test_optimize_assets_quantizes_png_fallback_and_preserves_alpha_in_webp(tmp_ assert "A" in preferred.getbands() -def test_optimize_assets_skips_unchanged_rasters(tmp_path: Path): - assert hasattr(image_pipeline, "optimize_assets") +def test_optimize_assets_republishes_from_cache_after_the_site_is_wiped( + tmp_path: Path, cache_dir: Path +): + """The cache outlives ``site/``, which MkDocs clears on every non-dirty build.""" + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + Image.new("RGB", (16, 16), "red").save(source / "diagram.png") + + assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1 + webp = (target / "diagram.webp").read_bytes() + + shutil.rmtree(target) # what MkDocs does to site/ before every non-dirty build + + assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 0 + assert (target / "diagram.webp").read_bytes() == webp + assert (target / "diagram.png").is_file() + + +def test_optimize_assets_reencodes_when_content_changes_under_the_old_stamp( + tmp_path: Path, cache_dir: Path +): + """Entries are addressed by content, so a rewritten file cannot pass as unchanged.""" source = tmp_path / "source" target = tmp_path / "target" source.mkdir() image = source / "diagram.png" Image.new("RGB", (16, 16), "red").save(image) + stamp = image.stat().st_mtime_ns + + assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1 + original = (target / "diagram.webp").read_bytes() + + Image.new("RGB", (16, 16), "blue").save(image) + os.utime(image, ns=(stamp, stamp)) - assert image_pipeline.optimize_assets(source, target, incremental=True) == 1 - fallback = target / "diagram.png" - webp = target / "diagram.webp" - mtimes = (fallback.stat().st_mtime_ns, webp.stat().st_mtime_ns) + assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1 + assert (target / "diagram.webp").read_bytes() != original + + +def test_optimize_assets_reencodes_when_encoder_parameters_change( + tmp_path: Path, cache_dir: Path, monkeypatch +): + """Retuning the encoder must not keep publishing output from the previous settings.""" + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + Image.new("RGB", (16, 16), "red").save(source / "diagram.png") + + assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1 + + monkeypatch.setattr(image_pipeline, "_ENCODER", b"retuned") + + assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 1 + + +def test_optimize_assets_publishes_only_completed_cache_entries(tmp_path: Path, cache_dir: Path): + """Nothing partial may reach ``site/``: entries are staged before they are visible.""" + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + Image.new("RGB", (16, 16), "red").save(source / "diagram.png") + + image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) + + assert not list(cache_dir.glob("*.part")) + assert not list(target.glob("*.part")) + + +def test_optimize_assets_ignores_non_raster_sources(tmp_path: Path, cache_dir: Path): + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + (source / "notes.txt").write_text("not an image", encoding="utf-8") - assert image_pipeline.optimize_assets(source, target, incremental=True) == 0 - assert (fallback.stat().st_mtime_ns, webp.stat().st_mtime_ns) == mtimes + assert image_pipeline.optimize_assets(source, target, cache_dir=cache_dir) == 0 + assert not target.exists() diff --git a/tests/test_minify.py b/tests/test_minify.py new file mode 100644 index 0000000..575b017 --- /dev/null +++ b/tests/test_minify.py @@ -0,0 +1,61 @@ +"""HTML minification tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import nmteam_support.minify as minify +from nmteam_support.minify import render_minified_html + + +@pytest.fixture +def cache_dir(tmp_path: Path) -> Path: + return tmp_path / "cache" / "html" + + +def test_render_minified_html_removes_comments_and_keeps_attribute_quoting(cache_dir: Path): + source = ( + '
Copyright
' + ) + + output = render_minified_html(source, cache_dir=cache_dir) + + assert 'class="md-footer"' in output + assert 'data-empty=""' in output + assert "internal" not in output + assert len(output) < len(source) + + +def test_render_minified_html_keeps_the_empty_tabindex_navigation_hook(cache_dir: Path): + """MkDocs Material binds its navigation state updates to ``label[tabindex]``. + + The theme renders ``tabindex=""`` for collapsible sections, so a minifier that + drops empty attributes would silently detach that behaviour. + """ + source = '' + + output = render_minified_html(source, cache_dir=cache_dir) + + assert 'tabindex=""' in output + + +def test_render_minified_html_reuses_the_cached_result(cache_dir: Path, monkeypatch): + source = "
spaced
" + first = render_minified_html(source, cache_dir=cache_dir) + + def explode(_: str) -> str: + raise AssertionError("re-minified an input that was already cached") + + monkeypatch.setattr(minify, "_minify", explode) + + assert render_minified_html(source, cache_dir=cache_dir) == first + + +def test_render_minified_html_caches_distinct_inputs_separately(cache_dir: Path): + assert render_minified_html("

a

", cache_dir=cache_dir) != render_minified_html( + "

b

", cache_dir=cache_dir + ) + + assert len(list(cache_dir.iterdir())) == 2 diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f2e8e48..e361a9f 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -112,3 +112,32 @@ def test_plugin_writes_final_images_and_markdown_copies_without_staging(tmp_path assert "帮助我们改进此文档" in markdown_copy.read_text(encoding="utf-8") assert not (tmp_path / "cache").exists() assert not (tmp_path / "generated").exists() + + +def test_cache_lives_outside_the_site_directory(tmp_path, docs_dir): + """MkDocs wipes ``site/`` every build, so a cache kept there could never be reused.""" + image_path = tmp_path / "assets" / "images" / "diagram.png" + image_path.parent.mkdir(parents=True) + Image.new("RGB", (16, 16), "red").save(image_path) + plugin, config, _files = _context(tmp_path, docs_dir) + Path(config.site_dir).mkdir() + + plugin.on_post_build(config=config) + + assert (tmp_path / ".cache" / "images").is_dir() + assert not (Path(config.site_dir) / ".cache").exists() + + +def test_pages_and_html_templates_are_minified_with_the_same_options(tmp_path, docs_dir): + plugin, config, files = _context(tmp_path, docs_dir) + markup = '
body
' + page = Page("MCP", files.get_file_from_path("nmbot-telegram/mcp.md"), config) + + rendered = plugin.on_post_page(markup, page=page, config=config) + template = plugin.on_post_template(markup, template_name="404.html", config=config) + ignored = plugin.on_post_template("", template_name="sitemap.xml", config=config) + + assert rendered == template + assert "comment" not in rendered + assert 'data-empty=""' in rendered + assert ignored == "" diff --git a/uv.lock b/uv.lock index 5e338d9..98225a9 100644 --- a/uv.lock +++ b/uv.lock @@ -98,12 +98,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "csscompressor" -version = "0.9.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808, upload-time = "2017-11-26T21:13:08.238Z" } - [[package]] name = "ghp-import" version = "2.1.0" @@ -154,12 +148,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jsmin" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" } - [[package]] name = "markdown" version = "3.10.3" @@ -380,21 +368,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] -[[package]] -name = "mkdocs-minify-plugin" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "csscompressor" }, - { name = "htmlmin2" }, - { name = "jsmin" }, - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/67/fe4b77e7a8ae7628392e28b14122588beaf6078b53eb91c7ed000fd158ac/mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d", size = 8366, upload-time = "2024-01-29T16:11:32.982Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/cd/2e8d0d92421916e2ea4ff97f10a544a9bd5588eb747556701c983581df13/mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", size = 6723, upload-time = "2024-01-29T16:11:31.851Z" }, -] - [[package]] name = "more-itertools" version = "11.1.0" @@ -409,10 +382,10 @@ name = "nmteam-support" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "htmlmin2" }, { name = "markdown" }, { name = "mkdocs" }, { name = "mkdocs-material" }, - { name = "mkdocs-minify-plugin" }, { name = "pillow" }, { name = "pyyaml" }, { name = "typer" }, @@ -425,15 +398,16 @@ dev = [ { name = "mdformat-front-matters" }, { name = "mdformat-mkdocs" }, { name = "pytest" }, + { name = "pytest-benchmark" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ + { name = "htmlmin2", specifier = ">=0.1.13" }, { name = "markdown", specifier = ">=3.10" }, { name = "mkdocs", specifier = "==1.6.1" }, { name = "mkdocs-material", specifier = "==9.7.7" }, - { name = "mkdocs-minify-plugin", specifier = ">=0.8.0" }, { name = "pillow", specifier = ">=12.1.0" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "typer", specifier = ">=0.21.1" }, @@ -446,6 +420,7 @@ dev = [ { name = "mdformat-front-matters", specifier = ">=2.0" }, { name = "mdformat-mkdocs", specifier = ">=5.2.2" }, { name = "pytest", specifier = ">=9.0" }, + { name = "pytest-benchmark", specifier = ">=5.3.0" }, { name = "ruff", specifier = ">=0.16" }, ] @@ -544,6 +519,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-cpuinfo2" +version = "10.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/97/a8b1ddada14c8280a047c0746f95cb05d94a31b1a331cea22bcdc2b2a82d/py_cpuinfo2-10.1.1.tar.gz", hash = "sha256:7861133863663f16e06eca63b12904ef100b5760415e92372dac0162799a4771", size = 100840, upload-time = "2026-03-25T21:49:40.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/0a/ba69d2dde1ae12ef1d389ea5a216384c5ff6ef7a1e7a48d1e9b6686f6790/py_cpuinfo2-10.1.1-py3-none-any.whl", hash = "sha256:adc53396bfb206e6498d078ec2ab407f85799ecd819584ac36a8f80a2d4d762d", size = 23791, upload-time = "2026-03-25T21:49:39.574Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -582,6 +566,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo2" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/8f/83a15e40dbc34a580ee56eb56983cae5394c6e94d50cf28fe268e457be25/pytest_benchmark-5.3.0.tar.gz", hash = "sha256:358444d4e89be901ee2b6404fb043ac3d7684002ad7f3563cc153fca6339c965", size = 375410, upload-time = "2026-08-23T17:45:08.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/42/7e80f7cfa191e0a766d1de99b4661847415ad5db34f8209d81fd42175b59/pytest_benchmark-5.3.0-py3-none-any.whl", hash = "sha256:920ab1dfcffa718d49aa15ba144c7e357bda59216a0dc308016cc1c7236f719d", size = 48401, upload-time = "2026-08-23T17:45:07.094Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"