zallery 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zallery/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Build a light-and-dark example gallery for Zensical docs from jupytext scripts.
2
+
3
+ Examples are percent-format `.py` files executed once in an ipykernel; each
4
+ displayed matplotlib figure is rendered for both themes from that single run
5
+ (see `zallery.theme`). [`build_gallery`][zallery.build_gallery] runs the whole
6
+ pipeline: discover, cache, execute, render, index.
7
+ """
8
+
9
+ from zallery.pipeline import build_gallery
10
+
11
+ __all__ = ["build_gallery"]
zallery/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m zallery` alongside the `zallery` console script."""
2
+
3
+ from zallery.cli import main
4
+
5
+ raise SystemExit(main())
zallery/_types.py ADDED
@@ -0,0 +1,43 @@
1
+ """Shared dataclasses for the gallery builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ExampleSpec:
11
+ """A single discovered example file.
12
+
13
+ Attributes
14
+ ----------
15
+ source : pathlib.Path
16
+ Absolute path to the percent-format `.py` file.
17
+ base_name : str
18
+ Output stem used for the rendered Markdown, downloads, and cache entry.
19
+ Derived from `source.stem` with any leading numeric ordering prefix
20
+ (e.g. `01_`) stripped, so the source filename can control card order
21
+ without leaking the prefix into URLs.
22
+ section : str
23
+ Section folder name (e.g. `"io"`).
24
+ section_intro : str
25
+ Contents of the sibling `_section.md` file, or empty string if none.
26
+ """
27
+
28
+ source: Path
29
+ base_name: str
30
+ section: str
31
+ section_intro: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class RenderedExample:
36
+ """An example after the renderer has produced its artifacts."""
37
+
38
+ spec: ExampleSpec
39
+ title: str
40
+ summary: str
41
+ md_path: Path
42
+ thumbnail_light: Path | None
43
+ thumbnail_dark: Path | None
zallery/cache.py ADDED
@@ -0,0 +1,58 @@
1
+ """Cache key + cache directory helpers for the gallery builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ from zallery._types import ExampleSpec
9
+
10
+
11
+ def cache_key(
12
+ spec: ExampleSpec,
13
+ *,
14
+ deps_fingerprint: str,
15
+ python_version: str,
16
+ ) -> str:
17
+ """Compute a deterministic cache key for one example.
18
+
19
+ Parameters
20
+ ----------
21
+ spec : ExampleSpec
22
+ The example; its source file content is part of the key.
23
+ deps_fingerprint : str
24
+ Text identifying everything else that shapes the render (lockfile,
25
+ builder sources, configuration).
26
+ python_version : str
27
+ Version of the Python interpreter the example runs under.
28
+
29
+ Returns
30
+ -------
31
+ str
32
+ SHA-256 hex digest of the three inputs.
33
+ """
34
+ digest = hashlib.sha256()
35
+ digest.update(spec.source.read_bytes())
36
+ digest.update(b"\x00")
37
+ digest.update(deps_fingerprint.encode("utf-8"))
38
+ digest.update(b"\x00")
39
+ digest.update(python_version.encode("utf-8"))
40
+ return digest.hexdigest()
41
+
42
+
43
+ def cache_dir(root: Path, key: str) -> Path:
44
+ """Return the cache directory for `key` rooted at `root`.
45
+
46
+ Parameters
47
+ ----------
48
+ root : pathlib.Path
49
+ Cache root directory.
50
+ key : str
51
+ Cache key of one example, see `cache_key`.
52
+
53
+ Returns
54
+ -------
55
+ pathlib.Path
56
+ Directory holding that example's cached artifacts.
57
+ """
58
+ return root / key
zallery/cli.py ADDED
@@ -0,0 +1,96 @@
1
+ """Command line interface: `zallery [--no-run] [--no-cache] [EXAMPLE ...]`.
2
+
3
+ Run from anywhere inside a project whose `pyproject.toml` has a `[tool.zallery]`
4
+ table (see `zallery.config`). With no arguments every example is executed; name
5
+ example scripts to run only those. The whole gallery is rendered either way: the
6
+ examples not run are restored from cache if present, or rendered without outputs
7
+ (code and Markdown only).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+
18
+ from zallery.config import ConfigError, deps_fingerprint, load_config
19
+ from zallery.pipeline import build_gallery
20
+
21
+ BINDER_REF_ENV = "ZALLERY_BINDER_REF"
22
+ """Environment variable giving the git ref stamped into Binder URLs (default `main`)."""
23
+
24
+
25
+ def main(argv: Sequence[str] | None = None) -> int:
26
+ """Build the gallery of the project the working directory belongs to.
27
+
28
+ Parameters
29
+ ----------
30
+ argv : Sequence[str], optional
31
+ Command line arguments. If not provided, `sys.argv[1:]` is used.
32
+
33
+ Returns
34
+ -------
35
+ int
36
+ Exit status: 0 on success, 1 when the examples directory is missing or an
37
+ argument names no example, 2 when no configuration is found.
38
+ """
39
+ parser = argparse.ArgumentParser(prog="zallery", description=__doc__)
40
+ parser.add_argument(
41
+ "examples",
42
+ nargs="*",
43
+ type=Path,
44
+ help=(
45
+ "Example scripts to run. The whole gallery is still rendered; unnamed "
46
+ "examples are taken from cache if present, or rendered without outputs. "
47
+ "If not provided, all examples are run."
48
+ ),
49
+ )
50
+ parser.add_argument(
51
+ "--no-run",
52
+ action="store_true",
53
+ help=(
54
+ "Run no example at all: every page is taken from cache if present, or "
55
+ "rendered without outputs. Cannot be combined with example paths."
56
+ ),
57
+ )
58
+ parser.add_argument(
59
+ "--no-cache",
60
+ action="store_true",
61
+ help=(
62
+ "Execute the selected examples even if a cached result exists, and "
63
+ "replace it. Examples not selected still come from cache."
64
+ ),
65
+ )
66
+ args = parser.parse_args(argv)
67
+ if args.no_run and args.examples:
68
+ parser.error("--no-run cannot be combined with example paths.")
69
+
70
+ try:
71
+ config = load_config(Path.cwd())
72
+ except ConfigError as exc:
73
+ print(f"zallery: {exc}", file=sys.stderr)
74
+ return 2
75
+ if not config.examples.is_dir():
76
+ print(f"No examples directory at {config.examples}", file=sys.stderr)
77
+ return 1
78
+
79
+ try:
80
+ build_gallery(
81
+ examples_root=config.examples,
82
+ built_dir=config.built,
83
+ cache_root=config.cache,
84
+ deps_fingerprint=deps_fingerprint(config),
85
+ repo_root=config.root,
86
+ binder_repo=config.binder_repo,
87
+ binder_ref=os.environ.get(BINDER_REF_ENV, "main"),
88
+ only=[] if args.no_run else (args.examples or None),
89
+ use_cache=not args.no_cache,
90
+ hook_module=config.hook,
91
+ hook_root=config.root,
92
+ )
93
+ except ValueError as exc:
94
+ print(exc, file=sys.stderr)
95
+ return 1
96
+ return 0
zallery/config.py ADDED
@@ -0,0 +1,204 @@
1
+ """Project configuration, read from the `[tool.zallery]` table of `pyproject.toml`.
2
+
3
+ The table is looked up from the working directory upwards, so `zallery` can run
4
+ from anywhere inside the project. Relative paths in it are resolved against the
5
+ directory holding that `pyproject.toml`, which is also the project root the kernel
6
+ gets on its `sys.path` so the theme hook module imports.
7
+
8
+ Keys (all optional):
9
+
10
+ - `examples`: directory of example sections, default `docs/examples`.
11
+ - `built`: where rendered pages go, default `<examples>/_built`.
12
+ - `cache`: execution/render cache directory, default `.gallery-cache`.
13
+ - `binder_repo`: `owner/repo` slug for the Binder launch buttons; no buttons if unset.
14
+ - `hook`: dotted name of the module defining `gallery_retheme(fig, theme)`.
15
+ - `fingerprint`: files whose content invalidates the cache when it changes,
16
+ default `["uv.lock"]`. The `zallery` sources, the hook module and the table
17
+ itself are always part of the fingerprint.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import tomllib
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ import zallery
28
+
29
+ _TABLE = "zallery"
30
+ """Name of the table under `[tool]`."""
31
+
32
+ _KNOWN_KEYS = frozenset(
33
+ {"examples", "built", "cache", "binder_repo", "hook", "fingerprint"}
34
+ )
35
+ """Keys the table may contain; anything else is a typo and rejected."""
36
+
37
+
38
+ class ConfigError(ValueError):
39
+ """Raised when no usable `[tool.zallery]` table is found or it is malformed."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Config:
44
+ """Resolved project configuration.
45
+
46
+ Attributes
47
+ ----------
48
+ root : pathlib.Path
49
+ Directory holding the `pyproject.toml` the table was read from.
50
+ examples : pathlib.Path
51
+ Directory of example sections.
52
+ built : pathlib.Path
53
+ Directory rendered pages and assets are written to.
54
+ cache : pathlib.Path
55
+ Execution/render cache directory.
56
+ binder_repo : str, optional
57
+ `owner/repo` slug for Binder launch buttons. If not provided, no buttons.
58
+ hook : str, optional
59
+ Dotted name of the project's theme hook module. If not provided, only the
60
+ built-in swap runs.
61
+ fingerprint_files : tuple[pathlib.Path, ...]
62
+ Files whose content is part of the cache fingerprint.
63
+ """
64
+
65
+ root: Path
66
+ examples: Path
67
+ built: Path
68
+ cache: Path
69
+ binder_repo: str | None
70
+ hook: str | None
71
+ fingerprint_files: tuple[Path, ...]
72
+
73
+ @property
74
+ def hook_file(self) -> Path | None:
75
+ """Source file of the hook module under `root`, if the module is configured.
76
+
77
+ Returns
78
+ -------
79
+ pathlib.Path or None
80
+ `<root>/a/b.py` or `<root>/a/b/__init__.py` for `hook = "a.b"`, whichever
81
+ exists, else the module path with `.py`; `None` without a hook.
82
+ """
83
+ if self.hook is None:
84
+ return None
85
+ base = self.root.joinpath(*self.hook.split("."))
86
+ package_init = base / "__init__.py"
87
+ return package_init if package_init.is_file() else base.with_suffix(".py")
88
+
89
+
90
+ def find_pyproject(start: Path) -> Path | None:
91
+ """Return the nearest `pyproject.toml` with a `[tool.zallery]` table.
92
+
93
+ Parameters
94
+ ----------
95
+ start : pathlib.Path
96
+ Directory to search from; its parents are searched in turn.
97
+
98
+ Returns
99
+ -------
100
+ pathlib.Path or None
101
+ The file, or `None` if no ancestor holds one with the table.
102
+ """
103
+ for directory in (start.resolve(), *start.resolve().parents):
104
+ candidate = directory / "pyproject.toml"
105
+ if candidate.is_file():
106
+ with candidate.open("rb") as handle:
107
+ data = tomllib.load(handle)
108
+ if _TABLE in data.get("tool", {}):
109
+ return candidate
110
+ return None
111
+
112
+
113
+ def load_config(start: Path) -> Config:
114
+ """Load the configuration that applies to `start`.
115
+
116
+ Parameters
117
+ ----------
118
+ start : pathlib.Path
119
+ Directory to search from, typically the working directory.
120
+
121
+ Returns
122
+ -------
123
+ Config
124
+ The resolved configuration, with relative paths made absolute under the
125
+ project root.
126
+
127
+ Raises
128
+ ------
129
+ ConfigError
130
+ If no `pyproject.toml` with a `[tool.zallery]` table is found, or the table
131
+ has unknown keys.
132
+ """
133
+ pyproject = find_pyproject(start)
134
+ if pyproject is None:
135
+ raise ConfigError(
136
+ f"No pyproject.toml with a [tool.zallery] table found in {start} or its "
137
+ "parents."
138
+ )
139
+ with pyproject.open("rb") as handle:
140
+ table = tomllib.load(handle)["tool"][_TABLE]
141
+ unknown = sorted(set(table) - _KNOWN_KEYS)
142
+ if unknown:
143
+ raise ConfigError(
144
+ f"Unknown keys in [tool.zallery] of {pyproject}: {', '.join(unknown)}. "
145
+ f"Known keys: {', '.join(sorted(_KNOWN_KEYS))}."
146
+ )
147
+
148
+ root = pyproject.parent
149
+ examples = root / table.get("examples", "docs/examples")
150
+ return Config(
151
+ root=root,
152
+ examples=examples,
153
+ built=root / table["built"] if "built" in table else examples / "_built",
154
+ cache=root / table.get("cache", ".gallery-cache"),
155
+ binder_repo=table.get("binder_repo"),
156
+ hook=table.get("hook"),
157
+ fingerprint_files=tuple(
158
+ root / p for p in table.get("fingerprint", ["uv.lock"])
159
+ ),
160
+ )
161
+
162
+
163
+ def deps_fingerprint(config: Config) -> str:
164
+ """Return a string identifying everything that shapes an example's render.
165
+
166
+ Any change to it makes every cached example miss: the configured fingerprint
167
+ files (the lockfile by default), the `zallery` sources, the hook module, and
168
+ the configuration values themselves. The Binder ref is deliberately not part of
169
+ it, so renders are reused across branches.
170
+
171
+ Parameters
172
+ ----------
173
+ config : Config
174
+ The project configuration.
175
+
176
+ Returns
177
+ -------
178
+ str
179
+ Concatenated, locale-independent text of all the inputs.
180
+ """
181
+ parts: list[str] = [
182
+ json.dumps(
183
+ {
184
+ "examples": str(config.examples.relative_to(config.root)),
185
+ "built": str(config.built.relative_to(config.root)),
186
+ "binder_repo": config.binder_repo,
187
+ "hook": config.hook,
188
+ },
189
+ sort_keys=True,
190
+ )
191
+ ]
192
+ # Read everything as UTF-8 so the text does not depend on the platform's locale.
193
+ for path in config.fingerprint_files:
194
+ if path.is_file():
195
+ parts.append(f"\n# {path.relative_to(config.root)}\n")
196
+ parts.append(path.read_text(encoding="utf-8"))
197
+ for path in sorted(Path(zallery.__file__).resolve().parent.glob("*.py")):
198
+ parts.append(f"\n# zallery/{path.name}\n")
199
+ parts.append(path.read_text(encoding="utf-8"))
200
+ hook_file = config.hook_file
201
+ if hook_file is not None and hook_file.is_file():
202
+ parts.append(f"\n# {hook_file.relative_to(config.root)}\n")
203
+ parts.append(hook_file.read_text(encoding="utf-8"))
204
+ return "".join(parts)
zallery/discover.py ADDED
@@ -0,0 +1,54 @@
1
+ """Discover example source files under the examples root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from zallery._types import ExampleSpec
9
+
10
+ # Strip leading "01_", "02_" etc. from section directory names and example file
11
+ # names so the built output uses clean names ("io", "decomposition",
12
+ # "pca_single_recording") while the source paths can be prefixed for explicit
13
+ # ordering.
14
+ _NUMERIC_PREFIX_RE = re.compile(r"^\d+_")
15
+
16
+
17
+ def discover(root: Path) -> list[ExampleSpec]:
18
+ """Return one example spec per example file.
19
+
20
+ Parameters
21
+ ----------
22
+ root : pathlib.Path
23
+ Examples root; each subdirectory not starting with `_` is a section.
24
+
25
+ Returns
26
+ -------
27
+ list[ExampleSpec]
28
+ One spec per `.py` file not starting with `_`, sorted by section folder
29
+ then file name. Empty if `root` is not a directory.
30
+ """
31
+ specs: list[ExampleSpec] = []
32
+ if not root.is_dir():
33
+ return specs
34
+
35
+ for section_dir in sorted(
36
+ p for p in root.iterdir() if p.is_dir() and not p.name.startswith("_")
37
+ ):
38
+ intro_path = section_dir / "_section.md"
39
+ intro = intro_path.read_text(encoding="utf-8") if intro_path.is_file() else ""
40
+ section_name = _NUMERIC_PREFIX_RE.sub("", section_dir.name)
41
+
42
+ for source in sorted(section_dir.glob("*.py")):
43
+ if source.name.startswith("_"):
44
+ continue
45
+ specs.append(
46
+ ExampleSpec(
47
+ source=source,
48
+ base_name=_NUMERIC_PREFIX_RE.sub("", source.stem),
49
+ section=section_name,
50
+ section_intro=intro,
51
+ )
52
+ )
53
+
54
+ return specs