diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
index aeaf1f0..21a3658 100644
--- a/.github/workflows/publish-docs.yml
+++ b/.github/workflows/publish-docs.yml
@@ -3,8 +3,7 @@ name: publish-docs
on:
push:
branches:
- - master
- - main
+ - "**"
workflow_dispatch:
permissions:
@@ -13,7 +12,7 @@ permissions:
id-token: write
concurrency:
- group: pages
+ group: pages-${{ github.ref }}
cancel-in-progress: true
jobs:
@@ -32,34 +31,75 @@ jobs:
with:
python-version: "3.11"
- - name: Install package
+ - name: Install package and docs tooling
run: |
python -m pip install --upgrade pip setuptools wheel
- python -m pip install -e ".[dev]"
+ python -m pip install mkpages
+ python -m pip install -e ".[dev,docs]"
- name: Generate benchmark report
run: |
python scripts/benchmark.py --json benchmark.json > benchmark.md
- - name: Build Jekyll source tree
+ - name: Stage mkpages content tree
run: |
- python scripts/build_pages_site.py \
- --output _site_src \
+ python scripts/stage_mkpages_site.py \
+ --output .mkpages-src \
--benchmark-summary benchmark.md \
--benchmark-json benchmark.json
+ - name: Build mkpages source tree
+ run: |
+ mkpages build .mkpages-src --output .mkpages
+
+ - name: Build Sphinx API docs
+ run: |
+ sphinx-build -M dirhtml sphinx .sphinx
+
+ - name: Merge Sphinx output into mkpages tree
+ run: |
+ cp -R .sphinx/dirhtml/api .mkpages/api
+ cp -R .sphinx/dirhtml/_static .mkpages/_static
+ cp -R .sphinx/dirhtml/genindex .mkpages/genindex
+ cp -R .sphinx/dirhtml/py-modindex .mkpages/py-modindex
+ cp -R .sphinx/dirhtml/search .mkpages/search
+ cp .sphinx/dirhtml/objects.inv .mkpages/objects.inv
+ cp .sphinx/dirhtml/searchindex.js .mkpages/searchindex.js
+
+ - name: Allow Sphinx static assets through Jekyll
+ run: |
+ printf '\ninclude:\n - _static\n' >> .mkpages/_config.yml
+
- name: Build site with Jekyll
uses: actions/jekyll-build-pages@v1
with:
- source: _site_src
+ source: .mkpages
destination: _site
+ - name: Verify published inventory
+ run: test -f _site/objects.inv
+
+ - name: Prepare artifact name
+ id: artifact_name
+ shell: bash
+ run: |
+ safe_ref=$(printf '%s' "${GITHUB_REF_NAME}" | tr '/\\:*?\"<>|\r\n' '-')
+ echo "value=docs-site-${safe_ref}" >> "$GITHUB_OUTPUT"
+
+ - name: Upload downloadable site artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ steps.artifact_name.outputs.value }}
+ path: _site
+
- name: Upload Pages artifact
+ if: github.ref_name == 'main' || github.ref_name == 'master'
uses: actions/upload-pages-artifact@v3
with:
path: _site
deploy:
+ if: github.ref_name == 'main' || github.ref_name == 'master'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
diff --git a/.gitignore b/.gitignore
index e2de1e8..80d1e76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,5 @@ pyseq.egg-info/
default.env
.codex
.agents/
-.pytest_cache/
\ No newline at end of file
+.pytest_cache/
+.mkpages/
\ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
index ed6287a..6e6fcc2 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -14,4 +14,5 @@ Available guides:
If you are evaluating pyseq for pipeline use, start with the examples guide and
then review the CLI reference for the sequence-aware utilities included with
-the package.
+the package. For generated Python API details, see the [Sphinx API
+reference](/api/).
diff --git a/docs/examples.md b/docs/examples.md
index 98fd80a..d199ac5 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -3,6 +3,8 @@
This guide expands on the examples in the main [README](../README.md) and
shows a few common ways pyseq is used in production scripts and tools.
+For the generated Python API reference, see [Sphinx API reference](/api/).
+
## Parse a List of Frames into a Sequence
```python
diff --git a/docs/index.md b/docs/index.md
index 1c7157a..2ca551d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,5 +1,5 @@
-
+
pyseq is a **Python library for detecting, parsing, and formatting numbered
@@ -127,7 +127,8 @@ Expected output:
## Learn More
-- [PySeq Docs](README.md): docs overview
+- [API Reference](/api/): generated Python module reference
+- [PySeq Docs](/docs/): docs overview
- [Examples](examples.md): Python and CLI usage patterns
- [CLI Tools Reference](cli-tools.md): bundled sequence-aware utilities
- [Formatting Reference](formatting.md): supported format directives
diff --git a/docs/mkpages.yml b/docs/mkpages.yml
new file mode 100644
index 0000000..2b239f1
--- /dev/null
+++ b/docs/mkpages.yml
@@ -0,0 +1,17 @@
+title: pyseq
+description: Python library for numbered file sequences.
+navigation:
+ - label: Home
+ href: /
+ - label: Usage
+ href: /docs/cli-tools/
+ - label: Examples
+ href: /docs/examples/
+ - label: Formatting
+ href: /docs/formatting/
+ - label: Performance
+ href: /docs/performance/
+ - label: API
+ href: /api/
+ - label: GitHub
+ href: https://github.com/rsgalloway/pyseq
diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py
index 3f4f23c..c5919c9 100755
--- a/lib/pyseq/seq.py
+++ b/lib/pyseq/seq.py
@@ -101,10 +101,10 @@ def padsize(item, frame):
For example: the file item.001.exr will have a pad size of 3, and the
file test.001001.exr will have a pad size of 6.
- :param item: Item object.
Signed frames use the digit width only; the leading ``-`` does not
contribute to the padding width.
+ :param item: Item object.
:param frame: The frame number token as a string.
:returns: The size of the frame pad as an int.
"""
@@ -777,6 +777,7 @@ def append(self, item: Item, check_membership: bool = True):
:param item: pyseq.Item object.
:param check_membership: Check if `item` is a member. Can be useful if
membership is checked prior to appending.
+
:exc:`SequenceError` raised if item is not a sequence member.
"""
diff --git a/pyproject.toml b/pyproject.toml
index 8b57bd4..9520baf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,7 +40,21 @@ classifiers = [
authors = [
{ name = "Ryan Galloway", email = "ryan@rsgalloway.com" },
]
-optional-dependencies = { dev = ["pytest", "flake8==7.1.1", "mccabe==0.7.0", "isort==5.13.2", "black==24.8.0"], test = ["pytest"] }
+
+[project.optional-dependencies]
+dev = [
+ "pytest",
+ "flake8==7.1.1",
+ "mccabe==0.7.0",
+ "isort==5.13.2",
+ "black==24.8.0",
+]
+docs = [
+ "Sphinx>=8,<9",
+]
+test = [
+ "pytest",
+]
[tool.isort]
profile = "black"
diff --git a/scripts/build_pages_site.py b/scripts/build_pages_site.py
deleted file mode 100644
index fa6c073..0000000
--- a/scripts/build_pages_site.py
+++ /dev/null
@@ -1,391 +0,0 @@
-#!/usr/bin/env python3
-#
-# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com)
-#
-
-"""Build a simple Jekyll-friendly docs site from repository markdown files."""
-
-import argparse
-import re
-import shutil
-from pathlib import Path
-from typing import Optional
-
-LINK_PATTERNS = (
- (r"\(README\.md\)", "(index.html)"),
- (r"\(docs/README\.md\)", "(docs/index.html)"),
- (r"\(docs/([^)]+)\.md\)", r"(docs/\1.html)"),
- (r"\(([^:)#]+)\.md\)", r"(\1.html)"),
-)
-
-
-def rewrite_links(content: str) -> str:
- """Rewrite local markdown links for generated HTML output."""
- updated = content
- for pattern, replacement in LINK_PATTERNS:
- updated = re.sub(pattern, replacement, updated)
- return updated
-
-
-def extract_title(content: str, fallback: str) -> str:
- """Extract the first markdown H1 title or use a fallback."""
- for line in content.splitlines():
- if line.startswith("# "):
- return line[2:].strip()
- return fallback
-
-
-def wrap_markdown(content: str, title: str) -> str:
- """Add minimal Jekyll front matter to markdown content."""
- return f"---\nlayout: default\ntitle: {title}\n---\n\n{content}"
-
-
-def write_markdown_page(src: Path, dst: Path, fallback_title: str):
- """Copy a markdown file into the site tree with front matter and fixed links."""
- content = src.read_text(encoding="utf-8")
- title = extract_title(content, fallback_title)
- content = rewrite_links(content)
- dst.parent.mkdir(parents=True, exist_ok=True)
- dst.write_text(wrap_markdown(content, title), encoding="utf-8")
-
-
-def write_site_config(output_dir: Path):
- """Write a minimal Jekyll config file."""
- config = """title: pyseq
-description: Python library for numbered file sequences
-markdown: kramdown
-permalink: pretty
-"""
- (output_dir / "_config.yml").write_text(config, encoding="utf-8")
-
-
-def write_layout(output_dir: Path):
- """Write the shared Jekyll layout used by the generated docs site."""
- layout_dir = output_dir / "_layouts"
- layout_dir.mkdir(parents=True, exist_ok=True)
- template = """
-
-
-
-
- {% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}
-
-
-
-
-
-
-
- {{ content }}
-
-
-
-
-"""
- (layout_dir / "default.html").write_text(template, encoding="utf-8")
-
-
-def write_stylesheet(output_dir: Path):
- """Write a minimal light stylesheet for the generated docs site."""
- assets_dir = output_dir / "assets"
- assets_dir.mkdir(parents=True, exist_ok=True)
- css = """:root {
- --bg: #ffffff;
- --panel: #ffffff;
- --border: #d9e2ec;
- --text: #102033;
- --muted: #516172;
- --accent: #0fba74;
- --accent-dark: #0b7f55;
- --code: #f4f8fb;
-}
-
-* { box-sizing: border-box; }
-
-html, body {
- margin: 0;
- padding: 0;
- background: var(--bg);
- color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- line-height: 1.7;
-}
-
-a {
- color: var(--accent-dark);
- text-decoration: none;
-}
-
-a:hover {
- color: var(--accent);
-}
-
-.site-shell {
- max-width: 1040px;
- margin: 0 auto;
- padding: 24px 24px 72px;
-}
-
-.site-header {
- display: flex;
- flex-wrap: wrap;
- gap: 16px 24px;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 36px;
-}
-
-.site-brand {
- color: var(--text);
- font-size: 0.98rem;
- font-weight: 700;
- letter-spacing: 0.01em;
-}
-
-.site-nav {
- display: flex;
- flex-wrap: wrap;
- gap: 16px;
-}
-
-.site-nav a {
- color: var(--muted);
- font-size: 0.95rem;
-}
-
-.site-nav a:hover {
- color: var(--text);
-}
-
-.site-main {
- background: transparent;
-}
-
-.site-main h1:first-child,
-.site-main p:first-child img {
- margin-top: 0;
-}
-
-h1, h2, h3 {
- color: var(--text);
- line-height: 1.15;
-}
-
-h1 {
- font-size: 2.7rem;
- margin: 0 0 1rem;
-}
-
-h2 {
- font-size: 1.5rem;
- margin-top: 2.5rem;
-}
-
-h3 {
- font-size: 1.08rem;
- margin-top: 1.5rem;
-}
-
-p, li {
- font-size: 1.02rem;
-}
-
-code, pre {
- font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
-}
-
-code {
- background: var(--code);
- border: 1px solid #e4ebf2;
- border-radius: 8px;
- padding: 0.12rem 0.4rem;
-}
-
-pre {
- background: var(--code);
- border: 1px solid var(--border);
- border-radius: 16px;
- overflow-x: auto;
- padding: 18px 20px;
-}
-
-pre code {
- background: transparent;
- border: 0;
- padding: 0;
-}
-
-img {
- max-width: 100%;
- height: auto;
-}
-
-blockquote {
- border-left: 4px solid #b8c6d6;
- color: var(--muted);
- margin: 1.5rem 0;
- padding-left: 1rem;
-}
-
-table {
- border-collapse: collapse;
- width: 100%;
-}
-
-th, td {
- border: 1px solid var(--border);
- padding: 0.7rem 0.8rem;
- text-align: left;
-}
-
-th {
- background: #f2f6fa;
-}
-
-@media (max-width: 720px) {
- .site-shell {
- padding: 18px 16px 56px;
- }
-
- .site-header {
- align-items: flex-start;
- margin-bottom: 28px;
- }
-
- .site-nav {
- gap: 12px;
- }
-
- h1 {
- font-size: 2.15rem;
- }
-}
-"""
- (assets_dir / "site.css").write_text(css, encoding="utf-8")
-
-
-def append_benchmark_section(
- content: str,
- benchmark_summary: Optional[Path] = None,
- benchmark_json: Optional[Path] = None,
-) -> str:
- """Append the latest benchmark summary to the performance document."""
- sections = [content.rstrip(), "", "## Latest Benchmarks", ""]
-
- if benchmark_summary and benchmark_summary.exists():
- sections.extend(
- [
- "This section is generated automatically by the docs publishing workflow,",
- "which runs `scripts/benchmark.py` on the current `master` branch before",
- "building the Pages site.",
- "",
- benchmark_summary.read_text(encoding="utf-8").strip(),
- "",
- ]
- )
- else:
- sections.extend(
- [
- "This section is generated automatically by the docs publishing workflow",
- "when benchmark artifacts are available.",
- "",
- ]
- )
-
- if benchmark_json and benchmark_json.exists():
- sections.extend(["### Downloads", "", "- [Benchmark JSON](../assets/benchmark.json)", ""])
-
- return "\n".join(sections).rstrip() + "\n"
-
-
-def copy_docs_assets(docs_dir: Path, output_dir: Path):
- """Copy docs assets to both root assets/ and docs/assets/ for relative links."""
- assets_src = docs_dir / "assets"
- if not assets_src.exists():
- return
-
- root_assets = output_dir / "assets"
- docs_assets = output_dir / "docs" / "assets"
- root_assets.mkdir(parents=True, exist_ok=True)
- docs_assets.mkdir(parents=True, exist_ok=True)
-
- for src in assets_src.iterdir():
- if src.is_file():
- shutil.copy2(src, root_assets / src.name)
- shutil.copy2(src, docs_assets / src.name)
-
-
-def build_site(args):
- repo_root = Path(args.repo_root).resolve()
- output_dir = Path(args.output).resolve()
-
- if output_dir.exists():
- shutil.rmtree(output_dir)
- output_dir.mkdir(parents=True, exist_ok=True)
-
- write_site_config(output_dir)
- write_layout(output_dir)
- write_stylesheet(output_dir)
-
- # Home page from docs/index.md.
- write_markdown_page(repo_root / "docs" / "index.md", output_dir / "index.md", "pyseq")
-
- benchmark_summary = Path(args.benchmark_summary) if args.benchmark_summary else None
- benchmark_json = Path(args.benchmark_json) if args.benchmark_json else None
-
- # Docs pages.
- docs_dir = repo_root / "docs"
- for src in docs_dir.glob("*.md"):
- if src.name == "index.md":
- continue
- if src.name == "README.md":
- dst = output_dir / "docs" / "index.md"
- fallback = "Docs"
- else:
- dst = output_dir / "docs" / src.name
- fallback = src.stem.replace("-", " ").title()
- if src.name == "performance.md":
- content = src.read_text(encoding="utf-8")
- content = append_benchmark_section(content, benchmark_summary, benchmark_json)
- content = rewrite_links(content)
- title = extract_title(content, fallback)
- dst.parent.mkdir(parents=True, exist_ok=True)
- dst.write_text(wrap_markdown(content, title), encoding="utf-8")
- else:
- write_markdown_page(src, dst, fallback)
-
- copy_docs_assets(docs_dir, output_dir)
-
- if benchmark_json and benchmark_json.exists():
- shutil.copy2(benchmark_json, output_dir / "assets" / "benchmark.json")
-
- cname = repo_root / "CNAME"
- if cname.exists():
- shutil.copy2(cname, output_dir / "CNAME")
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--repo-root", default=".")
- parser.add_argument("--output", required=True)
- parser.add_argument("--benchmark-summary")
- parser.add_argument("--benchmark-json")
- args = parser.parse_args()
- build_site(args)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/stage_mkpages_site.py b/scripts/stage_mkpages_site.py
new file mode 100644
index 0000000..8eb17d0
--- /dev/null
+++ b/scripts/stage_mkpages_site.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com)
+#
+
+"""Stage repository docs into the layout expected by the mkpages Pages build."""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+from pathlib import Path
+from typing import Optional
+
+
+def copy_text(src: Path, dst: Path) -> None:
+ """Copy one text file, creating parent directories as needed."""
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
+
+
+def copy_tree(src: Path, dst: Path) -> None:
+ """Copy one directory tree if it exists."""
+ if not src.exists():
+ return
+ shutil.copytree(src, dst, dirs_exist_ok=True)
+
+
+def append_benchmark_section(
+ content: str,
+ benchmark_summary: Optional[Path] = None,
+ benchmark_json: Optional[Path] = None,
+) -> str:
+ """Append the latest benchmark summary to the performance guide."""
+ sections = [content.rstrip(), "", "## Latest Benchmarks", ""]
+
+ if benchmark_summary and benchmark_summary.exists():
+ sections.extend(
+ [
+ "This section is generated automatically by the docs publishing workflow,",
+ "which runs `scripts/benchmark.py` for the current workflow ref before",
+ "building the Pages site.",
+ "",
+ benchmark_summary.read_text(encoding="utf-8").strip(),
+ "",
+ ]
+ )
+ else:
+ sections.extend(
+ [
+ "This section is generated automatically by the docs publishing workflow",
+ "when benchmark artifacts are available.",
+ "",
+ ]
+ )
+
+ if benchmark_json and benchmark_json.exists():
+ sections.extend(["### Downloads", "", "- [Benchmark JSON](../assets/benchmark.json)", ""])
+
+ return "\n".join(sections).rstrip() + "\n"
+
+
+def stage_site(
+ repo_root: Path,
+ output_dir: Path,
+ benchmark_summary: Optional[Path] = None,
+ benchmark_json: Optional[Path] = None,
+) -> None:
+ """Arrange docs so mkpages can preserve the existing published URLs."""
+ docs_dir = repo_root / "docs"
+
+ if output_dir.exists():
+ shutil.rmtree(output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ copy_text(docs_dir / "mkpages.yml", output_dir / "mkpages.yml")
+ copy_text(repo_root / "README.md", output_dir / "README.md")
+ copy_text(docs_dir / "index.md", output_dir / "index.md")
+ copy_text(docs_dir / "README.md", output_dir / "docs" / "index.md")
+
+ for src in docs_dir.glob("*.md"):
+ if src.name in {"index.md", "README.md"}:
+ continue
+ dst = output_dir / "docs" / src.name
+ if src.name == "performance.md":
+ content = append_benchmark_section(
+ src.read_text(encoding="utf-8"),
+ benchmark_summary=benchmark_summary,
+ benchmark_json=benchmark_json,
+ )
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ dst.write_text(content, encoding="utf-8")
+ continue
+ copy_text(src, dst)
+
+ copy_tree(docs_dir / "assets", output_dir / "assets")
+ copy_tree(docs_dir / "assets", output_dir / "docs" / "assets")
+
+ if benchmark_json and benchmark_json.exists():
+ copy_text(benchmark_json, output_dir / "assets" / "benchmark.json")
+ copy_text(benchmark_json, output_dir / "docs" / "assets" / "benchmark.json")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--repo-root", default=".")
+ parser.add_argument("--output", required=True)
+ parser.add_argument("--benchmark-summary")
+ parser.add_argument("--benchmark-json")
+ args = parser.parse_args()
+ stage_site(
+ Path(args.repo_root).resolve(),
+ Path(args.output).resolve(),
+ benchmark_summary=(
+ Path(args.benchmark_summary).resolve() if args.benchmark_summary else None
+ ),
+ benchmark_json=Path(args.benchmark_json).resolve() if args.benchmark_json else None,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sphinx/_static/custom.css b/sphinx/_static/custom.css
new file mode 100644
index 0000000..0b205fa
--- /dev/null
+++ b/sphinx/_static/custom.css
@@ -0,0 +1,243 @@
+:root {
+ --pyseq-bg: #f6f8fb;
+ --pyseq-surface: #ffffff;
+ --pyseq-surface-soft: #f1f5f9;
+ --pyseq-border: #d9e2ec;
+ --pyseq-text: #102033;
+ --pyseq-muted: #516172;
+ --pyseq-accent: #0fba74;
+ --pyseq-accent-dark: #0b7f55;
+ --pyseq-code: #edf3f8;
+}
+
+html,
+body {
+ background: linear-gradient(180deg, #eef4fa 0%, #f8fbfd 100%);
+ color: var(--pyseq-text);
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
+}
+
+body {
+ font-size: 17px;
+ line-height: 1.7;
+}
+
+div.document {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 32px 24px 72px;
+}
+
+div.documentwrapper,
+div.bodywrapper {
+ margin: 0;
+}
+
+div.documentwrapper {
+ margin-left: 312px;
+}
+
+div.body {
+ background: var(--pyseq-surface);
+ border: 1px solid var(--pyseq-border);
+ border-radius: 24px;
+ box-shadow: 0 18px 48px rgba(16, 32, 51, 0.08);
+ padding: 40px 48px 48px;
+}
+
+div.sphinxsidebar {
+ width: 280px;
+}
+
+div.sphinxsidebarwrapper {
+ background: rgba(255, 255, 255, 0.78);
+ border: 1px solid rgba(217, 226, 236, 0.9);
+ border-radius: 22px;
+ box-shadow: 0 12px 30px rgba(16, 32, 51, 0.06);
+ padding: 24px 22px;
+ position: sticky;
+ top: 24px;
+}
+
+div.sphinxsidebar h1.logo {
+ border-bottom: 1px solid var(--pyseq-border);
+ font-size: 1.4rem;
+ margin: 0 0 1rem;
+ padding-bottom: 0.9rem;
+}
+
+div.sphinxsidebar h1.logo a {
+ color: var(--pyseq-text);
+}
+
+div.sphinxsidebar h3,
+div.sphinxsidebar h4,
+div.sphinxsidebar p,
+div.sphinxsidebar ul,
+div.sphinxsidebar .caption {
+ color: var(--pyseq-muted);
+}
+
+div.sphinxsidebar a {
+ color: var(--pyseq-accent-dark);
+}
+
+div.sphinxsidebar a:hover,
+div.body a:hover {
+ color: var(--pyseq-accent);
+}
+
+div.body a,
+div.related a,
+div.footer a {
+ color: var(--pyseq-accent-dark);
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ color: var(--pyseq-text);
+ font-family: Georgia, "Times New Roman", serif;
+ letter-spacing: -0.015em;
+ line-height: 1.12;
+}
+
+h1 {
+ font-size: 3rem;
+ margin-top: 0;
+}
+
+h2 {
+ font-size: 1.9rem;
+ margin-top: 2.4rem;
+}
+
+h3 {
+ font-size: 1.35rem;
+ margin-top: 1.7rem;
+}
+
+p,
+li,
+dd,
+dt,
+dl,
+blockquote {
+ color: var(--pyseq-text);
+}
+
+blockquote {
+ border-left: 4px solid #b8c6d6;
+ margin: 1.4rem 0;
+ padding-left: 1rem;
+}
+
+code,
+tt,
+pre {
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+}
+
+code.literal,
+.rst-content code.literal,
+tt.descname,
+tt.descclassname {
+ background: var(--pyseq-code);
+ border: 1px solid #d9e5f0;
+ border-radius: 8px;
+ color: #16324a;
+ padding: 0.1rem 0.38rem;
+}
+
+div.highlight,
+pre {
+ background: #f7fafc;
+ border: 1px solid var(--pyseq-border);
+ border-radius: 18px;
+ overflow-x: auto;
+ padding: 18px 20px;
+}
+
+table.docutils,
+table.docutils td,
+table.docutils th {
+ border-color: var(--pyseq-border);
+}
+
+table.docutils th {
+ background: var(--pyseq-surface-soft);
+}
+
+dl.py.class,
+dl.py.function,
+dl.py.method,
+dl.py.exception,
+dl.py.property,
+dl.py.attribute {
+ background: #fbfdff;
+ border: 1px solid var(--pyseq-border);
+ border-radius: 18px;
+ margin: 1.25rem 0;
+ padding: 1rem 1.1rem;
+}
+
+dt.sig,
+dt.sig-object {
+ border-bottom: 1px solid #e7eef5;
+ margin-bottom: 0.8rem;
+ padding-bottom: 0.7rem;
+}
+
+.sig-name,
+.sig-prename,
+.sig-param-name {
+ color: #16324a;
+}
+
+.sig-return-typehint,
+.sig-param .n,
+.sig-param .p {
+ color: var(--pyseq-muted);
+}
+
+a.headerlink {
+ color: #8aa0b7;
+}
+
+a.headerlink:hover {
+ color: var(--pyseq-accent);
+}
+
+div.footer {
+ color: var(--pyseq-muted);
+ margin-top: 1.6rem;
+ padding: 0 24px 32px;
+ text-align: center;
+}
+
+@media (max-width: 980px) {
+ div.document {
+ padding: 18px 14px 48px;
+ }
+
+ div.documentwrapper {
+ margin-left: 0;
+ }
+
+ div.body {
+ padding: 28px 24px 30px;
+ }
+
+ div.sphinxsidebar {
+ float: none;
+ margin: 0 0 18px;
+ width: auto;
+ }
+
+ div.sphinxsidebarwrapper {
+ position: static;
+ }
+}
diff --git a/sphinx/api/index.rst b/sphinx/api/index.rst
new file mode 100644
index 0000000..831f487
--- /dev/null
+++ b/sphinx/api/index.rst
@@ -0,0 +1,12 @@
+API Reference
+=============
+
+The documented objects on this page are exported from ``pyseq`` and published
+under ``/api/`` so the root ``/objects.inv`` can resolve stable API URLs.
+
+.. automodule:: pyseq
+ :members:
+ :imported-members:
+ :exclude-members: deque, glob, iglob
+ :undoc-members:
+ :show-inheritance:
diff --git a/sphinx/conf.py b/sphinx/conf.py
new file mode 100644
index 0000000..ac742b7
--- /dev/null
+++ b/sphinx/conf.py
@@ -0,0 +1,45 @@
+"""Minimal Sphinx configuration for pyseq API docs and intersphinx inventory."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+LIB = ROOT / "lib"
+
+if str(LIB) not in sys.path:
+ sys.path.insert(0, str(LIB))
+
+import pyseq
+
+
+project = "pyseq"
+author = "Ryan Galloway"
+copyright = "2011-2026, Ryan Galloway"
+version = pyseq.__version__
+release = pyseq.__version__
+
+extensions = [
+ "sphinx.ext.autodoc",
+]
+
+templates_path = ["_templates"]
+exclude_patterns = ["_build"]
+
+autodoc_member_order = "bysource"
+
+html_theme = "alabaster"
+html_theme_options = {
+ "description": "Python library for numbered file sequences",
+ "fixed_sidebar": True,
+ "page_width": "1180px",
+ "sidebar_width": "280px",
+}
+html_title = f"pyseq {release} API"
+html_show_sourcelink = False
+html_copy_source = False
+html_static_path = ["_static"]
+html_css_files = ["custom.css"]
+
diff --git a/sphinx/index.rst b/sphinx/index.rst
new file mode 100644
index 0000000..e9b899a
--- /dev/null
+++ b/sphinx/index.rst
@@ -0,0 +1,11 @@
+pyseq API documentation
+=======================
+
+This Sphinx tree exists to publish pyseq's Python API reference and
+``objects.inv`` for Intersphinx consumers.
+
+.. toctree::
+ :maxdepth: 2
+
+ api/index
+