#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Public API for docs aggregation and retrieval across the SciTeX ecosystem.
Usage:
from scitex_dev import get_docs, build_docs
# All installed packages (manifest overview)
get_docs()
# Single package — returns unwrapped result
get_docs(package="scitex-writer", format="json")
# Multiple packages — dict keyed by package name
get_docs(packages=["scitex-writer", "scitex-stats"], format="json")
# Specific page
get_docs(package="scitex-writer", format="html", page="api")
# Build docs from Sphinx source
build_docs(package="scitex-writer")
build_docs() # all discovered packages
# Search across all packages
search_docs(query="save figure")
search_docs(query="statistics", packages=["scitex-stats"])
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any, Optional
from .._core.discovery import discover_packages, get_package_root, get_sphinx_source
logger = logging.getLogger(__name__)
[docs]
def get_docs(
package: Optional[str] = None,
packages: Optional[list[str]] = None,
format: Optional[str] = None,
page: Optional[str] = None,
*,
_discover_fn=None,
_root_fn=None,
_sphinx_fn=None,
) -> Any:
"""Get documentation for one, several, or all installed SciTeX packages.
Resolution chain per package:
1. Pre-built _sphinx_html/ in installed package → fastest (production)
2. Sphinx _build/ available → use existing build
3. Neither → introspect from docstrings + signatures (always works)
Args:
package: Single package name (returns unwrapped result).
packages: List of package names (returns dict keyed by name).
format: Output format — None for manifest, "json" for structured,
"html" for path to HTML directory.
page: Specific page name (only with format="html" or "json").
Returns:
- If package=: the doc result directly (unwrapped).
- If packages=: dict mapping package name → doc result.
- If neither: dict of all discovered packages → doc result.
Raises:
ValueError: If both package= and packages= are given.
LookupError: If a requested package is not found.
"""
if package is not None and packages is not None:
raise ValueError(
"Use either package= (singular) or packages= (plural), not both"
)
discover = _discover_fn if _discover_fn is not None else discover_packages
# Single package — unwrap
if package is not None:
return _get_one(
package,
format=format,
page=page,
_discover_fn=discover,
_root_fn=_root_fn,
_sphinx_fn=_sphinx_fn,
)
# Multiple or all packages
if packages is None:
discovered = discover()
packages = list(discovered.keys())
results = {}
for pkg in packages:
try:
results[pkg] = _get_one(
pkg,
format=format,
page=page,
_discover_fn=discover,
_root_fn=_root_fn,
_sphinx_fn=_sphinx_fn,
)
except LookupError:
logger.warning("Package %s not found, skipping", pkg)
return results
[docs]
def build_docs(
package: Optional[str] = None,
output_dir: Optional[Path] = None,
formats: Optional[list[str]] = None,
*,
_discover_fn=None,
_sphinx_fn=None,
) -> dict[str, Any]:
"""Build docs from Sphinx source for one or all packages.
Args:
package: Single package name. None = build all discovered.
output_dir: Override output directory. Default: in-place (_build/).
formats: List of builders ("html", "json"). Default: ["html"].
Returns:
Dict mapping package name → build result (path or error).
Raises:
LookupError: If package not found.
RuntimeError: If Sphinx is not installed.
"""
from .._core.builder import build_sphinx
if formats is None:
formats = ["html"]
discover = _discover_fn if _discover_fn is not None else discover_packages
sphinx = _sphinx_fn if _sphinx_fn is not None else get_sphinx_source
discovered = discover()
if package is not None:
targets = {package: discovered.get(package)}
if targets[package] is None:
raise LookupError(
f"Package '{package}' not found in scitex_dev.docs entry points. "
f"Available: {list(discovered.keys())}"
)
else:
targets = discovered
results = {}
for pkg_name, module_name in targets.items():
if module_name is None:
continue
sphinx_src = sphinx(module_name)
if sphinx_src is None:
results[pkg_name] = {"error": "No Sphinx source found"}
continue
pkg_results = {}
for fmt in formats:
try:
out = build_sphinx(
sphinx_src,
output_dir=(output_dir / fmt) if output_dir else None,
builder=fmt,
)
pkg_results[fmt] = str(out) if out else None
except (RuntimeError, FileNotFoundError) as e:
pkg_results[fmt] = {"error": str(e)}
results[pkg_name] = pkg_results
return results
[docs]
def search_docs(
query: str,
package: Optional[str] = None,
packages: Optional[list[str]] = None,
max_results: int = 10,
*,
_discover_fn=None,
_get_one_fn=None,
) -> list[dict[str, Any]]:
"""Search documentation across one, several, or all SciTeX packages.
Simple keyword search over page titles and introspected content.
Stdlib only — no external search dependencies.
Args:
query: Search query string (case-insensitive keyword matching).
package: Search within a single package.
packages: Search within specific packages.
max_results: Maximum number of results to return.
Returns:
List of dicts with keys: package, name, title, score, match_type.
Sorted by relevance score (descending).
"""
query_lower = query.lower()
query_terms = query_lower.split()
results = []
discover = _discover_fn if _discover_fn is not None else discover_packages
get_one = _get_one_fn if _get_one_fn is not None else _get_one
# Determine which packages to search
if package is not None:
search_targets = {package: None}
elif packages is not None:
search_targets = {p: None for p in packages}
else:
search_targets = discover()
for pkg_name in search_targets:
try:
manifest = get_one(pkg_name)
except LookupError:
continue
if not isinstance(manifest, dict):
continue
# Search page titles from manifest
for page_entry in manifest.get("pages", []):
if isinstance(page_entry, dict):
name = page_entry.get("name", "")
title = page_entry.get("title", "")
else:
name = str(page_entry)
title = name
text = f"{name} {title}".lower()
score = sum(1 for term in query_terms if term in text)
if score > 0:
results.append(
{
"package": pkg_name,
"name": name,
"title": title,
"score": score,
"match_type": "page_title",
}
)
# Search introspected module/function names
for member_name, info in manifest.get("modules", {}).items():
text = f"{member_name} {info.get('description', '')}".lower()
score = sum(1 for term in query_terms if term in text)
if score > 0:
results.append(
{
"package": pkg_name,
"name": member_name,
"title": info.get("description", "")[:80],
"score": score,
"match_type": "api_member",
}
)
# Search package description
desc = manifest.get("description", "").lower()
score = sum(1 for term in query_terms if term in desc)
if score > 0:
results.append(
{
"package": pkg_name,
"name": pkg_name,
"title": manifest.get("description", "")[:80],
"score": score,
"match_type": "package",
}
)
# Sort by score descending, then by name
results.sort(key=lambda r: (-r["score"], r["name"]))
return results[:max_results]
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _get_one(
package: str,
format: Optional[str] = None,
page: Optional[str] = None,
*,
_discover_fn=None,
_root_fn=None,
_sphinx_fn=None,
) -> Any:
"""Get docs for a single package, following the resolution chain."""
discover = _discover_fn if _discover_fn is not None else discover_packages
root_lookup = _root_fn if _root_fn is not None else get_package_root
sphinx_lookup = _sphinx_fn if _sphinx_fn is not None else get_sphinx_source
discovered = discover()
module_name = discovered.get(package)
if module_name is None:
raise LookupError(
f"Package '{package}' not found. Available: {list(discovered.keys())}"
)
# 1. Try pre-built _sphinx_html/
pkg_root = root_lookup(module_name)
if pkg_root is not None:
docs_dir = pkg_root / "_sphinx_html"
result = _resolve_from_built(docs_dir, format=format, page=page)
if result is not None:
return _enrich_manifest(result, package)
# 2. Try Sphinx _build/
sphinx_src = sphinx_lookup(module_name)
if sphinx_src is not None:
build_dir = sphinx_src / "_build"
result = _resolve_from_built(
build_dir / "html", format=format, page=page, json_dir=build_dir / "json"
)
if result is not None:
return _enrich_manifest(result, package)
# 3. Fallback: introspect
from .._core.introspect import introspect_package
return introspect_package(module_name)
def _resolve_from_built(
html_dir: Path,
format: Optional[str] = None,
page: Optional[str] = None,
json_dir: Optional[Path] = None,
) -> Any:
"""Try to resolve docs from a built docs directory.
Returns None if the directory doesn't exist or doesn't have the requested content.
"""
if not html_dir.exists():
return None
# If page requested but no format, default to html
if page is not None and format is None:
format = "html"
# No format requested → return manifest
if format is None:
from .._core.manifest import read_manifest, generate_manifest
manifest = read_manifest(html_dir)
if manifest is not None:
return manifest
# Auto-generate manifest from directory contents
return generate_manifest(
package=html_dir.parent.name,
docs_dir=html_dir,
)
# HTML format → return path
if format == "html":
if page is not None:
page_path = html_dir / f"{page}.html"
if not page_path.exists():
page_path = html_dir / page
if page_path.exists():
return page_path
return None
if (html_dir / "index.html").exists():
return html_dir
return None
# JSON format → try Sphinx JSON builder output or read HTML
if format == "json":
target_dir = json_dir if json_dir and json_dir.exists() else html_dir
if page is not None:
json_path = target_dir / f"{page}.fjson"
if json_path.exists():
with open(json_path, encoding="utf-8") as f:
return json.load(f)
# Try .json extension
json_path = target_dir / f"{page}.json"
if json_path.exists():
with open(json_path, encoding="utf-8") as f:
return json.load(f)
return None
# Return list of available pages
pages = []
for ext in ("*.fjson", "*.json"):
for p in sorted(target_dir.glob(ext)):
pages.append(p.stem)
if pages:
return {"pages": pages}
# Fall back to listing HTML pages
html_pages = [p.stem for p in sorted(html_dir.glob("*.html"))]
if html_pages:
return {"pages": html_pages, "note": "JSON not built, listing HTML pages"}
return None
raise ValueError(f"Unknown format: {format!r}. Use None, 'html', or 'json'.")
def _enrich_manifest(result: Any, package: str) -> Any:
"""Fill in version/description from importlib.metadata if missing."""
if not isinstance(result, dict):
return result
if result.get("version") is not None and result.get("description") is not None:
return result
try:
from importlib.metadata import metadata
meta = metadata(package)
if result.get("version") is None:
result["version"] = meta.get("Version")
if result.get("description") is None:
summary = meta.get("Summary")
if summary:
result["description"] = summary
except Exception:
pass
return result