-
-
Notifications
You must be signed in to change notification settings - Fork 44
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add
load_git
function allowing to load data from a specific g…
- Loading branch information
1 parent
c1c5c9c
commit b2c3946
Showing
5 changed files
with
225 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
__version__ = "0.1.0" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
__version__ = "0.2.0" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |