Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add load_git, to load Module from a git committish #76

Merged
merged 14 commits into from
Jun 6, 2022
2 changes: 1 addition & 1 deletion src/griffe/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from griffe.docstrings.parsers import Parser, parse # noqa: WPS347
from griffe.exceptions import AliasResolutionError, BuiltinModuleError, CyclicAliasError, NameResolutionError
from griffe.expressions import Expression, Name
from griffe.mixins import GetMembersMixin, ObjectAliasMixin, SetMembersMixin, SerializationMixin
from griffe.mixins import GetMembersMixin, ObjectAliasMixin, SerializationMixin, SetMembersMixin

# TODO: remove once Python 3.7 support is dropped
if sys.version_info < (3, 8):
Expand Down
128 changes: 128 additions & 0 deletions src/griffe/git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""This module contains the code allowing to load modules from specific git commits.

```python
from griffe.git import load_git

# where `repo` is the folder *containing* `.git`
old_api = load_git("my_module", commit="v0.1.0", repo="path/to/repo")
```
"""
from __future__ import annotations

import os
import shutil
from contextlib import contextmanager
from pathlib import Path
from subprocess import DEVNULL, PIPE, CalledProcessError, run # noqa: S404
from tempfile import TemporaryDirectory
from typing import Any, Iterator, Sequence

from griffe import loader
from griffe.agents.extensions import Extensions
from griffe.collections import LinesCollection, ModulesCollection
from griffe.dataclasses import Module
from griffe.docstrings.parsers import Parser


def _assert_git_repo(repo: str) -> None:
if not shutil.which("git"):
raise RuntimeError("Could not find git executable. Please install git.")

try:
run( # noqa: S603,S607
["git", "-C", repo, "rev-parse", "--is-inside-work-tree"], check=True, stdout=DEVNULL, stderr=DEVNULL
)
except CalledProcessError as err:
raise OSError(f"Not a git repository: {repo!r}") from err


@contextmanager
def tmp_worktree(commit: str = "HEAD", repo: str | Path = ".") -> Iterator[str]:
"""Context manager that checks out `commit` in `repo` to a temporary worktree.

Parameters:
commit: A "commit-ish" - such as a hash or tag.
repo: Path to the repository (i.e. the directory *containing* the `.git` directory)

Yields:
The path to the temporary worktree.

Raises:
OSError: If `repo` is not a valid `.git` repository
RuntimeError: If the `git` executable is unavailable, or if it cannot create a worktree
"""
repo = str(repo)
_assert_git_repo(repo)
with TemporaryDirectory() as td:
uid = f"griffe_{commit}"
target = os.path.join(td, uid)
retval = run( # noqa: S603,S607
["git", "-C", repo, "worktree", "add", "-b", uid, target, commit],
stderr=PIPE,
stdout=PIPE,
)
if retval.returncode:
raise RuntimeError(f"Could not create git worktree: {retval.stderr.decode()}")

try:
yield target
finally:
run(["git", "-C", repo, "worktree", "remove", uid], stdout=DEVNULL) # noqa: S603,S607
run(["git", "-C", repo, "worktree", "prune"], stdout=DEVNULL) # noqa: S603,S607
run(["git", "-C", repo, "branch", "-d", uid], stdout=DEVNULL) # noqa: S603,S607


def load_git(
module: str | Path,
commit: str = "HEAD",
repo: str | Path = ".",
submodules: bool = True,
try_relative_path: bool = True,
extensions: Extensions | None = None,
search_paths: Sequence[str | Path] | None = None,
docstring_parser: Parser | None = None,
docstring_options: dict[str, Any] | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
allow_inspection: bool = True,
) -> Module:
"""Load and return a module from a specific git commit in `repo`.

This function will create a temporary
[git worktree](https://git-scm.com/docs/git-worktree) at the requested `commit`,
before loading `module` with [`griffe.load`][griffe.loader.load].

This function requires that the `git` executable be installed.

Parameters:
module: The module name or path.
commit: A "commit-ish" - such as a hash or tag.
repo: Path to the repository (i.e. the directory *containing* the `.git` directory)
submodules: Whether to recurse on the submodules.
try_relative_path: Whether to try finding the module as a relative path.
extensions: The extensions to use.
search_paths: The paths to search into.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Additional docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.

Returns:
A loaded module.
"""
with tmp_worktree(commit, repo) as worktree:
search_paths = list(search_paths) if search_paths else []
search_paths.insert(0, worktree)
return loader.load(
module=module,
submodules=submodules,
try_relative_path=try_relative_path,
extensions=extensions,
search_paths=search_paths,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
allow_inspection=allow_inspection,
)
1 change: 1 addition & 0 deletions tests/fixtures/_repo/v0.1.0/my_module/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.1.0"
1 change: 1 addition & 0 deletions tests/fixtures/_repo/v0.2.0/my_module/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.2.0"
94 changes: 94 additions & 0 deletions tests/test_git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Tests for creating a griffe Module from specific commits in a git repository."""
import shutil
from pathlib import Path
from subprocess import run # noqa: S404

import pytest

from griffe.dataclasses import Module
from griffe.git import load_git
from tests import FIXTURES_DIR

REPO_NAME = "my-repo"
REPO_SOURCE = FIXTURES_DIR / "_repo"
MODULE_NAME = "my_module"


def _copy_contents(src: Path, dst: Path) -> None:
"""Copy *contents* of src into dst.

Parameters:
src: the folder whose contents will be copied to dst
dst: the destination folder
"""
dst.mkdir(exist_ok=True, parents=True)
for src_path in src.iterdir():
dst_path = dst / src_path.name
if src_path.is_dir():
_copy_contents(src_path, dst_path)
else:
shutil.copy(src_path, dst_path)


@pytest.fixture()
def git_repo(tmp_path: Path) -> Path:
"""Fixture that creates a git repo with multiple tagged versions.

For each directory in `tests/test_git/_repo/`

- the contents of the directory will be copied into the temporary repo
- all files will be added and commited
- the commit will be tagged with the name of the directory

To add to these tests (i.e. to simulate change over time), either modify one of
the files in the existing `v0.1.0`, `v0.2.0` folders, or continue adding new
version folders following the same pattern.

Parameters:
tmp_path: temporary directory fixture

Returns:
Path: path to temporary repo.
"""
repo_path = tmp_path / REPO_NAME
repo_path.mkdir()
run(["git", "-C", str(repo_path), "init"]) # noqa: S603,S607
run(["git", "-C", str(repo_path), "config", "user.name", "Name"]) # noqa: S603,S607
run(["git", "-C", str(repo_path), "config", "user.email", "my@email.com"]) # noqa: S603,S607
for tagdir in REPO_SOURCE.iterdir():
ver = tagdir.name
_copy_contents(tagdir, repo_path)
run(["git", "-C", str(repo_path), "add", "."]) # noqa: S603,S607
run(["git", "-C", str(repo_path), "commit", "-m", f"feat: {ver} stuff"]) # noqa: S603,S607
run(["git", "-C", str(repo_path), "tag", ver]) # noqa: S603,S607
return repo_path


def test_load_git(git_repo: Path): # noqa: WPS442
"""Test that we can load modules from different commits from a git repo.

Parameters:
git_repo: temporary git repo
"""
v1 = load_git(MODULE_NAME, commit="v0.1.0", repo=git_repo)
v2 = load_git(MODULE_NAME, commit="v0.2.0", repo=git_repo)
assert isinstance(v1, Module)
assert isinstance(v2, Module)
assert v1.attributes["__version__"].value == "'0.1.0'"
assert v2.attributes["__version__"].value == "'0.2.0'"


def test_load_git_errors(git_repo: Path): # noqa: WPS442
"""Test that we get informative errors for various invalid inputs.

Parameters:
git_repo: temporary git repo
"""
with pytest.raises(OSError, match="Not a git repository"):
load_git(MODULE_NAME, commit="v0.2.0", repo="not-a-repo")

with pytest.raises(RuntimeError, match="Could not create git worktre"):
load_git(MODULE_NAME, commit="invalid-tag", repo=git_repo)

with pytest.raises(ModuleNotFoundError, match="No module named 'not_a_real_module'"):
load_git("not_a_real_module", commit="v0.2.0", repo=git_repo)