Skip to content

Commit 5a83b1e

Browse files
authored
feat: better list-all support (#101)
* feat: better list-all support * feat: two small helpers * feat: get_check_description too ----- Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
1 parent 20bd94a commit 5a83b1e

File tree

13 files changed

+267
-36
lines changed

13 files changed

+267
-36
lines changed

.readthedocs.yml renamed to .readthedocs.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# .readthedocs.yml
1+
# .readthedocs.yaml
22
# Read the Docs configuration file
33
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
44

docs/checks.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ the check passes, or `False` if the check fails. If you want a dynamic error
2929
explanation instead of the `check()` docstring, you can return a non-empty
3030
string from the check instead of `False`. Returning `None` makes a check
3131
"skipped". Docstrings/error messages can access their own object with `{self}`
32-
and check name with `{name}` (these are processed with `.format`, so escape `{}`
32+
and check name with `{name}` (these are processed with `.format()`, so escape `{}`
3333
as `{{}}`). The error message is in markdown format.
3434

3535
If the check named in `requires` does not pass, the check is skipped.
@@ -101,7 +101,7 @@ def repo_review_checks() -> dict[str, General | PyProject]:
101101
return general | pyproject
102102
```
103103

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

106106
```toml
107107
[project.entry-points."repo_review.checks"]
@@ -154,3 +154,36 @@ def repo_review_checks(pyproject: dict[str, Any]) -> dict[str, PyProject]:
154154
case _:
155155
return {}
156156
```
157+
158+
### Handling empty generation
159+
160+
If repo-review is listing all checks, a
161+
{class}`repo_review.ghpath.EmptyTraversable` is passed for `root` and
162+
`package`. This will appear to be a directory with no contents. If you have
163+
conditional checks, you should handle this case to support being listed as a
164+
possible check. As a helper for this case, a
165+
{func}`~repo_review.fixtures.list_all` fixture is provided that returns {obj}`True`
166+
only if a list-all operation is being performed. The above can then be written:
167+
168+
```python
169+
def repo_review_checks(
170+
list_all: bool, pyproject: dict[str, Any]
171+
) -> dict[str, PyProject]:
172+
backends = {
173+
"setuptools.build_api": "setuptools",
174+
"scikit_build_core.build": "scikit-build",
175+
}
176+
177+
if list_all:
178+
return {"PP003": PP003(name="<backend>")}
179+
180+
match pyproject:
181+
case {"build-system": {"build-backend": str(x)}} if x in backends:
182+
return {"PP003": PP003(name=backends[x])}
183+
case _:
184+
return {}
185+
```
186+
187+
```{versionadded} 0.8
188+
The {func}`~repo_review.fixtures.list_all` fixture.
189+
```

docs/fixtures.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# Fixtures
22

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

5-
- `root: Traversable` - The repository path. All checks or fixtures that depend on the root of the repository should use this.
6-
- `package: Traversable` - The path to the package directory. This is the same as `root` unless `--package-dir` is passed.
7-
- `pyproject: dict[str, Any]` - The `pyproject.toml` in the package if it exists, an empty dict otherwise.
5+
- `root`: {class}`~importlib.resources.abc.Traversable` - The repository path. All checks or fixtures that depend on the root of the repository should use this.
6+
- `package`: `~importlib.resources.abc.Traversable` - The path to the package directory. This is the same as `root` unless `--package-dir` is passed.
7+
- {func}`~repo_review.fixtures.pyproject`: `dict[str, Any]` - The `pyproject.toml` in the package if it exists, an empty dict otherwise.
8+
- {func}`~repo_review.fixtures.list_all`: `bool` - returns True if repo-review is just trying to collect all checks to list them.
89

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

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
intro
88
cli
9+
programmatic
910
plugins
1011
fixtures
1112
checks
@@ -15,7 +16,6 @@ changelog
1516
```
1617

1718
```{toctree}
18-
:maxdepth: 2
1919
:hidden:
2020
:caption: API
2121

docs/programmatic.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Programmatic usage
2+
3+
You can use repo-review from other Python code, as well, such as with
4+
[`cog`][]. Also see [](./webapp.md).
5+
6+
## Processors
7+
8+
The core of repo-review is the {func}`repo_review.processor.process` function. Use it like this:
9+
10+
```python
11+
root = Path(".")
12+
processed = repo_review.processor.process(root, select=set(), ignore=set(), subdir=".")
13+
```
14+
15+
The `root` parameter can be any
16+
{class}`~importlib.resources.abc.Traversable`. The keyword arguments are
17+
optional (defaults are shown). This returns a {class}`~typing.NamedTuple`,
18+
{class}`~repo_review.processor.ProcessReturn`. `.families` is a dict mapping
19+
family names to {class}`~repo_review.families.Family` and `.results` is a list
20+
of {class}`~repo_review.processor.Result`s. If you want, you can turn the results
21+
list into a simple list of dicts with {func}`~repo_review.processor.as_simple_dict`.
22+
23+
### Getting the family name
24+
25+
A common requirement is getting the "nice" family name given the short name.
26+
There's a tiny helper for this, {func}`repo_review.families.get_family_name`:
27+
28+
```python
29+
family_name = get_family_name(families, family)
30+
```
31+
32+
.. versionadded:: 0.8
33+
34+
## Listing all possible checks
35+
36+
You can also get a list of checks:
37+
38+
```python
39+
collected = repo_review.processor.collect_all()
40+
```
41+
42+
This returns a {class}`~repo_review.processor.CollectionReturn`. You can access the `.checks`,
43+
`.families`, and `.fixtures`, all are dicts.
44+
45+
.. versionadded:: 0.8
46+
47+
### Getting the check properties
48+
49+
A common requirement is getting the url from the
50+
{class}`~repo_review.checks.Check`. While a
51+
{class}`~repo_review.processor.Result` already has the URL fully rendered,
52+
checks do not; they are directly returned by plugins. There's a tiny helper
53+
for this, {func}`repo_review.checks.get_check_url`:
54+
55+
```python
56+
url = get_check_url(name, check)
57+
```
58+
59+
.. versionadded:: 0.8
60+
61+
You can also use a helper to get `__doc__` with the correct substitution, as well:
62+
63+
```python
64+
doc = get_check_description(name, check)
65+
```
66+
67+
.. versionadded:: 0.8
68+
69+
### Example: cog
70+
71+
Here's an example of using this to fill out a README with [`cog`][], formatting all possible checks in markdown:
72+
73+
```md
74+
<!-- [[[cog
75+
import itertools
76+
77+
from repo_review.processor import collect_all
78+
from repo_review.checks import get_check_url, get_check_description
79+
from repo_review.families import get_family_name
80+
81+
collected = collect_all()
82+
print()
83+
for family, grp in itertools.groupby(collected.checks.items(), key=lambda x: x[1].family):
84+
print(f'### {get_family_name(collected.families, family)}')
85+
for code, check in grp:
86+
url = get_check_url(code, check)
87+
link = f"[`{code}`]({url})" if url else f"`{code}`"
88+
print(f"- {link}: {get_check_description(code, check)}")
89+
print()
90+
]]] -->
91+
<!-- [[[end]]] -->
92+
```
93+
94+
[`cog`]: https://nedbatchelder.com/code/cog

noxfile.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def docs(session: nox.Session) -> None:
9292
if args.builder != "html" and args.serve:
9393
session.error("Must not specify non-HTML builder with --serve")
9494

95-
session.install(".[docs]")
95+
session.install("-e.[docs]")
9696
session.chdir("docs")
9797

9898
if args.builder == "linkcheck":
@@ -104,6 +104,7 @@ def docs(session: nox.Session) -> None:
104104
session.run(
105105
"sphinx-build",
106106
"-n", # nitpicky mode
107+
"--keep-going", # show all errors
107108
"-T", # full tracebacks
108109
"-b",
109110
args.builder,

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ repo-review = "repo_review.__main__:main"
7373

7474
[project.entry-points."repo_review.fixtures"]
7575
pyproject = "repo_review.fixtures:pyproject"
76+
list_all = "repo_review.fixtures:list_all"
7677

7778

7879
[tool.hatch]

src/repo_review/__main__.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@
2626
from . import __version__
2727
from ._compat.importlib.resources.abc import Traversable
2828
from ._compat.typing import assert_never
29-
from .families import Family
30-
from .ghpath import EmptyTraversable, GHPath
29+
from .checks import get_check_description, get_check_url
30+
from .families import Family, get_family_name
31+
from .ghpath import GHPath
3132
from .html import to_html
32-
from .processor import Result, _collect_all, as_simple_dict, process
33+
from .processor import Result, as_simple_dict, collect_all, process
3334

3435
__all__ = ["main"]
3536

@@ -45,15 +46,22 @@ def list_all(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
4546
if not value or ctx.resilient_parsing:
4647
return
4748

48-
_, checks, families = _collect_all(EmptyTraversable())
49-
if len(checks) == 0:
49+
collected = collect_all()
50+
if len(collected.checks) == 0:
5051
msg = "No checks registered. Please install a repo-review plugin."
5152
raise click.ClickException(msg)
5253

53-
for family, grp in itertools.groupby(checks.items(), key=lambda x: x[1].family):
54-
rich.print(f' [dim]# {families[family].get("name", family)}')
54+
for family, grp in itertools.groupby(
55+
collected.checks.items(), key=lambda x: x[1].family
56+
):
57+
rich.print(f" [dim]# {get_family_name(collected.families, family)}")
5558
for code, check in grp:
56-
rich.print(f' "{code}", [dim]# {check.__doc__}')
59+
url = get_check_url(code, check)
60+
doc = get_check_description(code, check)
61+
link = f"[link={url}]{code}[/link]" if url else code
62+
comment = f" [dim]# {doc}" if doc else ""
63+
rich.print(f' "{link}",{comment}')
64+
5765
ctx.exit()
5866

5967

@@ -70,7 +78,7 @@ def rich_printer(
7078
)
7179

7280
for family, results_list in itertools.groupby(processed, lambda r: r.family):
73-
family_name = families[family].get("name", family)
81+
family_name = get_family_name(families, family)
7482
tree = rich.tree.Tree(f"[bold]{family_name}[/bold]:")
7583
for result in results_list:
7684
style = (
@@ -206,8 +214,8 @@ def main(
206214
ignore_list = {x.strip() for x in ignore.split(",") if x}
207215
select_list = {x.strip() for x in select.split(",") if x}
208216

209-
_, checks, _ = _collect_all(package, subdir=package_dir)
210-
if len(checks) == 0:
217+
collected = collect_all(package, subdir=package_dir)
218+
if len(collected.checks) == 0:
211219
msg = "No checks registered. Please install a repo-review plugin."
212220
raise click.ClickException(msg)
213221

src/repo_review/checks.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from .fixtures import apply_fixtures
88

9-
__all__ = ["Check", "collect_checks", "is_allowed"]
9+
__all__ = ["Check", "collect_checks", "is_allowed", "get_check_url"]
1010

1111

1212
class Check(Protocol):
@@ -83,3 +83,31 @@ def is_allowed(select: Set[str], ignore: Set[str], name: str) -> bool:
8383
if name in ignore or name.rstrip("0123456789") in ignore:
8484
return False
8585
return True
86+
87+
88+
def get_check_url(name: str, check: Check) -> str:
89+
"""
90+
Get the url from a check instance. Will return an empty string if missing.
91+
Will process string via format.
92+
93+
:param name: The name of the check (letters and number)
94+
:param check: The check to process.
95+
:return: The final URL.
96+
97+
.. versionadded:: 0.8
98+
"""
99+
return getattr(check, "url", "").format(self=check, name=name)
100+
101+
102+
def get_check_description(name: str, check: Check) -> str:
103+
"""
104+
Get the doc from a check instance. Will return an empty string if missing.
105+
Will process string via format.
106+
107+
:param name: The name of the check (letters and number)
108+
:param check: The check to process.
109+
:return: The final doc.
110+
111+
.. versionadded:: 0.8
112+
"""
113+
return (check.__doc__ or "").format(self=check, name=name)

src/repo_review/families.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import importlib.metadata
44
import typing
5+
from collections.abc import Mapping
56

6-
__all__ = ["Family", "collect_families"]
7+
__all__ = ["Family", "collect_families", "get_family_name"]
78

89

910
def __dir__() -> list[str]:
@@ -36,3 +37,17 @@ def collect_families() -> dict[str, Family]:
3637
for ep in importlib.metadata.entry_points(group="repo_review.families")
3738
for name, family in ep.load()().items()
3839
}
40+
41+
42+
def get_family_name(families: Mapping[str, Family], family: str) -> str:
43+
"""
44+
Returns the "nice" family name if there is one, otherwise the (input)
45+
family short name.
46+
47+
:param families: A dict of family short names to :class:`.Family`'s.
48+
:param family: The short name of a family.
49+
:return: The nice family name if there is one, otherwise the short name is returned.
50+
51+
.. versionadded:: 0.8
52+
"""
53+
return families.get(family, {}).get("name", family)

src/repo_review/fixtures.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99

1010
from ._compat import tomllib
1111
from ._compat.importlib.resources.abc import Traversable
12+
from .ghpath import EmptyTraversable
1213

1314
__all__ = [
1415
"pyproject",
16+
"list_all",
1517
"compute_fixtures",
1618
"apply_fixtures",
1719
"collect_fixtures",
@@ -28,6 +30,8 @@ def pyproject(package: Traversable) -> dict[str, Any]:
2830
empty dict if no pyproject.toml found.
2931
3032
:param package: The package fixture.
33+
34+
:return: The pyproject.toml dict or an empty dict if no file found.
3135
"""
3236
pyproject_path = package.joinpath("pyproject.toml")
3337
if pyproject_path.is_file():
@@ -36,6 +40,20 @@ def pyproject(package: Traversable) -> dict[str, Any]:
3640
return {}
3741

3842

43+
def list_all(root: Traversable) -> bool:
44+
"""
45+
Fixture: Is True when this is trying to produce a list of all checks.
46+
47+
:param root: The root fixture.
48+
49+
:return: True only if trying to make a list of all checks/fixtures/families.
50+
51+
.. versionadded:: 0.8
52+
"""
53+
54+
return isinstance(root, EmptyTraversable)
55+
56+
3957
def compute_fixtures(
4058
root: Traversable,
4159
package: Traversable,

0 commit comments

Comments
 (0)