From 8b2d6a5046d954f823c7181cc704b013a5865536 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Tue, 20 Jan 2026 20:47:28 +0530 Subject: [PATCH] added cli --- docforge/__init__.py | 2 + docforge/__init__.pyi | 1 + docforge/cli/__init__.py | 3 + docforge/cli/__init__.pyi | 3 + docforge/cli/main.py | 142 ++++++++++++++++++++++++++ docforge/cli/main.pyi | 77 ++++++++++++++ tests/cli/__init__.py | 0 tests/cli/conftest.py | 67 ++++++++++++ tests/cli/test_build.py | 20 ++++ tests/cli/test_generate.py | 32 ++++++ tests/cli/test_serve.py | 19 ++++ tests/cli/test_tree.py | 24 +++++ tests/{introspection => }/conftest.py | 0 13 files changed, 390 insertions(+) create mode 100644 docforge/cli/__init__.py create mode 100644 docforge/cli/__init__.pyi create mode 100644 docforge/cli/main.py create mode 100644 docforge/cli/main.pyi create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/conftest.py create mode 100644 tests/cli/test_build.py create mode 100644 tests/cli/test_generate.py create mode 100644 tests/cli/test_serve.py create mode 100644 tests/cli/test_tree.py rename tests/{introspection => }/conftest.py (100%) diff --git a/docforge/__init__.py b/docforge/__init__.py index 3320f8c..3f93efa 100644 --- a/docforge/__init__.py +++ b/docforge/__init__.py @@ -8,10 +8,12 @@ until their contracts are finalized. from .loader import GriffeLoader from .renderers import MkDocsRenderer +from .cli import main from . import model __all__ = [ "GriffeLoader", "MkDocsRenderer", "model", + "main", ] diff --git a/docforge/__init__.pyi b/docforge/__init__.pyi index 9f9d408..09ec028 100644 --- a/docforge/__init__.pyi +++ b/docforge/__init__.pyi @@ -1,5 +1,6 @@ from .loader import GriffeLoader from .renderers import MkDocsRenderer +from .cli import main from . import model __all__: list[str] diff --git a/docforge/cli/__init__.py b/docforge/cli/__init__.py new file mode 100644 index 0000000..21db616 --- /dev/null +++ b/docforge/cli/__init__.py @@ -0,0 +1,3 @@ +from .main import main + +__all__ = ["main"] diff --git a/docforge/cli/__init__.pyi b/docforge/cli/__init__.pyi new file mode 100644 index 0000000..21db616 --- /dev/null +++ b/docforge/cli/__init__.pyi @@ -0,0 +1,3 @@ +from .main import main + +__all__ = ["main"] diff --git a/docforge/cli/main.py b/docforge/cli/main.py new file mode 100644 index 0000000..14bb62d --- /dev/null +++ b/docforge/cli/main.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Sequence, Optional + +import click + +from docforge.loader import GriffeLoader +from docforge.renderers.mkdocs import MkDocsRenderer + + +@click.group() +def cli() -> None: + """doc-forge command-line interface.""" + pass + + +# --------------------------------------------------------------------- +# tree +# --------------------------------------------------------------------- + +@cli.command() +@click.option( + "--modules", + multiple=True, + required=True, + help="Python module import paths to introspect", +) +@click.option( + "--project-name", + help="Project name (defaults to first module)", +) +def tree( + modules: Sequence[str], + project_name: Optional[str], +) -> None: + """Show introspection tree.""" + loader = GriffeLoader() + project = loader.load_project(list(modules), project_name) + + click.echo(project.name) + + for module in project.get_all_modules(): + click.echo(f"├── {module.path}") + + for obj in module.get_all_objects(): + _print_object(obj, indent="│ ") + + +def _print_object(obj, indent: str) -> None: + click.echo(f"{indent}├── {obj.name}") + for member in obj.get_all_members(): + _print_object(member, indent + "│ ") + + +# --------------------------------------------------------------------- +# generate +# --------------------------------------------------------------------- + +@cli.command() +@click.option( + "--modules", + multiple=True, + required=True, + help="Python module import paths to document", +) +@click.option( + "--project-name", + help="Project name (defaults to first module)", +) +@click.option( + "--docs-dir", + type=click.Path(path_type=Path), + default=Path("docs"), +) +def generate( + modules: Sequence[str], + project_name: Optional[str], + docs_dir: Path, +) -> None: + """Generate documentation source files using MkDocs renderer.""" + loader = GriffeLoader() + project = loader.load_project(list(modules), project_name) + + renderer = MkDocsRenderer() + renderer.generate_sources(project, docs_dir) + + click.echo(f"Documentation sources generated in {docs_dir}") + + +# --------------------------------------------------------------------- +# build +# --------------------------------------------------------------------- + +@cli.command() +@click.option( + "--mkdocs-yml", + type=click.Path(path_type=Path), + default=Path("mkdocs.yml"), +) +def build(mkdocs_yml: Path) -> None: + """Build documentation using MkDocs.""" + if not mkdocs_yml.exists(): + raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}") + + from mkdocs.config import load_config + from mkdocs.commands.build import build as mkdocs_build + + mkdocs_build(load_config(str(mkdocs_yml))) + + click.echo("MkDocs build completed") + + +# --------------------------------------------------------------------- +# serve +# --------------------------------------------------------------------- + +@cli.command() +@click.option( + "--mkdocs-yml", + type=click.Path(path_type=Path), + default=Path("mkdocs.yml"), +) +def serve(mkdocs_yml: Path) -> None: + """Serve documentation using MkDocs.""" + if not mkdocs_yml.exists(): + raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}") + + from mkdocs.commands.serve import serve as mkdocs_serve + mkdocs_serve(config_file=str(mkdocs_yml)) + + +# --------------------------------------------------------------------- +# entry point +# --------------------------------------------------------------------- + +def main() -> None: + cli() + + +if __name__ == "__main__": + main() diff --git a/docforge/cli/main.pyi b/docforge/cli/main.pyi new file mode 100644 index 0000000..f20edf9 --- /dev/null +++ b/docforge/cli/main.pyi @@ -0,0 +1,77 @@ +from typing import Sequence +from pathlib import Path + +import click + + +@click.group() +def cli() -> None: + """doc-forge command-line interface.""" + + +@cli.command() +@click.option( + "--modules", + multiple=True, + help="Python module import paths to introspect", +) +@click.option( + "--project-name", + help="Project name (defaults to first module)", +) +def tree( + modules: Sequence[str], + project_name: str | None, +) -> None: + """Show introspection tree.""" + + +@cli.command() +@click.option( + "--modules", + multiple=True, + help="Python module import paths to document", +) +@click.option( + "--project-name", + help="Project name (defaults to first module)", +) +@click.option( + "--docs-dir", + type=click.Path(path_type=Path), + default=Path("docs"), +) +def generate( + modules: Sequence[str], + project_name: str | None, + docs_dir: Path, +) -> None: + """Generate documentation source files using MkDocs renderer.""" + + +@cli.command() +@click.option( + "--mkdocs-yml", + type=click.Path(path_type=Path), + default=Path("mkdocs.yml"), +) +def build( + mkdocs_yml: Path, +) -> None: + """Build documentation using MkDocs.""" + + +@cli.command() +@click.option( + "--mkdocs-yml", + type=click.Path(path_type=Path), + default=Path("mkdocs.yml"), +) +def serve( + mkdocs_yml: Path, +) -> None: + """Serve documentation using MkDocs.""" + + +def main() -> None: + """CLI entry point.""" diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..76d2515 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import Callable + +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def cli_runner() -> CliRunner: + """Click CLI runner.""" + return CliRunner() + + +@pytest.fixture +def fake_mkdocs_yml(tmp_path: Path) -> Path: + """Create a minimal mkdocs.yml file.""" + yml = tmp_path / "mkdocs.yml" + yml.write_text( + """ +site_name: Test Docs +nav: [] +plugins: + - mkdocstrings +""", + encoding="utf-8", + ) + return yml + + +@pytest.fixture +def mock_mkdocs_load_config(monkeypatch): + """Mock mkdocs.config.load_config.""" + def fake_load_config(path): + return object() # dummy config object + + monkeypatch.setattr( + "mkdocs.config.load_config", + fake_load_config, + ) + + +@pytest.fixture +def mock_mkdocs_build(monkeypatch): + called = {"value": False} + + def fake_build(config): + called["value"] = True + + monkeypatch.setattr( + "mkdocs.commands.build.build", + fake_build, + ) + return lambda: called["value"] + + +@pytest.fixture +def mock_mkdocs_serve(monkeypatch): + called = {"value": False} + + def fake_serve(*args, **kwargs): + called["value"] = True + + monkeypatch.setattr( + "mkdocs.commands.serve.serve", + fake_serve, + ) + return lambda: called["value"] diff --git a/tests/cli/test_build.py b/tests/cli/test_build.py new file mode 100644 index 0000000..2494141 --- /dev/null +++ b/tests/cli/test_build.py @@ -0,0 +1,20 @@ +from docforge.cli.main import cli + + +def test_build_command( + cli_runner, + fake_mkdocs_yml, + mock_mkdocs_load_config, + mock_mkdocs_build, +): + result = cli_runner.invoke( + cli, + [ + "build", + "--mkdocs-yml", + str(fake_mkdocs_yml), + ], + ) + + assert result.exit_code == 0 + assert mock_mkdocs_build() is True diff --git a/tests/cli/test_generate.py b/tests/cli/test_generate.py new file mode 100644 index 0000000..757ad05 --- /dev/null +++ b/tests/cli/test_generate.py @@ -0,0 +1,32 @@ +from pathlib import Path + +from docforge.cli.main import cli + + +def test_generate_command(cli_runner, temp_package, tmp_path: Path): + (temp_package / "mod.py").write_text( + ''' +def f(): ... +''' + ) + + docs_dir = tmp_path / "docs" + + result = cli_runner.invoke( + cli, + [ + "generate", + "--modules", + "testpkg.mod", + "--docs-dir", + str(docs_dir), + ], + ) + + assert result.exit_code == 0 + + md = docs_dir / "testpkg" / "mod.md" + assert md.exists() + + content = md.read_text() + assert "::: testpkg.mod" in content diff --git a/tests/cli/test_serve.py b/tests/cli/test_serve.py new file mode 100644 index 0000000..5c30edd --- /dev/null +++ b/tests/cli/test_serve.py @@ -0,0 +1,19 @@ +from docforge.cli.main import cli + + +def test_serve_command( + cli_runner, + fake_mkdocs_yml, + mock_mkdocs_serve, +): + result = cli_runner.invoke( + cli, + [ + "serve", + "--mkdocs-yml", + str(fake_mkdocs_yml), + ], + ) + + assert result.exit_code == 0 + assert mock_mkdocs_serve() is True diff --git a/tests/cli/test_tree.py b/tests/cli/test_tree.py new file mode 100644 index 0000000..c26205c --- /dev/null +++ b/tests/cli/test_tree.py @@ -0,0 +1,24 @@ +from docforge.cli.main import cli + + +def test_tree_command(cli_runner, temp_package): + (temp_package / "mod.py").write_text( + ''' +class A: + def f(self): ... +''' + ) + + result = cli_runner.invoke( + cli, + [ + "tree", + "--modules", + "testpkg.mod", + ], + ) + + assert result.exit_code == 0 + assert "testpkg" in result.output + assert "mod" in result.output + assert "A" in result.output diff --git a/tests/introspection/conftest.py b/tests/conftest.py similarity index 100% rename from tests/introspection/conftest.py rename to tests/conftest.py