diff --git a/great_docs/cli.py b/great_docs/cli.py index b02625f..756051d 100644 --- a/great_docs/cli.py +++ b/great_docs/cli.py @@ -210,12 +210,47 @@ def init(project_path: str | None, force: bool) -> None: is_flag=True, help="Build only the latest version (skip historical versions)", ) +@click.option( + "--from-repo", + "from_repo", + type=str, + default=None, + help="Clone a remote Git repository and build its docs (HTTPS or SSH URL)", +) +@click.option( + "--branch", + type=str, + default=None, + help="Branch or tag to check out when using --from-repo (default: repo default)", +) +@click.option( + "--output-dir", + type=click.Path(file_okay=False, dir_okay=True), + default=None, + help="Where to copy the built site when using --from-repo (default: ./great-docs/_site)", +) +@click.option( + "--shallow", + is_flag=True, + help="Force shallow clone with --from-repo (fastest, but no versioned docs or page dates)", +) +@click.option( + "--preview", + "preview_after", + is_flag=True, + help="Start a preview server after building with --from-repo", +) def build( project_path: str | None, watch: bool, no_refresh: bool, version_filter: str | None, latest_only: bool, + from_repo: str | None, + branch: str | None, + output_dir: str | None, + shallow: bool, + preview_after: bool, ) -> None: """Build your documentation site. @@ -226,16 +261,8 @@ def build( and builds the documentation site. The build directory is ephemeral and should not be committed to version control. - \b - 1. Creates great-docs/ directory with all assets - 2. Copies user guide files from project root - 3. Generates index.qmd from README.md - 4. Refreshes API reference configuration (discovers API changes) - 5. Generates llms.txt and llms-full.txt for AI/LLM indexing - 6. Creates source links to GitHub - 7. Generates CLI reference pages (if enabled) - 8. Generates API reference pages - 9. Runs Quarto to render the final HTML site in great-docs/_site/ + Use --project-path to point to a project in a different directory. + Use --watch to automatically rebuild when source files change. Use --no-refresh to skip API discovery for faster rebuilds when your package's public API hasn't changed. @@ -243,6 +270,14 @@ def build( When multi-version documentation is configured, use --versions to build only specific versions, or --latest-only to skip historical versions. + Use --from-repo to build documentation from a remote Git repository. + This clones the repo into a temporary directory, creates an isolated + virtual environment, installs the package and great-docs, builds the + site, and copies the output to --output-dir (or ./great-docs/_site). + + Add --preview to automatically start a local server after a --from-repo + build completes, opening the site in your browser. + \b Examples: great-docs build # Full build with API refresh @@ -251,19 +286,58 @@ def build( great-docs build --versions 0.3,dev # Build specific versions only great-docs build --latest-only # Build only the latest version great-docs build --project-path ../pkg + great-docs build --from-repo https://github.com/owner/pkg.git + great-docs build --from-repo git@github.com:owner/pkg.git --branch v1.0 + great-docs build --from-repo https://github.com/owner/pkg.git --output-dir ./site + great-docs build --from-repo https://github.com/owner/pkg.git --shallow + great-docs build --from-repo https://github.com/owner/pkg.git --preview """ try: - docs = GreatDocs(project_path=project_path) - # Parse version filter if provided - version_tags = None - if version_filter: - version_tags = [v.strip() for v in version_filter.split(",") if v.strip()] - docs.build( - watch=watch, - refresh=not no_refresh, - version_tags=version_tags, - latest_only=latest_only, - ) + if from_repo: + # Remote build: clone, install, build, copy output + if project_path: + click.echo( + "Warning: --project-path is ignored when --from-repo is used", + err=True, + ) + if watch: + click.echo("Error: --watch is not supported with --from-repo", err=True) + sys.exit(1) + version_tags = None + if version_filter: + version_tags = [v.strip() for v in version_filter.split(",") if v.strip()] + GreatDocs.build_from_repo( + from_repo, + branch=branch, + output_dir=output_dir, + refresh=not no_refresh, + version_tags=version_tags, + latest_only=latest_only, + shallow=shallow, + ) + if preview_after: + site_path = output_dir or str(Path.cwd() / "great-docs" / "_site") + GreatDocs.preview_site(site_path) + else: + if branch: + click.echo("Warning: --branch is ignored without --from-repo", err=True) + if shallow: + click.echo("Warning: --shallow is ignored without --from-repo", err=True) + if output_dir: + click.echo("Warning: --output-dir is ignored without --from-repo", err=True) + if preview_after: + click.echo("Warning: --preview is ignored without --from-repo", err=True) + docs = GreatDocs(project_path=project_path) + # Parse version filter if provided + version_tags = None + if version_filter: + version_tags = [v.strip() for v in version_filter.split(",") if v.strip()] + docs.build( + watch=watch, + refresh=not no_refresh, + version_tags=version_tags, + latest_only=latest_only, + ) except KeyboardInterrupt: click.echo("\nšŸ‘‹ Stopped watching") except Exception as e: @@ -313,7 +387,13 @@ def uninstall(project_path: str | None) -> None: show_default=True, help="Port for the local preview server", ) -def preview(project_path: str | None, port: int) -> None: +@click.option( + "--site-dir", + type=click.Path(exists=True, file_okay=False, dir_okay=True), + default=None, + help="Path to a pre-built site directory to serve (bypasses project detection)", +) +def preview(project_path: str | None, port: int, site_dir: str | None) -> None: """Preview your documentation locally. Starts a local HTTP server and opens the built documentation site in your @@ -322,14 +402,26 @@ def preview(project_path: str | None, port: int) -> None: The site is served from great-docs/_site/. Use 'great-docs build' to rebuild if you've made changes. + Use --site-dir to preview a site from any directory (e.g. output from + a --from-repo build). + \b Examples: great-docs preview # Preview on port 3000 great-docs preview --port 8080 # Preview on port 8080 + great-docs preview --site-dir /tmp/weathervault-site """ try: - docs = GreatDocs(project_path=project_path) - docs.preview(port=port) + if site_dir: + if project_path: + click.echo( + "Warning: --project-path is ignored when --site-dir is used", + err=True, + ) + GreatDocs.preview_site(site_dir, port=port) + else: + docs = GreatDocs(project_path=project_path) + docs.preview(port=port) except KeyboardInterrupt: click.echo("\nšŸ‘‹ Server stopped") except Exception as e: diff --git a/great_docs/core.py b/great_docs/core.py index 01175fe..6aacccc 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -13357,6 +13357,381 @@ def _on_pass(label): finally: os.chdir(original_dir) + @classmethod + def build_from_repo( + cls, + repo_url: str, + *, + branch: str | None = None, + output_dir: str | None = None, + refresh: bool = True, + version_tags: list[str] | None = None, + latest_only: bool = False, + shallow: bool = False, + ) -> Path: + """ + Clone a remote repository and build its documentation site. + + This is a convenience method for building documentation from a separate repository. It + handles cloning, creating a temporary virtual environment, installing the package, building + the docs, and copying the output. + + After the initial shallow clone, the target's `great-docs.yml` is inspected so the git + history depth can be adapted automatically: + + * `versions:` is non-empty -> full unshallow + fetch tags (versioned builds check out + multiple tags). + * `site.show_dates: true` -> full unshallow (page-creation dates need `git log` history). + * Otherwise, only `git fetch --tags` (enough for source-link tag detection). + + Pass `shallow=True` to skip all post-clone fetching (fastest, but git-history features will + be degraded). + + Parameters + ---------- + repo_url + Git URL of the repository to clone (HTTPS or SSH). + branch + Branch, tag, or commit to check out. If `None`, uses the repository's default branch. + output_dir + Directory to copy the built site into. If `None`, defaults to `./great-docs/_site` in + the current working directory. + refresh + If `True` (default), re-discover package exports before building. + version_tags + If provided, only build these specific version tags. + latest_only + If `True`, build only the latest version. + shallow + If `True`, force a shallow clone (`--depth=1`) with no additional history fetching. + Fastest option, but versioned docs, page metadata dates, and tag-based source links will + not work. + + Returns + ------- + Path + Absolute path to the directory containing the built site. + + Examples + -------- + Build docs from a remote repository: + + ```python + from great_docs import GreatDocs + + site_dir = GreatDocs.build_from_repo( + "https://github.com/posit-dev/great-tables.git", + branch="main", + output_dir="./my-site", + ) + ``` + """ + import subprocess + import sys + import tempfile + import venv + + if output_dir is None: + output_path = Path.cwd() / "great-docs" / "_site" + else: + output_path = Path(output_dir).resolve() + + tmpdir = tempfile.mkdtemp(prefix="great-docs-remote-") + clone_dir = Path(tmpdir) / "repo" + venv_dir = Path(tmpdir) / "venv" + + try: + # ── 1. Clone the repository (shallow first) ──────────────── + print(f"šŸ“¦ Cloning {repo_url}...") + clone_cmd = ["git", "clone", "--depth=1"] + if branch: + clone_cmd += ["--branch", branch] + clone_cmd += [repo_url, str(clone_dir)] + result = subprocess.run(clone_cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + f"git clone failed (exit {result.returncode}):\n{result.stderr.strip()}" + ) + + # ── 1b. Inspect great-docs.yml and deepen history if needed ─ + if not shallow: + needs = cls._inspect_repo_git_needs(clone_dir) + if needs == "full": + print(" Fetching full history (versioned docs / page dates)...") + subprocess.run( + ["git", "fetch", "--unshallow"], + cwd=str(clone_dir), + capture_output=True, + text=True, + ) + subprocess.run( + ["git", "fetch", "--tags"], + cwd=str(clone_dir), + capture_output=True, + text=True, + ) + elif needs == "tags": + print(" Fetching tags for source link detection...") + subprocess.run( + ["git", "fetch", "--tags"], + cwd=str(clone_dir), + capture_output=True, + text=True, + ) + print(" Cloned to temporary directory") + + # ── 2. Create temporary virtual environment ──────────────── + print("šŸ Creating temporary virtual environment...") + venv.create(str(venv_dir), with_pip=True, clear=True) + + if sys.platform == "win32": + venv_python = venv_dir / "Scripts" / "python.exe" + venv_pip = venv_dir / "Scripts" / "pip.exe" + else: + venv_python = venv_dir / "bin" / "python" + venv_pip = venv_dir / "bin" / "pip" + + # ── 3. Install great-docs into the temp venv ─────────────── + print("šŸ“„ Installing great-docs into temporary environment...") + result = subprocess.run( + [str(venv_pip), "install", "great-docs"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + # Fall back to installing from the current source tree if + # the PyPI package is not available (e.g. during development) + src_root = Path(__file__).resolve().parent.parent + result = subprocess.run( + [str(venv_pip), "install", str(src_root)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"Failed to install great-docs:\n{result.stderr.strip()}") + + # ── 4. Install the target package ────────────────────────── + print("šŸ“„ Installing target package...") + # Try installing with optional dev/docs extras first + install_cmd = [str(venv_pip), "install", "-e"] + extras = cls._detect_install_extras(clone_dir) + if extras: + install_cmd.append(f"{clone_dir}[{extras}]") + else: + install_cmd.append(str(clone_dir)) + result = subprocess.run(install_cmd, capture_output=True, text=True) + if result.returncode != 0: + # Retry without extras + result = subprocess.run( + [str(venv_pip), "install", "-e", str(clone_dir)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"Failed to install target package:\n{result.stderr.strip()}" + ) + + # ── 5. Build docs using the temp venv's great-docs ───────── + print("šŸ”Ø Building documentation...") + if sys.platform == "win32": + gd_cli = venv_dir / "Scripts" / "great-docs.exe" + else: + gd_cli = venv_dir / "bin" / "great-docs" + + build_cmd = [str(gd_cli), "build", "--project-path", str(clone_dir)] + if not refresh: + build_cmd.append("--no-refresh") + if version_tags: + build_cmd.extend(["--versions", ",".join(version_tags)]) + if latest_only: + build_cmd.append("--latest-only") + + # Pass through GITHUB_TOKEN / GH_TOKEN and PATH + build_env = os.environ.copy() + build_env["VIRTUAL_ENV"] = str(venv_dir) + build_env["PATH"] = ( + str(venv_dir / ("Scripts" if sys.platform == "win32" else "bin")) + + os.pathsep + + build_env.get("PATH", "") + ) + + result = subprocess.run( + build_cmd, + env=build_env, + cwd=str(clone_dir), + ) + if result.returncode != 0: + raise RuntimeError(f"great-docs build failed (exit {result.returncode})") + + # ── 6. Copy built site to output directory ───────────────── + site_dir = clone_dir / "great-docs" / "_site" + if not site_dir.exists(): + raise RuntimeError(f"Build completed but _site/ directory not found at {site_dir}") + + print(f"šŸ“‚ Copying built site to {output_path}...") + output_path.parent.mkdir(parents=True, exist_ok=True) + if output_path.exists(): + shutil.rmtree(output_path) + shutil.copytree(site_dir, output_path) + + print(f"āœ… Site built successfully: {output_path}") + return output_path + + finally: + # ── 7. Clean up temporary directory ──────────────────────── + try: + shutil.rmtree(tmpdir) + except Exception: + pass # Best-effort cleanup + + @staticmethod + def preview_site(site_dir: str | Path, port: int = 3000) -> None: + """Preview a pre-built documentation site from any directory. + + Starts a local HTTP server and opens the site in the default browser. This is useful for + previewing output from `build_from_repo()` or any other pre-built site directory. + + Parameters + ---------- + site_dir + Path to the directory containing the built site (must have `index.html`). + port + The port number for the local HTTP server (default `3000`). + """ + import functools + import http.server + import socketserver + import sys + import threading + import webbrowser + + site_path = Path(site_dir) + index_html = site_path / "index.html" + + if not index_html.exists(): + print(f"āŒ No index.html found in {site_path}") + sys.exit(1) + + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, + directory=str(site_path), + ) + socketserver.TCPServer.allow_reuse_address = True + + try: + httpd = socketserver.TCPServer(("", port), handler) + except OSError: + print(f"āŒ Port {port} is already in use. Try a different port.") + sys.exit(1) + + url = f"http://localhost:{port}/" + print(f"\n🌐 Serving site at {url}") + print(f" Site directory: {site_path}") + print(" Press Ctrl+C to stop\n") + + threading.Timer(0.3, webbrowser.open, args=(url,)).start() + + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + httpd.server_close() + + @staticmethod + def _detect_install_extras(project_dir: Path) -> str: + """Detect optional dependency groups suitable for a docs build. + + Scans `pyproject.toml` for extras like `dev`, `docs`, `test` and returns a comma-separated + string (e.g. `"dev,docs"`). + + Parameters + ---------- + project_dir + Path to the cloned project root. + + Returns + ------- + str + Comma-separated extras string, or empty string if none found. + """ + pyproject = project_dir / "pyproject.toml" + if not pyproject.exists(): + return "" + + try: + import tomllib + except ImportError: # pragma: no cover + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + return "" + + try: + with open(pyproject, "rb") as f: + data = tomllib.load(f) + except Exception: + return "" + + opt_deps = data.get("project", {}).get("optional-dependencies", {}) + # Prioritize groups likely to contain docs/dev deps + candidates = ["dev", "docs", "doc", "all"] + found = [c for c in candidates if c in opt_deps] + return ",".join(found) + + @staticmethod + def _inspect_repo_git_needs(clone_dir: Path) -> str: + """Inspect a cloned repo's `great-docs.yml` to determine git depth needs. + + Returns one of three strings indicating how much git history is + required for the features declared in the config: + + * `"full"`: full history needed (versioned docs or page dates). + * `"tags"`: only tags needed (source-link tag detection). + * `"none"`: shallow clone is sufficient. + + Parameters + ---------- + clone_dir + Path to the cloned repository root. + + Returns + ------- + str + One of `"full"`, `"tags"`, or `"none"`. + """ + config_path = clone_dir / "great-docs.yml" + if not config_path.exists(): + return "none" + + try: + from yaml12 import read_yaml + + with open(config_path, "r", encoding="utf-8") as f: + cfg = read_yaml(f) or {} + except Exception: + return "none" + + # Versioned docs require checking out multiple tags → full history + versions = cfg.get("versions", []) + if versions: + return "full" + + # Page metadata dates need git log for first-commit detection + site = cfg.get("site", {}) + if isinstance(site, dict) and site.get("show_dates"): + return "full" + + # Source links benefit from tags for _detect_git_ref() + source = cfg.get("source", {}) + if isinstance(source, dict): + # If a branch is explicitly set, tags aren't needed for detection + if source.get("branch"): + return "none" + + return "tags" + def preview(self, port: int = 3000) -> None: """ Preview the documentation site locally. diff --git a/tests/test_build_from_repo.py b/tests/test_build_from_repo.py new file mode 100644 index 0000000..361d326 --- /dev/null +++ b/tests/test_build_from_repo.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import subprocess +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from great_docs.cli import cli +from great_docs.core import GreatDocs + + +def test_detect_install_extras_dev_and_docs(): + """Finds dev and docs extras.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "pyproject.toml").write_text( + "[project.optional-dependencies]\n" + 'dev = ["pytest"]\n' + 'docs = ["sphinx"]\n' + 'other = ["requests"]\n' + ) + result = GreatDocs._detect_install_extras(Path(tmp)) + assert "dev" in result + assert "docs" in result + assert "other" not in result + + +def test_detect_install_extras_all(): + """Finds 'all' extra.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "pyproject.toml").write_text( + '[project.optional-dependencies]\nall = ["everything"]\n' + ) + result = GreatDocs._detect_install_extras(Path(tmp)) + assert result == "all" + + +def test_detect_install_extras_no_pyproject(): + """Returns empty string when no pyproject.toml.""" + with tempfile.TemporaryDirectory() as tmp: + assert GreatDocs._detect_install_extras(Path(tmp)) == "" + + +def test_detect_install_extras_no_optional_deps(): + """Returns empty string when no optional-dependencies.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "pyproject.toml").write_text('[project]\nname = "x"\n') + assert GreatDocs._detect_install_extras(Path(tmp)) == "" + + +def test_detect_install_extras_malformed_toml(): + """Returns empty string on malformed TOML.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "pyproject.toml").write_text("bad {{toml") + assert GreatDocs._detect_install_extras(Path(tmp)) == "" + + +def test_detect_install_extras_doc_variant(): + """Finds 'doc' (singular) extra.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "pyproject.toml").write_text( + '[project.optional-dependencies]\ndoc = ["sphinx"]\n' + ) + result = GreatDocs._detect_install_extras(Path(tmp)) + assert result == "doc" + + +def test_build_from_repo_watch_rejected(): + """--watch + --from-repo is rejected.""" + runner = CliRunner() + result = runner.invoke( + cli, + ["build", "--from-repo", "https://github.com/x/y.git", "--watch"], + ) + assert result.exit_code != 0 + assert "--watch is not supported" in result.output + + +def test_build_project_path_ignored_with_from_repo(): + """--project-path emits a warning when --from-repo is used.""" + runner = CliRunner() + with runner.isolated_filesystem(): + Path("dummy").mkdir() + with patch.object(GreatDocs, "build_from_repo") as mock_bfr: + result = runner.invoke( + cli, + [ + "build", + "--from-repo", + "https://github.com/x/y.git", + "--project-path", + "dummy", + ], + ) + assert "--project-path is ignored" in result.output + mock_bfr.assert_called_once() + + +def test_build_branch_ignored_without_from_repo(): + """--branch without --from-repo emits a warning.""" + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text('[project]\nname = "x"\n') + Path("great-docs.yml").write_text("name: x\n") + with patch.object(GreatDocs, "build") as mock_build: + result = runner.invoke(cli, ["build", "--branch", "main", "--project-path", "."]) + assert "--branch is ignored" in result.output + + +def test_build_output_dir_ignored_without_from_repo(): + """--output-dir without --from-repo emits a warning.""" + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text('[project]\nname = "x"\n') + Path("great-docs.yml").write_text("name: x\n") + with patch.object(GreatDocs, "build") as mock_build: + result = runner.invoke(cli, ["build", "--output-dir", "./out", "--project-path", "."]) + assert "--output-dir is ignored" in result.output + + +def test_build_from_repo_clone_failure(): + """Raises RuntimeError when git clone fails.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=128, stderr="fatal: repo not found") + + with pytest.raises(RuntimeError, match="git clone failed"): + GreatDocs.build_from_repo("https://github.com/nonexistent/repo.git") + + +def test_build_from_repo_clone_with_branch(): + """Passes --branch to git clone when specified.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=128, stderr="fatal: error") + + with pytest.raises(RuntimeError, match="git clone failed"): + GreatDocs.build_from_repo( + "https://github.com/x/y.git", + branch="v2.0", + ) + + clone_call = mock_run.call_args_list[0] + cmd = clone_call[0][0] + assert "--branch" in cmd + assert "v2.0" in cmd + + +def test_build_from_repo_clone_without_branch(): + """Does not pass --branch when not specified.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=128, stderr="fatal: error") + + with pytest.raises(RuntimeError, match="git clone failed"): + GreatDocs.build_from_repo("https://github.com/x/y.git") + + clone_call = mock_run.call_args_list[0] + cmd = clone_call[0][0] + assert "--branch" not in cmd + + +def test_build_from_repo_cli_passes_flags(): + """CLI flags are forwarded to build_from_repo correctly.""" + runner = CliRunner() + with patch.object(GreatDocs, "build_from_repo") as mock_bfr: + result = runner.invoke( + cli, + [ + "build", + "--from-repo", + "https://github.com/x/y.git", + "--branch", + "v1.0", + "--output-dir", + "./my-site", + "--no-refresh", + "--latest-only", + ], + ) + mock_bfr.assert_called_once_with( + "https://github.com/x/y.git", + branch="v1.0", + output_dir="./my-site", + refresh=False, + version_tags=None, + latest_only=True, + shallow=False, + ) + + +def test_build_from_repo_cli_version_tags(): + """--versions flag is forwarded as version_tags list.""" + runner = CliRunner() + with patch.object(GreatDocs, "build_from_repo") as mock_bfr: + result = runner.invoke( + cli, + [ + "build", + "--from-repo", + "https://github.com/x/y.git", + "--versions", + "0.3,0.2", + ], + ) + mock_bfr.assert_called_once_with( + "https://github.com/x/y.git", + branch=None, + output_dir=None, + refresh=True, + version_tags=["0.3", "0.2"], + latest_only=False, + shallow=False, + ) + + +def test_build_from_repo_cli_error_shown(): + """RuntimeError from build_from_repo is shown to user.""" + runner = CliRunner() + with patch.object(GreatDocs, "build_from_repo", side_effect=RuntimeError("clone exploded")): + result = runner.invoke( + cli, + ["build", "--from-repo", "https://github.com/x/y.git"], + ) + assert result.exit_code != 0 + assert "clone exploded" in result.output + + +def test_inspect_needs_no_config(): + """Returns 'none' when great-docs.yml does not exist.""" + with tempfile.TemporaryDirectory() as tmp: + assert GreatDocs._inspect_repo_git_needs(Path(tmp)) == "none" + + +def test_inspect_needs_empty_config(): + """Returns 'tags' for a minimal config (source links benefit from tags).""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "great-docs.yml").write_text("name: mypkg\n") + assert GreatDocs._inspect_repo_git_needs(Path(tmp)) == "tags" + + +def test_inspect_needs_versions(): + """Returns 'full' when versions are configured.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "great-docs.yml").write_text("name: mypkg\nversions:\n - '0.3'\n - '0.2'\n") + assert GreatDocs._inspect_repo_git_needs(Path(tmp)) == "full" + + +def test_inspect_needs_show_dates(): + """Returns 'full' when show_dates is enabled.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "great-docs.yml").write_text("name: mypkg\nsite:\n show_dates: true\n") + assert GreatDocs._inspect_repo_git_needs(Path(tmp)) == "full" + + +def test_inspect_needs_explicit_branch(): + """Returns 'none' when source.branch is explicitly set (no tag detection needed).""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "great-docs.yml").write_text("name: mypkg\nsource:\n branch: main\n") + assert GreatDocs._inspect_repo_git_needs(Path(tmp)) == "none" + + +def test_inspect_needs_malformed_yaml(): + """Returns 'none' on unparseable YAML.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "great-docs.yml").write_text("bad: {{yaml: [") + assert GreatDocs._inspect_repo_git_needs(Path(tmp)) == "none" + + +def test_inspect_needs_show_dates_false(): + """Returns 'tags' when show_dates is explicitly false.""" + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "great-docs.yml").write_text("name: mypkg\nsite:\n show_dates: false\n") + assert GreatDocs._inspect_repo_git_needs(Path(tmp)) == "tags" + + +def test_build_shallow_cli_flag(): + """--shallow is forwarded to build_from_repo.""" + runner = CliRunner() + with patch.object(GreatDocs, "build_from_repo") as mock_bfr: + result = runner.invoke( + cli, + [ + "build", + "--from-repo", + "https://github.com/x/y.git", + "--shallow", + ], + ) + mock_bfr.assert_called_once_with( + "https://github.com/x/y.git", + branch=None, + output_dir=None, + refresh=True, + version_tags=None, + latest_only=False, + shallow=True, + ) + + +def test_build_shallow_ignored_without_from_repo(): + """--shallow without --from-repo emits a warning.""" + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text('[project]\nname = "x"\n') + Path("great-docs.yml").write_text("name: x\n") + with patch.object(GreatDocs, "build") as mock_build: + result = runner.invoke(cli, ["build", "--shallow", "--project-path", "."]) + assert "--shallow is ignored" in result.output + + +# ── --preview flag tests ────────────────────────────────────────────────────── + + +def test_build_preview_calls_preview_site(): + """--preview after --from-repo calls preview_site with the output dir.""" + runner = CliRunner() + with ( + patch.object(GreatDocs, "build_from_repo") as mock_bfr, + patch.object(GreatDocs, "preview_site") as mock_preview, + ): + result = runner.invoke( + cli, + [ + "build", + "--from-repo", + "https://github.com/x/y.git", + "--output-dir", + "/tmp/out", + "--preview", + ], + ) + assert result.exit_code == 0 + mock_bfr.assert_called_once() + mock_preview.assert_called_once_with("/tmp/out") + + +def test_build_preview_default_output_dir(): + """--preview without --output-dir uses the default site path.""" + runner = CliRunner() + with ( + patch.object(GreatDocs, "build_from_repo") as mock_bfr, + patch.object(GreatDocs, "preview_site") as mock_preview, + ): + result = runner.invoke( + cli, + ["build", "--from-repo", "https://github.com/x/y.git", "--preview"], + ) + assert result.exit_code == 0 + mock_preview.assert_called_once() + site_arg = mock_preview.call_args[0][0] + assert site_arg.endswith("great-docs/_site") + + +def test_build_preview_ignored_without_from_repo(): + """--preview without --from-repo emits a warning.""" + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text('[project]\nname = "x"\n') + Path("great-docs.yml").write_text("name: x\n") + with patch.object(GreatDocs, "build") as mock_build: + result = runner.invoke(cli, ["build", "--preview", "--project-path", "."]) + assert "--preview is ignored" in result.output + + +# ── preview --site-dir tests ───────────────────────────────────────────────── + + +def test_preview_site_dir(): + """preview --site-dir calls preview_site with the given path.""" + runner = CliRunner() + with runner.isolated_filesystem(): + Path("mysite").mkdir() + (Path("mysite") / "index.html").write_text("") + site_path = str(Path("mysite").resolve()) + with patch.object(GreatDocs, "preview_site") as mock_preview: + result = runner.invoke(cli, ["preview", "--site-dir", site_path]) + mock_preview.assert_called_once_with(site_path, port=3000) + + +def test_preview_site_dir_project_path_warning(): + """preview --site-dir with --project-path emits a warning.""" + runner = CliRunner() + with runner.isolated_filesystem(): + Path("mysite").mkdir() + (Path("mysite") / "index.html").write_text("") + Path("proj").mkdir() + site_path = str(Path("mysite").resolve()) + proj_path = str(Path("proj").resolve()) + with patch.object(GreatDocs, "preview_site"): + result = runner.invoke( + cli, + ["preview", "--site-dir", site_path, "--project-path", proj_path], + ) + assert "--project-path is ignored" in result.output + + +def test_preview_site_missing_index(): + """preview_site exits when index.html is missing.""" + with tempfile.TemporaryDirectory() as tmp: + with pytest.raises(SystemExit): + GreatDocs.preview_site(tmp) diff --git a/user_guide/13-building.qmd b/user_guide/13-building.qmd index 855c300..dc7b0df 100644 --- a/user_guide/13-building.qmd +++ b/user_guide/13-building.qmd @@ -108,6 +108,56 @@ The `_site/` subdirectory contains the final HTML output. This is the directory Everything outside of `_site/` is intermediate Quarto source. You generally do not need to inspect these files, but they can be useful for debugging rendering issues. +## Building from a Remote Repository {#from-repo} + +The `--from-repo` flag lets you build documentation for any Git-hosted package without cloning it yourself. Great Docs handles the entire workflow: cloning the repository, creating an isolated virtual environment, installing the package and its dependencies, running the full build pipeline, and copying the finished site to a local directory. + +```{.bash filename="Terminal"} +great-docs build --from-repo https://github.com/owner/package.git +``` + +The built site is copied to `./great-docs/_site/` by default. Use `--output-dir` to put it somewhere else: + +```{.bash filename="Terminal"} +great-docs build --from-repo https://github.com/owner/package.git --output-dir /tmp/my-site +``` + +### Branch or Tag + +By default the repository's default branch is cloned. Use `--branch` to check out a specific branch or tag: + +```{.bash filename="Terminal"} +great-docs build --from-repo https://github.com/owner/package.git --branch v2.0.0 +``` + +### Clone Depth + +Great Docs inspects the target project's `great-docs.yml` to decide how much Git history to fetch. If the project uses multi-version docs or page dates, a full clone is performed automatically. Otherwise a lightweight tag-only clone is used. + +Use `--shallow` to force a minimal `--depth 1` clone. This is the fastest option but disables versioned documentation and page dates: + +```{.bash filename="Terminal"} +great-docs build --from-repo https://github.com/owner/package.git --shallow +``` + +### Previewing After Build + +Add `--preview` to start a local server and open the site in your browser as soon as the build finishes: + +```{.bash filename="Terminal"} +great-docs build --from-repo https://github.com/owner/package.git --preview +``` + +### Previewing a Previously Built Site + +If you have already built a site with `--from-repo` (or received a site directory from someone else), use `great-docs preview --site-dir` to serve it without any project context: + +```{.bash filename="Terminal"} +great-docs preview --site-dir /tmp/my-site +``` + +This starts the same local HTTP server and opens your browser, just like the regular `great-docs preview` command. + ## Using the Python API In addition to the CLI, you can drive the build programmatically: