Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 70 additions & 5 deletions great_docs/cli.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,66 @@
from __future__ import annotations

import re
import sys
from pathlib import Path

import click

from . import __version__
from .core import GreatDocs


def _detect_python_version_from_pyproject(project_root: Path) -> str | None:
"""Detect the minimum Python version from pyproject.toml.

Parses the `requires-python` field (e.g., '>=3.12', '>=3.10,<3.13')
and returns a suitable Python version string for CI (e.g., '3.12').

Returns None if pyproject.toml doesn't exist or has no version requirement.
"""
pyproject_path = project_root / "pyproject.toml"
if not pyproject_path.exists():
return None

try:
# Use tomllib (Python 3.11+) or tomli as fallback
try:
import tomllib
except ImportError:
import tomli as tomllib

with open(pyproject_path, "rb") as f:
data = tomllib.load(f)

requires_python = data.get("project", {}).get("requires-python")
if not requires_python:
return None

# Parse version specifier to find minimum version
# Common patterns: ">=3.12", ">=3.10,<3.13", "~=3.11", ">=3.9"
# Extract versions from specifiers
version_pattern = r"(\d+\.\d+)"
matches = re.findall(version_pattern, requires_python)

if not matches:
return None

# For >= specifiers, use the specified version
if ">=" in requires_python or "~=" in requires_python:
# Return the first (minimum) version found
return matches[0]

# For other specifiers, try to pick a reasonable version
# Find the highest version mentioned (likely the target)
versions = [tuple(map(int, v.split("."))) for v in matches]
max_version = max(versions)
return f"{max_version[0]}.{max_version[1]}"

except Exception:
# If parsing fails, return None to use default
return None


class OrderedGroup(click.Group):
"""Click group that lists commands in the order they were added."""

Expand Down Expand Up @@ -433,16 +486,16 @@ def scan(project_path: str | None, docs_dir: str | None, verbose: bool) -> None:
@click.option(
"--python-version",
type=str,
default="3.11",
help="Python version for CI (default: 3.11)",
default=None,
help="Python version for CI (default: auto-detect from pyproject.toml, or 3.11)",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing workflow file without prompting",
)
def setup_github_pages(
project_path: str | None, main_branch: str, python_version: str, force: bool
project_path: str | None, main_branch: str, python_version: str | None, force: bool
) -> None:
"""Set up automatic deployment to GitHub Pages.

Expand All @@ -455,22 +508,34 @@ def setup_github_pages(
• Deploy to GitHub Pages on main branch pushes
• Use Quarto's official GitHub Action for reliable builds

The Python version is automatically detected from your pyproject.toml's
`requires-python` field. Use --python-version to override.

After running this command, commit the workflow file and enable GitHub
Pages in your repository settings (Settings → Pages → Source: GitHub Actions).

\b
Examples:
great-docs setup-github-pages # Use defaults
great-docs setup-github-pages # Auto-detect Python version
great-docs setup-github-pages --main-branch dev # Deploy from 'dev' branch
great-docs setup-github-pages --python-version 3.12
great-docs setup-github-pages --force # Overwrite existing workflow
"""
from pathlib import Path

try:
# Determine project root
project_root = Path(project_path) if project_path else Path.cwd()

# Auto-detect Python version if not specified
if python_version is None:
detected_version = _detect_python_version_from_pyproject(project_root)
if detected_version:
python_version = detected_version
click.echo(f"📦 Detected Python {python_version} from pyproject.toml")
else:
python_version = "3.12"
click.echo("📦 Using default Python 3.12 (no requires-python found)")

# Create .github/workflows directory
workflow_dir = project_root / ".github" / "workflows"
workflow_file = workflow_dir / "docs.yml"
Expand Down
121 changes: 121 additions & 0 deletions tests/test_great_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@
)
from great_docs._qrenderer.typing_information import TypeInformation, TypeSections
from great_docs.cli import (
_detect_python_version_from_pyproject,
build,
changelog,
check_links,
Expand Down Expand Up @@ -604,6 +605,126 @@ def test_setup_github_pages_overwrite_protection():
assert "Aborted" in result.output


def test_detect_python_version_from_pyproject():
"""Test detection of Python version from pyproject.toml."""
with tempfile.TemporaryDirectory() as tmp_dir:
project_root = Path(tmp_dir)

# Test with no pyproject.toml
assert _detect_python_version_from_pyproject(project_root) is None

# Test with requires-python >= 3.12
pyproject = project_root / "pyproject.toml"
pyproject.write_text("""
[project]
name = "test-package"
requires-python = ">=3.12"
""")
assert _detect_python_version_from_pyproject(project_root) == "3.12"

# Test with requires-python >= 3.10
pyproject.write_text("""
[project]
name = "test-package"
requires-python = ">=3.10"
""")
assert _detect_python_version_from_pyproject(project_root) == "3.10"

# Test with range specifier
pyproject.write_text("""
[project]
name = "test-package"
requires-python = ">=3.11,<3.13"
""")
assert _detect_python_version_from_pyproject(project_root) == "3.11"

# Test with ~= specifier
pyproject.write_text("""
[project]
name = "test-package"
requires-python = "~=3.9"
""")
assert _detect_python_version_from_pyproject(project_root) == "3.9"

# Test with no requires-python field
pyproject.write_text("""
[project]
name = "test-package"
""")
assert _detect_python_version_from_pyproject(project_root) is None


def test_setup_github_pages_auto_detects_python_version():
"""Test that setup-github-pages auto-detects Python version from pyproject.toml."""
runner = CliRunner()

with tempfile.TemporaryDirectory() as tmp_dir:
# Create pyproject.toml with requires-python
pyproject = Path(tmp_dir) / "pyproject.toml"
pyproject.write_text("""
[project]
name = "test-package"
requires-python = ">=3.12"
""")

# Run without --python-version flag
result = runner.invoke(setup_github_pages, ["--project-path", tmp_dir, "--force"])

assert result.exit_code == 0
assert "Detected Python 3.12 from pyproject.toml" in result.output

# Check the workflow file has the correct version
workflow_file = Path(tmp_dir) / ".github" / "workflows" / "docs.yml"
content = workflow_file.read_text()
assert "3.12" in content


def test_setup_github_pages_falls_back_to_default():
"""Test that setup-github-pages falls back to 3.12 when no requires-python found."""
runner = CliRunner()

with tempfile.TemporaryDirectory() as tmp_dir:
# Run without pyproject.toml
result = runner.invoke(setup_github_pages, ["--project-path", tmp_dir, "--force"])

assert result.exit_code == 0
assert "Using default Python 3.12" in result.output

# Check the workflow file has the default version
workflow_file = Path(tmp_dir) / ".github" / "workflows" / "docs.yml"
content = workflow_file.read_text()
assert "3.12" in content


def test_setup_github_pages_explicit_version_overrides_detection():
"""Test that explicit --python-version overrides auto-detection."""
runner = CliRunner()

with tempfile.TemporaryDirectory() as tmp_dir:
# Create pyproject.toml with requires-python = 3.12
pyproject = Path(tmp_dir) / "pyproject.toml"
pyproject.write_text("""
[project]
name = "test-package"
requires-python = ">=3.12"
""")

# Run with explicit --python-version 3.11
result = runner.invoke(
setup_github_pages,
["--project-path", tmp_dir, "--python-version", "3.11", "--force"],
)

assert result.exit_code == 0
# Should NOT mention detection since explicit version was provided
assert "Detected Python" not in result.output

# Check the workflow file has the explicit version
workflow_file = Path(tmp_dir) / ".github" / "workflows" / "docs.yml"
content = workflow_file.read_text()
assert "3.11" in content


def test_generate_llms_txt():
"""Test generation of llms.txt file."""
with tempfile.TemporaryDirectory() as tmp_dir:
Expand Down
Loading