From 8d363946b96d5cc1acfa8f50e8b684d24051859e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:13:21 +0000 Subject: [PATCH 1/7] Initial plan From b5e3fa8f91afd5fa0d0ccb6f5ea86726732b9cf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:25:02 +0000 Subject: [PATCH 2/7] Initial exploration - understanding the cross-references issue Agent-Logs-Url: https://github.com/OutSquareCapital/pyochain/sessions/ae1f437f-4e75-4afb-a797-a5d4be62346e Co-authored-by: OutSquareCapital <166045166+OutSquareCapital@users.noreply.github.com> --- docs/index.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/index.md b/docs/index.md index 7069134..64d5905 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,3 +9,9 @@ Welcome to the `pyochain` documentation! This library brings a fluent, declarati - [**GitHub Repository**](https://github.com/OutSquareCapital/pyochain) - [**Contributing Guide**](https://github.com/OutSquareCapital/pyochain/blob/master/CONTRIBUTING.md) - [**PyPI Package**](https://pypi.org/project/pyochain/) + +## Test Cross-References + +[`pyochain.Iter`][] + +[`pyochain._iter.Seq`][] From 99ba739a8bdd17f8e5e809f4a655fd0566fbdcb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:36:33 +0000 Subject: [PATCH 3/7] Add cross-reference support: fix_autorefs.py script, autorefs extension, and docs updates Agent-Logs-Url: https://github.com/OutSquareCapital/pyochain/sessions/ae1f437f-4e75-4afb-a797-a5d4be62346e Co-authored-by: OutSquareCapital <166045166+OutSquareCapital@users.noreply.github.com> --- .github/workflows/docs.yml | 3 + docs/api-reference.md | 60 ++++---- docs/getting-started.md | 10 ++ docs/index.md | 6 - scripts/fix_autorefs.py | 305 +++++++++++++++++++++++++++++++++++++ scripts/rebuild-docs.ps1 | 5 + zensical.toml | 2 + 7 files changed, 355 insertions(+), 36 deletions(-) create mode 100644 scripts/fix_autorefs.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5db8a04..3263374 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,6 +26,9 @@ jobs: - name: Build documentation run: uv run zensical build + - name: Resolve cross-references + run: uv run scripts/fix_autorefs.py + - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: diff --git a/docs/api-reference.md b/docs/api-reference.md index 1b6439e..c1d40c6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -4,53 +4,53 @@ This page is the entry point to the **complete** public API documentation. ## Collections -- [`Seq[T]`](reference/seq.md) — Immutable collections (tuple-backed) -- [`Vec[T]`](reference/vec.md) — Mutable collections (list-backed) -- [`Set[T]`](reference/set.md) — Immutable collections (frozenset-backed) -- [`SetMut[T]`](reference/setmut.md) — Mutable sets (set-backed) -- [`Dict[K, V]`](reference/dict.md) — Chainable dictionaries +- [`Seq[T]`][pyochain._iter.Seq] — Immutable collections (tuple-backed) +- [`Vec[T]`][pyochain._iter.Vec] — Mutable collections (list-backed) +- [`Set[T]`][pyochain._iter.Set] — Immutable collections (frozenset-backed) +- [`SetMut[T]`][pyochain._iter.SetMut] — Mutable sets (set-backed) +- [`Dict[K, V]`][pyochain._dict.Dict] — Chainable dictionaries ## Iterators -- [`Iter[T]`](reference/iter.md) — Lazy processing of iterators -- [`Peekable`](reference/peekable.md) — Peeking iterator -- [`Unzipped`](reference/unzipped.md) — Unzipped iterator results +- [`Iter[T]`][pyochain._iter.Iter] — Lazy processing of iterators +- [`Peekable`][pyochain._iter.Peekable] — Peeking iterator +- [`Unzipped`][pyochain._iter.Unzipped] — Unzipped iterator results ## Error handling -- [`Result[T, E]`](reference/result.md) — Explicit error handling (`Ok` / `Err`) -- [`Ok`](reference/ok.md) -- [`Err`](reference/err.md) -- [`ResultUnwrapError`](reference/resultunwraperror.md) +- [`Result[T, E]`][pyochain.rs.Result] — Explicit error handling (`Ok` / `Err`) +- [`Ok`][pyochain.rs.Ok] +- [`Err`][pyochain.rs.Err] +- [`ResultUnwrapError`][pyochain.rs.ResultUnwrapError] ## Optional values -- [`Option[T]`](reference/option.md) — Optional values (`Some` / `NONE`) -- [`Some`](reference/some.md) -- [`None`](reference/noneoption.md) -- [`OptionUnwrapError`](reference/optionunwraperror.md) +- [`Option[T]`][pyochain.rs.Option] — Optional values (`Some` / `NONE`) +- [`Some`][pyochain.rs.Some] +- [`None`][pyochain.rs.NoneOption] +- [`OptionUnwrapError`][pyochain.rs.OptionUnwrapError] ## Traits & mixins ### Fluent Traits -- [`Pipeable`](reference/pipeable.md) — `.into()`, `.inspect()` -- [`Checkable`](reference/checkable.md) — `.then()`, `.ok_or()`, ... +- [`Pipeable`][pyochain.rs.Pipeable] — `.into()`, `.inspect()` +- [`Checkable`][pyochain.rs.Checkable] — `.then()`, `.ok_or()`, ... ### Abstract Collection Traits -- [`PyoIterable[T]`](reference/pyoiterable.md) — Base trait for all iterables -- [`PyoIterator[T]`](reference/pyoiterator.md) — Iterator trait -- [`PyoCollection[T]`](reference/pyocollection.md) — Base trait for eager collections -- [`PyoSequence[T]`](reference/pyosequence.md) — Sequence trait -- [`PyoMutableSequence[T]`](reference/pyomutablesequence.md) — Mutable sequence trait -- [`PyoSet[T]`](reference/pyoset.md) — Set trait -- [`PyoMappingView[T]`](reference/pyomappingview.md) — Mapping view trait -- [`PyoMapping[K, V]`](reference/pyomapping.md) — Mapping trait -- [`PyoMutableMapping[K, V]`](reference/pyomutablemapping.md) — Mutable mapping trait +- [`PyoIterable[T]`][pyochain.traits._iterable.PyoIterable] — Base trait for all iterables +- [`PyoIterator[T]`][pyochain.traits._iterable.PyoIterator] — Iterator trait +- [`PyoCollection[T]`][pyochain.traits._iterable.PyoCollection] — Base trait for eager collections +- [`PyoSequence[T]`][pyochain.traits._iterable.PyoSequence] — Sequence trait +- [`PyoMutableSequence[T]`][pyochain.traits._iterable.PyoMutableSequence] — Mutable sequence trait +- [`PyoSet[T]`][pyochain.traits._iterable.PyoSet] — Set trait +- [`PyoMappingView[T]`][pyochain.traits._iterable.PyoMappingView] — Mapping view trait +- [`PyoMapping[K, V]`][pyochain.traits._iterable.PyoMapping] — Mapping trait +- [`PyoMutableMapping[K, V]`][pyochain.traits._iterable.PyoMutableMapping] — Mutable mapping trait ### Mapping Views -- [`PyoKeysView[K]`](reference/pyokeysview.md) — Keys view -- [`PyoValuesView[V]`](reference/pyovaluesview.md) — Values view -- [`PyoItemsView[K, V]`](reference/pyoitemsview.md) — Items view +- [`PyoKeysView[K]`][pyochain.traits._iterable.PyoKeysView] — Keys view +- [`PyoValuesView[V]`][pyochain.traits._iterable.PyoValuesView] — Values view +- [`PyoItemsView[K, V]`][pyochain.traits._iterable.PyoItemsView] — Items view diff --git a/docs/getting-started.md b/docs/getting-started.md index e25225f..c5a277b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -26,3 +26,13 @@ Seq(1, 9, 25, 49, 81) - [Core Types Overview](core-types-overview.md): choose between the various provided types - [Interoperability](interoperability.md): convert between types with various methods - [Examples & Cookbook](examples.md): practical patterns and concrete examples + +## Cross-references + +Throughout the documentation, type names like [`Iter`][pyochain._iter.Iter], +[`Seq`][pyochain._iter.Seq], [`Option`][pyochain.rs.Option], and +[`Result`][pyochain.rs.Result] are linked directly to their API pages. + +You can also use this syntax in your own documentation pages when using +pyochain — write `[ClassName][pyochain._iter.ClassName]` or +`[pyochain._iter.ClassName][]` to create a cross-reference. diff --git a/docs/index.md b/docs/index.md index 64d5905..7069134 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,9 +9,3 @@ Welcome to the `pyochain` documentation! This library brings a fluent, declarati - [**GitHub Repository**](https://github.com/OutSquareCapital/pyochain) - [**Contributing Guide**](https://github.com/OutSquareCapital/pyochain/blob/master/CONTRIBUTING.md) - [**PyPI Package**](https://pypi.org/project/pyochain/) - -## Test Cross-References - -[`pyochain.Iter`][] - -[`pyochain._iter.Seq`][] diff --git a/scripts/fix_autorefs.py b/scripts/fix_autorefs.py new file mode 100644 index 0000000..a56ae42 --- /dev/null +++ b/scripts/fix_autorefs.py @@ -0,0 +1,305 @@ +"""Post-process the built site to resolve mkdocstrings cross-reference tags. + +Zensical does not call ``fix_refs`` from ``mkdocs-autorefs``, so the +```` tags emitted by mkdocstrings are left un-resolved in the +generated HTML. This script performs a two-pass fix: + +1. Walk every ``index.html`` file under ``site/`` and collect a mapping of + anchor ``id`` → absolute-root URL (e.g. + ``/reference/iter/#pyochain._iter.Iter``). +2. Walk every ``index.html`` file again and replace each ```` + tag with either a proper ```` link (when the identifier can be + resolved) or a ```` (when the identifier is optional and + cannot be resolved). + +Usage:: + + uv run scripts/fix_autorefs.py [site_dir] + +The optional ``site_dir`` argument defaults to ``site``. +""" + +from __future__ import annotations + +import importlib +import re +import sys +from html import escape +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Regex patterns +# --------------------------------------------------------------------------- + +# Matches a single tag (non-greedy, dot-all). +_AUTOREF_RE = re.compile( + r"[^>]*?)>(?P.*?)</autoref>", + re.DOTALL, +) + +# Matches a single HTML attribute in the form key or key="value". +_ATTR_RE = re.compile( + r'(?P<key>[\w][\w-]*)(?:="(?P<value>[^"]*)")?', +) + +# Matches an anchor id attribute anywhere in the HTML source. +_ID_RE = re.compile(r'\bid="([^"]+)"') + +# Strips generic type parameters from an identifier, e.g. +# pyochain._iter.Iter.collect[R] → pyochain._iter.Iter.collect +_GENERIC_STRIP_RE = re.compile(r"\[.*") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _parse_attrs(attrs_str: str) -> dict[str, str | None]: + """Parse a space-separated HTML attribute string into a dict. + + Bare flags (no value) are stored as ``{key: None}``. + """ + result: dict[str, str | None] = {} + for m in _ATTR_RE.finditer(attrs_str): + key = m.group("key") + value = m.group("value") # None when flag-only + result[key] = value + return result + + +def _relative_url(from_page: str, to_url: str) -> str: + """Compute the relative URL from *from_page* to *to_url*. + + Both paths must be absolute (start with ``/``) and use ``/`` separators. + ``to_url`` may include a ``#anchor`` fragment. + + Args: + from_page: The absolute URL of the page that contains the link. + to_url: The absolute URL of the target (including optional anchor). + + Returns: + A relative URL string. + """ + to_url_no_anchor, *anchor_parts = to_url.split("#", 1) + anchor = anchor_parts[0] if anchor_parts else "" + + parts_a = from_page.strip("/").split("/") + parts_b = to_url_no_anchor.strip("/").split("/") + + # Remove common prefix segments. + while parts_a and parts_b and parts_a[0] == parts_b[0]: + parts_a.pop(0) + parts_b.pop(0) + + ups = len(parts_a) + relative_parts = [".."] * ups + parts_b + relative = "/".join(relative_parts) or "." + if anchor: + return f"{relative}#{anchor}" + return relative + + +# --------------------------------------------------------------------------- +# Pass 1 – collect anchor map +# --------------------------------------------------------------------------- + + +def _build_anchor_map(site_dir: Path) -> dict[str, str]: + """Return a mapping of anchor identifier → absolute-root URL. + + Also adds aliases for re-exported names so that e.g. + ``pyochain.traits.PyoIterator`` resolves even though the anchor in the + HTML carries the canonical module path + ``pyochain.traits._iterable.PyoIterator``. + + Args: + site_dir: The root of the built site. + + Returns: + A dict mapping anchor ``id`` values to their absolute URLs + (e.g. ``/reference/iter/#pyochain._iter.Iter``). + """ + anchor_map: dict[str, str] = {} + + for html_file in sorted(site_dir.rglob("index.html")): + rel = html_file.parent.relative_to(site_dir) + # Convert to a URL path, e.g. "reference/iter" → "/reference/iter/" + page_url = "/" + str(rel).replace("\\", "/").strip("/") + if page_url != "/": + page_url += "/" + + content = html_file.read_text(encoding="utf-8") + for anchor_id in _ID_RE.findall(content): + full_url = f"{page_url}#{anchor_id}" + # First registration wins (keeps stable ordering). + anchor_map.setdefault(anchor_id, full_url) + + # Add aliases for re-exported names discovered by inspecting the package. + _add_reexport_aliases(anchor_map) + + return anchor_map + + +def _add_reexport_aliases(anchor_map: dict[str, str]) -> None: + """Extend *anchor_map* with aliases for re-exported public names. + + For every module ``M`` that re-exports a class ``C`` originally defined + in ``M._sub`` (or ``pyochain.rs``), we add the entry + ``M.C → anchor_map["M._sub.C"]`` so that cross-references using the + public path are resolved correctly. + + Args: + anchor_map: The anchor map to extend in-place. + """ + try: + import pyochain # noqa: PLC0415 + except ImportError: + return # Can't introspect; skip silently. + + # Collect all pyochain (sub-)modules to inspect. + modules_to_inspect: list[tuple[str, object]] = [] + for attr in dir(pyochain): + try: + mod = importlib.import_module(f"pyochain.{attr}") + modules_to_inspect.append((f"pyochain.{attr}", mod)) + except (ImportError, ModuleNotFoundError): + pass + + for module_path, module in modules_to_inspect: + public_names = getattr(module, "__all__", None) + if public_names is None: + continue + for name in public_names: + obj = getattr(module, name, None) + if obj is None or not isinstance(obj, type): + continue + canonical_mod = getattr(obj, "__module__", None) + if canonical_mod is None: + continue + # Normalise: builtins → pyochain.rs (Rust extension types). + if canonical_mod == "builtins": + canonical_mod = "pyochain.rs" + canonical_id = f"{canonical_mod}.{obj.__qualname__}" + alias_id = f"{module_path}.{name}" + if alias_id != canonical_id and canonical_id in anchor_map: + anchor_map.setdefault(alias_id, anchor_map[canonical_id]) + + +# --------------------------------------------------------------------------- +# Pass 2 – rewrite autoref tags +# --------------------------------------------------------------------------- + + +def _make_replacer( + anchor_map: dict[str, str], + page_url: str, +) -> re.Pattern[str]: + """Return a replacement callable for ``re.sub``. + + Args: + anchor_map: Mapping built by :func:`_build_anchor_map`. + page_url: The absolute URL of the page being processed. + + Returns: + A function suitable as the ``repl`` argument of ``re.sub``. + """ + + def _replace(match: re.Match) -> str: + attrs = _parse_attrs(match.group("attrs")) + title: str = match.group("title") + identifier: str = attrs.get("identifier") or "" + optional: bool = "optional" in attrs + + # Resolution strategy (first match wins): + # 1. Exact identifier. + # 2. Strip trailing generic parameters: Iter.collect[R] → Iter.collect + # 3. Strip trailing attribute name: Err.error → Err + target_url = anchor_map.get(identifier) + if target_url is None: + stripped = _GENERIC_STRIP_RE.sub("", identifier) + target_url = anchor_map.get(stripped) + if target_url is None and "." in identifier: + parent = identifier.rsplit(".", 1)[0] + target_url = anchor_map.get(parent) + + if target_url is None: + if optional: + # Render as a <span> with the identifier as tooltip. + return f'<span title="{escape(identifier)}">{title}</span>' + # Non-optional unresolved reference: leave as Markdown cross-ref. + return f"[{title}][{identifier}]" + + rel = _relative_url(page_url, target_url) + return ( + f'<a class="autorefs autorefs-internal" href="{escape(rel)}">' + f"{title}</a>" + ) + + return _replace + + +def _fix_file(html_file: Path, anchor_map: dict[str, str], site_dir: Path) -> int: + """Rewrite *html_file* in-place, resolving all ``<autoref>`` tags. + + Args: + html_file: Path to the HTML file to process. + anchor_map: Mapping built by :func:`_build_anchor_map`. + site_dir: The root of the built site. + + Returns: + The number of ``<autoref>`` tags that were replaced. + """ + content = html_file.read_text(encoding="utf-8") + if "<autoref " not in content: + return 0 + + rel = html_file.parent.relative_to(site_dir) + page_url = "/" + str(rel).replace("\\", "/").strip("/") + if page_url != "/": + page_url += "/" + + replacer = _make_replacer(anchor_map, page_url) + new_content, count = _AUTOREF_RE.subn(replacer, content) + if count: + html_file.write_text(new_content, encoding="utf-8") + return count + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(site_dir: Path | None = None) -> None: + """Run the two-pass fix on every HTML file inside *site_dir*. + + Args: + site_dir: Root of the built site. Defaults to ``./site``. + """ + if site_dir is None: + site_dir = Path("site") + + if not site_dir.is_dir(): + print(f"Site directory not found: {site_dir}", file=sys.stderr) + sys.exit(1) + + print("Building anchor map…") + anchor_map = _build_anchor_map(site_dir) + print(f" Found {len(anchor_map)} anchors.") + + total = 0 + files = 0 + for html_file in sorted(site_dir.rglob("index.html")): + replaced = _fix_file(html_file, anchor_map, site_dir) + if replaced: + files += 1 + total += replaced + + print(f"Resolved {total} cross-reference(s) across {files} file(s).") + + +if __name__ == "__main__": + site_arg = Path(sys.argv[1]) if len(sys.argv) > 1 else None + main(site_arg) diff --git a/scripts/rebuild-docs.ps1 b/scripts/rebuild-docs.ps1 index c45a124..8a65c95 100644 --- a/scripts/rebuild-docs.ps1 +++ b/scripts/rebuild-docs.ps1 @@ -6,4 +6,9 @@ Remove-Item -Recurse -Force site/ -ErrorAction SilentlyContinue Remove-Item -Recurse -Force .cache/ -ErrorAction SilentlyContinue Write-Host "Building documentation..." -ForegroundColor Cyan +uv run zensical build + +Write-Host "Resolving cross-references..." -ForegroundColor Cyan +uv run scripts/fix_autorefs.py + uv run zensical serve \ No newline at end of file diff --git a/zensical.toml b/zensical.toml index 761ee4a..9add4c4 100644 --- a/zensical.toml +++ b/zensical.toml @@ -83,3 +83,5 @@ custom_fences = [ { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" }, ] + + [project.markdown_extensions."mkdocs_autorefs._internal.references:AutorefsExtension"] From 41923b3b68c8f68501027087aa2d7e7df9c89c6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:39:14 +0000 Subject: [PATCH 4/7] Fix review comments: correct return type annotation, improve unresolved ref handling, simplify rebuild script Agent-Logs-Url: https://github.com/OutSquareCapital/pyochain/sessions/ae1f437f-4e75-4afb-a797-a5d4be62346e Co-authored-by: OutSquareCapital <166045166+OutSquareCapital@users.noreply.github.com> --- scripts/fix_autorefs.py | 22 +++++++++++++--------- scripts/rebuild-docs.ps1 | 4 +--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/scripts/fix_autorefs.py b/scripts/fix_autorefs.py index a56ae42..4875dc2 100644 --- a/scripts/fix_autorefs.py +++ b/scripts/fix_autorefs.py @@ -9,8 +9,8 @@ ``/reference/iter/#pyochain._iter.Iter``). 2. Walk every ``index.html`` file again and replace each ``<autoref …>`` tag with either a proper ``<a>`` link (when the identifier can be - resolved) or a ``<span title="…">`` (when the identifier is optional and - cannot be resolved). + resolved) or a ``<span title="…">`` (when the identifier cannot be + resolved). Non-optional unresolved references also emit a warning. Usage:: @@ -24,6 +24,7 @@ import importlib import re import sys +from collections.abc import Callable from html import escape from pathlib import Path @@ -195,7 +196,7 @@ def _add_reexport_aliases(anchor_map: dict[str, str]) -> None: def _make_replacer( anchor_map: dict[str, str], page_url: str, -) -> re.Pattern[str]: +) -> Callable[[re.Match[str]], str]: """Return a replacement callable for ``re.sub``. Args: @@ -206,7 +207,7 @@ def _make_replacer( A function suitable as the ``repl`` argument of ``re.sub``. """ - def _replace(match: re.Match) -> str: + def _replace(match: re.Match[str]) -> str: attrs = _parse_attrs(match.group("attrs")) title: str = match.group("title") identifier: str = attrs.get("identifier") or "" @@ -225,11 +226,14 @@ def _replace(match: re.Match) -> str: target_url = anchor_map.get(parent) if target_url is None: - if optional: - # Render as a <span> with the identifier as tooltip. - return f'<span title="{escape(identifier)}">{title}</span>' - # Non-optional unresolved reference: leave as Markdown cross-ref. - return f"[{title}][{identifier}]" + # Render as a <span> with the identifier as tooltip. + # Optional refs are silently unresolved; non-optional ones emit a warning. + if not optional: + print( + f"WARNING: unresolved cross-reference: {identifier!r}", + file=sys.stderr, + ) + return f'<span title="{escape(identifier)}">{title}</span>' rel = _relative_url(page_url, target_url) return ( diff --git a/scripts/rebuild-docs.ps1 b/scripts/rebuild-docs.ps1 index 8a65c95..8932e2b 100644 --- a/scripts/rebuild-docs.ps1 +++ b/scripts/rebuild-docs.ps1 @@ -9,6 +9,4 @@ Write-Host "Building documentation..." -ForegroundColor Cyan uv run zensical build Write-Host "Resolving cross-references..." -ForegroundColor Cyan -uv run scripts/fix_autorefs.py - -uv run zensical serve \ No newline at end of file +uv run scripts/fix_autorefs.py \ No newline at end of file From 169d641f68fab42633b4711f0a7be9246f6cf6e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:40:31 +0000 Subject: [PATCH 5/7] Improve unresolved cross-reference warning to include page URL context Agent-Logs-Url: https://github.com/OutSquareCapital/pyochain/sessions/ae1f437f-4e75-4afb-a797-a5d4be62346e Co-authored-by: OutSquareCapital <166045166+OutSquareCapital@users.noreply.github.com> --- scripts/fix_autorefs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/fix_autorefs.py b/scripts/fix_autorefs.py index 4875dc2..9d4dbaa 100644 --- a/scripts/fix_autorefs.py +++ b/scripts/fix_autorefs.py @@ -230,7 +230,7 @@ def _replace(match: re.Match[str]) -> str: # Optional refs are silently unresolved; non-optional ones emit a warning. if not optional: print( - f"WARNING: unresolved cross-reference: {identifier!r}", + f"WARNING: unresolved cross-reference in {page_url}: {identifier!r}", file=sys.stderr, ) return f'<span title="{escape(identifier)}">{title}</span>' From 2ec62315ce16a6e19688582cc807cf142881739f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:31:54 +0000 Subject: [PATCH 6/7] Address review: remove getting-started section, rewrite fix_autorefs.py with pyochain patterns Agent-Logs-Url: https://github.com/OutSquareCapital/pyochain/sessions/22463733-e40a-47a5-9090-ca23ca661686 Co-authored-by: OutSquareCapital <166045166+OutSquareCapital@users.noreply.github.com> --- docs/getting-started.md | 10 -- scripts/fix_autorefs.py | 326 ++++++++++++++++++++++++++++------------ 2 files changed, 234 insertions(+), 102 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index c5a277b..e25225f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -26,13 +26,3 @@ Seq(1, 9, 25, 49, 81) - [Core Types Overview](core-types-overview.md): choose between the various provided types - [Interoperability](interoperability.md): convert between types with various methods - [Examples & Cookbook](examples.md): practical patterns and concrete examples - -## Cross-references - -Throughout the documentation, type names like [`Iter`][pyochain._iter.Iter], -[`Seq`][pyochain._iter.Seq], [`Option`][pyochain.rs.Option], and -[`Result`][pyochain.rs.Result] are linked directly to their API pages. - -You can also use this syntax in your own documentation pages when using -pyochain — write `[ClassName][pyochain._iter.ClassName]` or -`[pyochain._iter.ClassName][]` to create a cross-reference. diff --git a/scripts/fix_autorefs.py b/scripts/fix_autorefs.py index 9d4dbaa..bbd6886 100644 --- a/scripts/fix_autorefs.py +++ b/scripts/fix_autorefs.py @@ -28,6 +28,9 @@ from html import escape from pathlib import Path +import pyochain as pc +import rich +import rich.text # --------------------------------------------------------------------------- # Regex patterns @@ -61,12 +64,22 @@ def _parse_attrs(attrs_str: str) -> dict[str, str | None]: """Parse a space-separated HTML attribute string into a dict. Bare flags (no value) are stored as ``{key: None}``. + + Args: + attrs_str: The HTML attributes string to parse. + + Returns: + A dict mapping attribute names to values (or None for flags). + + Examples: + ```python + >>> _parse_attrs('identifier="foo.bar" optional hover') + {'identifier': 'foo.bar', 'optional': None, 'hover': None} + ``` """ result: dict[str, str | None] = {} for m in _ATTR_RE.finditer(attrs_str): - key = m.group("key") - value = m.group("value") # None when flag-only - result[key] = value + result[m.group("key")] = m.group("value") return result @@ -82,6 +95,12 @@ def _relative_url(from_page: str, to_url: str) -> str: Returns: A relative URL string. + + Examples: + ```python + >>> _relative_url("/reference/iter/", "/reference/seq/#pyochain._iter.Seq") + '../seq#pyochain._iter.Seq' + ``` """ to_url_no_anchor, *anchor_parts = to_url.split("#", 1) anchor = anchor_parts[0] if anchor_parts else "" @@ -89,17 +108,33 @@ def _relative_url(from_page: str, to_url: str) -> str: parts_a = from_page.strip("/").split("/") parts_b = to_url_no_anchor.strip("/").split("/") - # Remove common prefix segments. while parts_a and parts_b and parts_a[0] == parts_b[0]: parts_a.pop(0) parts_b.pop(0) - ups = len(parts_a) - relative_parts = [".."] * ups + parts_b - relative = "/".join(relative_parts) or "." - if anchor: - return f"{relative}#{anchor}" - return relative + relative = "/".join([".."] * len(parts_a) + parts_b) or "." + return f"{relative}#{anchor}" if anchor else relative + + +def _get_page_url(html_file: Path, site_dir: Path) -> str: + """Return the absolute-root URL for the page at *html_file*. + + Args: + html_file: Path to the HTML file. + site_dir: Root of the built site. + + Returns: + An absolute URL string ending with ``/``. + + Examples: + ```python + >>> _get_page_url(Path("site/reference/iter/index.html"), Path("site")) + '/reference/iter/' + ``` + """ + rel = html_file.parent.relative_to(site_dir) + page_url = "/" + str(rel).replace("\\", "/").strip("/") + return page_url + "/" if page_url != "/" else page_url # --------------------------------------------------------------------------- @@ -107,40 +142,108 @@ def _relative_url(from_page: str, to_url: str) -> str: # --------------------------------------------------------------------------- -def _build_anchor_map(site_dir: Path) -> dict[str, str]: - """Return a mapping of anchor identifier → absolute-root URL. +def _file_anchors(html_file: Path, site_dir: Path) -> pc.Iter[tuple[str, str]]: + """Yield ``(anchor_id, absolute_url)`` pairs for all anchors in *html_file*. - Also adds aliases for re-exported names so that e.g. - ``pyochain.traits.PyoIterator`` resolves even though the anchor in the - HTML carries the canonical module path - ``pyochain.traits._iterable.PyoIterator``. + Args: + html_file: Path to the HTML file to scan. + site_dir: Root of the built site. + + Returns: + An iterator of ``(anchor_id, url)`` pairs. + + Examples: + ```python + >>> # Each anchor id found in the file is mapped to its full URL. + >>> pairs = _file_anchors(Path("site/reference/iter/index.html"), Path("site")) + ``` + """ + page_url = _get_page_url(html_file, site_dir) + content = html_file.read_text(encoding="utf-8") + return pc.Iter(_ID_RE.findall(content)).map( + lambda anchor_id: (anchor_id, f"{page_url}#{anchor_id}") + ) + + +def _class_alias( + module_path: str, name: str, obj: type +) -> pc.Option[tuple[str, str]]: + """Return ``Some((alias_id, canonical_id))`` if the class is re-exported. + + Returns ``NONE`` when the alias and canonical paths are identical. Args: - site_dir: The root of the built site. + module_path: The module where the class is re-exported (e.g. ``pyochain.traits``). + name: The public name under which the class is available. + obj: The class object. Returns: - A dict mapping anchor ``id`` values to their absolute URLs - (e.g. ``/reference/iter/#pyochain._iter.Iter``). + An option containing ``(alias_id, canonical_id)`` or ``NONE``. + + Examples: + ```python + >>> import pyochain.traits as t + >>> _class_alias("pyochain.traits", "PyoIterator", t.PyoIterator) + Some(('pyochain.traits.PyoIterator', 'pyochain.traits._iterable.PyoIterator')) + ``` """ - anchor_map: dict[str, str] = {} + canonical_mod = getattr(obj, "__module__", None) or "" + if canonical_mod == "builtins": + canonical_mod = "pyochain.rs" + canonical_id = f"{canonical_mod}.{obj.__qualname__}" + alias_id = f"{module_path}.{name}" + return pc.NONE if alias_id == canonical_id else pc.Some((alias_id, canonical_id)) - for html_file in sorted(site_dir.rglob("index.html")): - rel = html_file.parent.relative_to(site_dir) - # Convert to a URL path, e.g. "reference/iter" → "/reference/iter/" - page_url = "/" + str(rel).replace("\\", "/").strip("/") - if page_url != "/": - page_url += "/" - content = html_file.read_text(encoding="utf-8") - for anchor_id in _ID_RE.findall(content): - full_url = f"{page_url}#{anchor_id}" - # First registration wins (keeps stable ordering). - anchor_map.setdefault(anchor_id, full_url) +def _module_aliases(module_path: str, module: object) -> pc.Iter[tuple[str, str]]: + """Yield ``(alias_id, canonical_id)`` pairs for re-exported classes. - # Add aliases for re-exported names discovered by inspecting the package. - _add_reexport_aliases(anchor_map) + Args: + module_path: The dotted Python path of *module*. + module: The module object to inspect. + + Returns: + An iterator of ``(alias_id, canonical_id)`` pairs. + + Examples: + ```python + >>> import pyochain.traits as t + >>> list(_module_aliases("pyochain.traits", t)) # doctest: +ELLIPSIS + [('pyochain.traits.Checkable', 'pyochain.rs.Checkable'), ...] + ``` + """ + public_names = getattr(module, "__all__", None) + if public_names is None: + return pc.Iter([]) + return ( + pc.Iter(public_names) + .map(lambda name: (name, getattr(module, name, None))) + .filter_star(lambda _name, obj: isinstance(obj, type)) + .map_star(lambda name, obj: _class_alias(module_path, name, obj)) + .filter_map(lambda opt: opt) + ) - return anchor_map + +def _try_import_module(attr: str) -> pc.Option[tuple[str, object]]: + """Attempt to import ``pyochain.<attr>`` and return ``Some((path, mod))``. + + Args: + attr: Attribute name under the ``pyochain`` namespace. + + Returns: + ``Some((module_path, module))`` on success, ``NONE`` on import error. + + Examples: + ```python + >>> _try_import_module("traits") + Some(('pyochain.traits', <module 'pyochain.traits' ...>)) + ``` + """ + try: + mod = importlib.import_module(f"pyochain.{attr}") + return pc.Some((f"pyochain.{attr}", mod)) + except (ImportError, ModuleNotFoundError): + return pc.NONE def _add_reexport_aliases(anchor_map: dict[str, str]) -> None: @@ -149,43 +252,64 @@ def _add_reexport_aliases(anchor_map: dict[str, str]) -> None: For every module ``M`` that re-exports a class ``C`` originally defined in ``M._sub`` (or ``pyochain.rs``), we add the entry ``M.C → anchor_map["M._sub.C"]`` so that cross-references using the - public path are resolved correctly. + public path resolve correctly. Args: anchor_map: The anchor map to extend in-place. + + Examples: + ```python + >>> m: dict[str, str] = {"pyochain.traits._iterable.PyoIterator": "/reference/pyoiterator/"} + >>> _add_reexport_aliases(m) + >>> "pyochain.traits.PyoIterator" in m + True + ``` """ try: import pyochain # noqa: PLC0415 except ImportError: - return # Can't introspect; skip silently. - - # Collect all pyochain (sub-)modules to inspect. - modules_to_inspect: list[tuple[str, object]] = [] - for attr in dir(pyochain): - try: - mod = importlib.import_module(f"pyochain.{attr}") - modules_to_inspect.append((f"pyochain.{attr}", mod)) - except (ImportError, ModuleNotFoundError): - pass - - for module_path, module in modules_to_inspect: - public_names = getattr(module, "__all__", None) - if public_names is None: - continue - for name in public_names: - obj = getattr(module, name, None) - if obj is None or not isinstance(obj, type): - continue - canonical_mod = getattr(obj, "__module__", None) - if canonical_mod is None: - continue - # Normalise: builtins → pyochain.rs (Rust extension types). - if canonical_mod == "builtins": - canonical_mod = "pyochain.rs" - canonical_id = f"{canonical_mod}.{obj.__qualname__}" - alias_id = f"{module_path}.{name}" - if alias_id != canonical_id and canonical_id in anchor_map: - anchor_map.setdefault(alias_id, anchor_map[canonical_id]) + return + + ( + pc.Iter(dir(pyochain)) + .filter_map(_try_import_module) + .flat_map(lambda t: _module_aliases(t[0], t[1])) + .filter_star(lambda _alias, canonical: canonical in anchor_map) + .for_each_star( + lambda alias, canonical: anchor_map.setdefault(alias, anchor_map[canonical]) + ) + ) + + +def _build_anchor_map(site_dir: Path) -> dict[str, str]: + """Return a mapping of anchor identifier → absolute-root URL. + + Also adds aliases for re-exported names so that e.g. + ``pyochain.traits.PyoIterator`` resolves even though the anchor in the + HTML carries the canonical module path + ``pyochain.traits._iterable.PyoIterator``. + + Args: + site_dir: The root of the built site. + + Returns: + A dict mapping anchor ``id`` values to their absolute URLs + (e.g. ``/reference/iter/#pyochain._iter.Iter``). + + Examples: + ```python + >>> # Returns a non-empty dict after scanning the built site. + >>> anchor_map = _build_anchor_map(Path("site")) + ``` + """ + anchor_map: dict[str, str] = {} + ( + pc.Iter(sorted(site_dir.rglob("index.html"))) + .flat_map(lambda f: _file_anchors(f, site_dir)) + .for_each_star(lambda k, v: anchor_map.setdefault(k, v)) + ) + _add_reexport_aliases(anchor_map) + return anchor_map # --------------------------------------------------------------------------- @@ -197,7 +321,7 @@ def _make_replacer( anchor_map: dict[str, str], page_url: str, ) -> Callable[[re.Match[str]], str]: - """Return a replacement callable for ``re.sub``. + """Return a replacement callable for :func:`re.sub`. Args: anchor_map: Mapping built by :func:`_build_anchor_map`. @@ -205,6 +329,15 @@ def _make_replacer( Returns: A function suitable as the ``repl`` argument of ``re.sub``. + + Examples: + ```python + >>> replacer = _make_replacer({"foo.Bar": "/reference/bar/#foo.Bar"}, "/") + >>> import re + >>> m = re.match(r'<autoref (?P<attrs>[^>]*?)>(?P<title>.*?)</autoref>', '<autoref identifier="foo.Bar">Bar</autoref>', re.DOTALL) + >>> replacer(m) + '<a class="autorefs autorefs-internal" href="reference/bar#foo.Bar">Bar</a>' + ``` """ def _replace(match: re.Match[str]) -> str: @@ -222,16 +355,15 @@ def _replace(match: re.Match[str]) -> str: stripped = _GENERIC_STRIP_RE.sub("", identifier) target_url = anchor_map.get(stripped) if target_url is None and "." in identifier: - parent = identifier.rsplit(".", 1)[0] - target_url = anchor_map.get(parent) + target_url = anchor_map.get(identifier.rsplit(".", 1)[0]) if target_url is None: - # Render as a <span> with the identifier as tooltip. - # Optional refs are silently unresolved; non-optional ones emit a warning. if not optional: - print( - f"WARNING: unresolved cross-reference in {page_url}: {identifier!r}", - file=sys.stderr, + rich.print( + rich.text.Text( + f"WARNING: unresolved cross-reference in {page_url}: {identifier!r}", + style="yellow", + ) ) return f'<span title="{escape(identifier)}">{title}</span>' @@ -254,18 +386,20 @@ def _fix_file(html_file: Path, anchor_map: dict[str, str], site_dir: Path) -> in Returns: The number of ``<autoref>`` tags that were replaced. + + Examples: + ```python + >>> # Returns 0 for a file with no <autoref> tags. + >>> _fix_file(Path("site/index.html"), {}, Path("site")) + 0 + ``` """ content = html_file.read_text(encoding="utf-8") if "<autoref " not in content: return 0 - rel = html_file.parent.relative_to(site_dir) - page_url = "/" + str(rel).replace("\\", "/").strip("/") - if page_url != "/": - page_url += "/" - - replacer = _make_replacer(anchor_map, page_url) - new_content, count = _AUTOREF_RE.subn(replacer, content) + page_url = _get_page_url(html_file, site_dir) + new_content, count = _AUTOREF_RE.subn(_make_replacer(anchor_map, page_url), content) if count: html_file.write_text(new_content, encoding="utf-8") return count @@ -281,27 +415,35 @@ def main(site_dir: Path | None = None) -> None: Args: site_dir: Root of the built site. Defaults to ``./site``. + + Examples: + ```python + >>> main(Path("site")) + ``` """ if site_dir is None: site_dir = Path("site") if not site_dir.is_dir(): - print(f"Site directory not found: {site_dir}", file=sys.stderr) + rich.print(rich.text.Text(f"Site directory not found: {site_dir}", style="red")) sys.exit(1) - print("Building anchor map…") + rich.print(rich.text.Text("Building anchor map…", style="cyan")) anchor_map = _build_anchor_map(site_dir) - print(f" Found {len(anchor_map)} anchors.") - - total = 0 - files = 0 - for html_file in sorted(site_dir.rglob("index.html")): - replaced = _fix_file(html_file, anchor_map, site_dir) - if replaced: - files += 1 - total += replaced - - print(f"Resolved {total} cross-reference(s) across {files} file(s).") + rich.print(f" Found {len(anchor_map)} anchors.") + + counts = ( + pc.Iter(sorted(site_dir.rglob("index.html"))) + .map(lambda f: _fix_file(f, anchor_map, site_dir)) + .filter(lambda n: n > 0) + .collect() + ) + rich.print( + rich.text.Text( + f"Resolved {counts.sum()} cross-reference(s) across {counts.length()} file(s).", + style="green", + ) + ) if __name__ == "__main__": From b7b188602ee40a12899e3d18c77b08922f0cbb09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:34:04 +0000 Subject: [PATCH 7/7] Address code review: restore intermediate variables, fix docstring ref syntax, improve lambda readability, rename counts variable Agent-Logs-Url: https://github.com/OutSquareCapital/pyochain/sessions/22463733-e40a-47a5-9090-ca23ca661686 Co-authored-by: OutSquareCapital <166045166+OutSquareCapital@users.noreply.github.com> --- scripts/fix_autorefs.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/fix_autorefs.py b/scripts/fix_autorefs.py index bbd6886..b6b0cd4 100644 --- a/scripts/fix_autorefs.py +++ b/scripts/fix_autorefs.py @@ -79,7 +79,9 @@ def _parse_attrs(attrs_str: str) -> dict[str, str | None]: """ result: dict[str, str | None] = {} for m in _ATTR_RE.finditer(attrs_str): - result[m.group("key")] = m.group("value") + key = m.group("key") + value = m.group("value") # None when flag-only + result[key] = value return result @@ -273,7 +275,7 @@ def _add_reexport_aliases(anchor_map: dict[str, str]) -> None: ( pc.Iter(dir(pyochain)) .filter_map(_try_import_module) - .flat_map(lambda t: _module_aliases(t[0], t[1])) + .flat_map(lambda module_info: _module_aliases(*module_info)) .filter_star(lambda _alias, canonical: canonical in anchor_map) .for_each_star( lambda alias, canonical: anchor_map.setdefault(alias, anchor_map[canonical]) @@ -321,7 +323,7 @@ def _make_replacer( anchor_map: dict[str, str], page_url: str, ) -> Callable[[re.Match[str]], str]: - """Return a replacement callable for :func:`re.sub`. + """Return a replacement callable for ``re.sub``. Args: anchor_map: Mapping built by :func:`_build_anchor_map`. @@ -432,7 +434,7 @@ def main(site_dir: Path | None = None) -> None: anchor_map = _build_anchor_map(site_dir) rich.print(f" Found {len(anchor_map)} anchors.") - counts = ( + replaced_counts = ( pc.Iter(sorted(site_dir.rglob("index.html"))) .map(lambda f: _fix_file(f, anchor_map, site_dir)) .filter(lambda n: n > 0) @@ -440,7 +442,7 @@ def main(site_dir: Path | None = None) -> None: ) rich.print( rich.text.Text( - f"Resolved {counts.sum()} cross-reference(s) across {counts.length()} file(s).", + f"Resolved {replaced_counts.sum()} cross-reference(s) across {replaced_counts.length()} file(s).", style="green", ) )