Skip to content

Commit

Permalink
fix: corrections while adding docs (#78)
Browse files Browse the repository at this point in the history
Signed-off-by: Henry Schreiner <[email protected]>
  • Loading branch information
henryiii authored Jun 5, 2023
1 parent d7d1a6b commit b511917
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 34 deletions.
109 changes: 109 additions & 0 deletions docs/checks.md
Original file line number Diff line number Diff line change
@@ -1 +1,110 @@
# Checks

Plugins provide checks; repo-review requires at least one plugin providing checks to operate; there are no built-in checks.

## Writing a check

A check is an object following a specific Protocol:

```python
class Check:
"""
Short description.
"""

family: str
requires: Set[str] = frozenset() # Optional
url: str = "" # Optional

def check(self) -> bool | str:
"""
Error message if returns False.
"""
...
```

You need to implement `family`, which is a string indicating which family it is
grouped under, and `check()`, which can take [](fixtures), and returns `True` if
the check passes, or `False` if the check fails. If you want a dynamic error
explanation instead of the `check()` docstring, you can return a non-empty
string from the check instead of `False`. Docstrings/error messages can access
their own object with `{self}` and name with `{name}`. The error message is in
markdown format.

If the check named in `requires` does not pass, the check is skipped.

A suggested convention for easily writing checks is as follows:

```python
class General:
family = "general"


class PY001(General):
"Has a pyproject.toml"

@staticmethod
def check(package: Traversable) -> bool:
"""
All projects should have a `pyproject.toml` file to support a modern
build system and support wheel installs properly.
"""
return package.joinpath("pyproject.toml").is_file()


class PyProject:
family = "pyproject"


class PP002(PyProject):
"Has a proper build-system table"

requires = {"PY001"}
url = "https://peps.python.org/pep-0517"

@staticmethod
def check(pyproject: dict[str, Any]) -> bool:
"""
Must have `build-system.requires` *and* `build-system.backend`. Both
should be present in all modern packages.
"""

match pyproject:
case {"build-system": {"requires": list(), "build-backend": str()}}:
return True
case _:
return False
```

Key features:

- The base class allows setting the family once, and gives a quick shortcut for accessing all the checks via `.__subclasses__`.
- The name of the check class itself is the check code.
- The check method is a classmethod since it has no state.
- Likewise, all attributes are set on the class (`family`, `requires`, `url`) since there is no state.
- `requries` is used so that the pyproject checks are skipped if the pyproject file is missing.

## Registering checks

You register checks with a function that returns a dict of checks, with the code
of the check (letters + number) as the key, and check instances as the values.
This function can take [](fixtures), as well, allowing customization of checks
based on repo properties.

Here is the suggested function for the above example:

```python
def repo_review_checks() -> dict[str, General | PyProject]:
return {p.__name__: p() for p in General.__subclasses__()} | {
p.__name__: p() for p in PyProject.__subclasses__()
}
```

You tell repo review to use this function via an entry-point:

```toml
[project.entry-points."repo_review.checks"]
general_pyproject = "my_plugin_package.my_checks_module:repo_review_checks"
```

The entry-point name doesn't matter.
35 changes: 35 additions & 0 deletions docs/families.md
Original file line number Diff line number Diff line change
@@ -1 +1,36 @@
# Families

Families are a set of simple strings that group together similar checks. You can provide a nicer user experience, however, by adding a mapping of information for repo-review to improve the ordering and display of families.

You can construct a dict with the following optional keys:

```python
class Family(typing.TypedDict, total=False):
name: str # defaults to key
order: int # defaults to 0
```

Then you can provide a function that maps family strings to this extra information:

```python
def get_familes() -> dict[str, Family]:
return {
"general": Family(
name="General",
order=-3,
),
"pyproject": Family(
name="PyProject",
order=-2,
),
}
```

And finally, you register this function as an entry-point:

```toml
[project.entry-points."repo_review.families"]
families = "my_plugin_package.my_family_module:get_families"
```

The entry-point name doesn't matter.
49 changes: 49 additions & 0 deletions docs/fixtures.md
Original file line number Diff line number Diff line change
@@ -1 +1,50 @@
# Fixtures

Like pytest fixtures, fixtures in repo-review are requested by name. There are three built-in fixtures:

- `root: Traversable` - The repository path. All checks or fixtures that depend on the root of the repository should use this.
- `package: Traversable` - The path to the package directory. This is the same as `root` unless `--package-dir` is passed.
- `pyproject: dict[str, Any]` - The `pyproject.toml` in the package if it exists, an empty dict otherwise.

Repo-review doesn't necessarily assume any form or language for your repository,
but since it already looks for configuration in `pyproject.toml`, this fixture
is provided.

## Writing a fixture

You can provide new fixtures easily. A fixture can take any other fixture(s) as
arguments; repo-review topologically sorts fixtures before computing them. The
result of a fixture is cached and provided when requested. The return from a
fixture should be treated as immutable.

A fixture function looks like this:

```python
import yaml
from importlib.resources.abc import Traversable


def workflows(root: Traversable) -> dict[str, Any]:
workflows_base_path = package.joinpath(".github/workflows")
workflows_dict: dict[str, Any] = {}
if workflows_base_path.is_dir():
for workflow_path in workflows_base_path.iterdir():
if workflow_path.name.endswith(".yml"):
with workflow_path.open("rb") as f:
workflows_dict[Path(workflow_path.name).stem] = yaml.safe_load(f)

return workflows_dict
```

Don't assume a specific `Traversable`, like `Path`; for remote repos or in
WebAssembly, this will be a custom `Traversable`.

To register the fixture with repo-review, you need to declare it as an entry-point:

```toml
[project.entry-points."repo_review.fixtures"]
workflows = "my_plugin_package.my_fixture_module:workflows"
```

The name of the entry-point is the fixture name. It is recommended that you name
the fixture function with the same name for simplicity, but it is not required.
4 changes: 2 additions & 2 deletions src/repo_review/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@

class Check(Protocol):
family: str
requires: Set[str] = frozenset()
url: str = ""
requires: Set[str] = frozenset() # Optional
url: str = "" # Optional

def check(self) -> bool | None | str:
...
Expand Down
27 changes: 1 addition & 26 deletions src/repo_review/families.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import importlib.metadata
import typing

__all__ = ["Family", "collect_families", "get_familes"]
__all__ = ["Family", "collect_families"]


def __dir__() -> list[str]:
Expand All @@ -21,28 +21,3 @@ def collect_families() -> dict[str, Family]:
for ep in importlib.metadata.entry_points(group="repo_review.families")
for name, family in ep.load()().items()
}


def get_familes() -> dict[str, Family]:
return {
"general": Family(
name="General",
order=-3,
),
"pyproject": Family(
name="PyProject",
order=-2,
),
"github": Family(
name="GitHub Actions",
),
"pre-commit": Family(
name="Pre-commit",
),
"mypy": Family(
name="MyPy",
),
"ruff": Family(
name="Ruff",
),
}
11 changes: 5 additions & 6 deletions src/repo_review/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,13 @@ def process(
"""
package = root.joinpath(subdir) if subdir else root

fixtures, checks, families = _collect_all(root, subdir)
fixtures, tasks, families = _collect_all(root, subdir)

# Collect our own config
config = pyproject(package).get("tool", {}).get("repo-review", {})
select_checks = select if select else set(config.get("select", ()))
skip_checks = ignore if ignore else set(config.get("ignore", ()))

# Make list of filtered checks to run
tasks: dict[str, Check] = {
n: r for n, r in checks.items() if is_allowed(select_checks, skip_checks, n)
}
# Make a graph of the check's interdependencies
graph: dict[str, set[str]] = {
n: getattr(t, "requires", set()) for n, t in tasks.items()
Expand Down Expand Up @@ -145,13 +141,16 @@ def process(
doc = check.__doc__ or ""
err_msg = completed[task_name] or ""

if not is_allowed(select_checks, skip_checks, task_name):
continue

result_list.append(
Result(
family=check.family,
name=task_name,
description=doc,
result=result,
err_msg=textwrap.dedent(err_msg.format(cls=check)),
err_msg=textwrap.dedent(err_msg.format(self=check, name=task_name)),
url=getattr(check, "url", ""),
)
)
Expand Down

0 comments on commit b511917

Please sign in to comment.