Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ repos:
always_run: true
pass_filenames: false
priority: 0
- id: export-functions
name: export-functions
entry: uv run --frozen --offline hooks/export-functions.py
args: ["--source-root=src/usethis", "--output-file=docs/functions.txt"]
language: system
always_run: true
pass_filenames: false
priority: 0
- repo: local
hooks:
- id: deptry
Expand Down
586 changes: 586 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

576 changes: 576 additions & 0 deletions docs/functions.txt

Large diffs are not rendered by default.

133 changes: 133 additions & 0 deletions hooks/export-functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Export all functions with docstrings from a package to a markdown reference file.

Recursively scans a Python package directory for all functions (including
private ones) and writes a flat markdown bullet list to an output file.
Functions are listed in the order they appear in each module; modules are
visited in sorted order. Pass ``--strict`` to fail when any function is
missing a docstring.
"""

from __future__ import annotations

import argparse
import ast
import sys
from pathlib import Path


def _path_to_module(path: Path, source_root: Path) -> str:
"""Convert a .py file path to a dotted module name.

The module name is derived relative to the parent of source_root, so that
the package name itself is included (e.g. ``src/pkg/sub/mod.py`` with
``source_root=src/pkg`` gives ``pkg.sub.mod``).
"""
rel = path.relative_to(source_root.parent)
parts = list(rel.with_suffix("").parts)
if parts and parts[-1] == "__init__":
parts = parts[:-1]
return ".".join(parts)


def _collect_py_files(source_root: Path) -> list[Path]:
"""Return all .py files under source_root in sorted order."""
return sorted(source_root.rglob("*.py"))


def _get_functions(path: Path) -> list[tuple[str, str | None]]:
"""Return (name, docstring_first_line_or_None) for every function in path."""
try:
source = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
print(f"ERROR: Cannot read {path}: {exc}", file=sys.stderr)
return []

try:
tree = ast.parse(source)
except SyntaxError as exc:
print(f"ERROR: Cannot parse {path}: {exc}", file=sys.stderr)
return []

results: list[tuple[str, str | None]] = []
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
docstring = ast.get_docstring(node)
if docstring is not None:
first_line = docstring.split("\n")[0].strip()
results.append((node.name, first_line if first_line else None))
else:
results.append((node.name, None))

return results


def main() -> int:
parser = argparse.ArgumentParser(
description="Export a function reference from a Python package to a markdown file.",
)
parser.add_argument(
"--source-root",
required=True,
help="Path to the root package directory to scan.",
)
parser.add_argument(
"--output-file",
required=True,
help="Path to the output markdown file to write.",
)
parser.add_argument(
"--strict",
action="store_true",
default=False,
help="Fail if any function is missing a docstring.",
)
args = parser.parse_args()

source_root = Path(args.source_root)
output_file = Path(args.output_file)

if not source_root.is_dir():
print(f"ERROR: Source root {source_root} is not a directory.", file=sys.stderr)
return 1

if not (source_root / "__init__.py").is_file():
print(
f"ERROR: {source_root} is not a Python package (no __init__.py).",
file=sys.stderr,
)
return 1

bullets: list[str] = []
missing: list[tuple[Path, str]] = []

for py_file in _collect_py_files(source_root):
module = _path_to_module(py_file, source_root)
for func_name, first_line in _get_functions(py_file):
if first_line is not None:
bullets.append(f"- `{func_name}()` (`{module}`) — {first_line}")
else:
missing.append((py_file, func_name))
bullets.append(f"- `{func_name}()` (`{module}`)")

content = "\n".join(bullets) + "\n"

output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(content, encoding="utf-8")

print(f"Function reference written to {output_file}.")

if args.strict and missing:
print(
f"ERROR: {len(missing)} function(s) missing a docstring:",
file=sys.stderr,
)
for path, name in missing:
print(f" - {name} in {path}", file=sys.stderr)
return 1

return 0


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions src/usethis/_backend/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@


def get_backend() -> Literal[BackendEnum.uv, BackendEnum.none]:
"""Get the current package manager backend."""
# Effectively we cache the inference, storing it in usethis_config.
if usethis_config.inferred_backend is not None:
return usethis_config.inferred_backend
Expand Down
6 changes: 6 additions & 0 deletions src/usethis/_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def table_print(table: Table) -> None:


def tick_print(msg: str | Exception) -> None:
"""Print a ✔ success/completion message (green)."""
msg = str(msg)

if not (
Expand All @@ -59,6 +60,7 @@ def tick_print(msg: str | Exception) -> None:


def instruct_print(msg: str | Exception) -> None:
"""Print a ☐ instruction the user must perform manually (red)."""
msg = str(msg)

if not (usethis_config.quiet or usethis_config.alert_only):
Expand All @@ -67,6 +69,7 @@ def instruct_print(msg: str | Exception) -> None:


def how_print(msg: str | Exception) -> None:
"""Print a ☐ guidance message explaining how to do something (red)."""
msg = str(msg)

if not (
Expand All @@ -79,6 +82,7 @@ def how_print(msg: str | Exception) -> None:


def info_print(msg: str | Exception, temporary: bool = False) -> None:
"""Print an informational message (blue)."""
msg = str(msg)

if not (
Expand All @@ -95,6 +99,7 @@ def info_print(msg: str | Exception, temporary: bool = False) -> None:


def err_print(msg: str | Exception) -> None:
"""Print a ✗ error message to stderr (red)."""
msg = str(msg)

if not usethis_config.quiet:
Expand All @@ -103,6 +108,7 @@ def err_print(msg: str | Exception) -> None:


def warn_print(msg: str | Exception) -> None:
"""Print a ⚠ warning message (yellow; deduplicated)."""
msg = str(msg)

_cached_warn_print(msg)
Expand Down
9 changes: 7 additions & 2 deletions src/usethis/_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def get_project_deps() -> list[Dependency]:


def get_dep_groups() -> dict[str, list[Dependency]]:
"""Get all dependency groups from the dependency-groups section of pyproject.toml."""
try:
pyproject = PyprojectTOMLManager().get()
except FileNotFoundError:
Expand Down Expand Up @@ -103,6 +104,7 @@ def get_dep_groups() -> dict[str, list[Dependency]]:


def get_deps_from_group(group: str) -> list[Dependency]:
"""Get the list of dependencies in a named dependency group."""
dep_groups = get_dep_groups()
try:
return dep_groups[group]
Expand Down Expand Up @@ -148,6 +150,7 @@ def add_default_groups(groups: list[str]) -> None:


def get_default_groups() -> list[str]:
"""Get the list of default dependency groups installed automatically by the package manager."""
backend = get_backend()
if backend is BackendEnum.uv:
return get_default_groups_via_uv()
Expand All @@ -164,6 +167,7 @@ def ensure_dev_group_is_defined() -> None:


def is_dep_satisfied_in(dep: Dependency, *, in_: list[Dependency]) -> bool:
"""Check if a dependency is satisfied by any dependency in the given list."""
return any(_is_dep_satisfied_by(dep, by=by) for by in in_)


Expand All @@ -173,7 +177,7 @@ def _is_dep_satisfied_by(dep: Dependency, *, by: Dependency) -> bool:


def remove_deps_from_group(deps: list[Dependency], group: str) -> None:
"""Remove the tool's development dependencies, if present."""
"""Remove dependencies from the named group if present."""
existing_group = get_deps_from_group(group)

_deps = [dep for dep in deps if is_dep_satisfied_in(dep, in_=existing_group)]
Expand All @@ -198,6 +202,7 @@ def remove_deps_from_group(deps: list[Dependency], group: str) -> None:


def is_dep_in_any_group(dep: Dependency) -> bool:
"""Check if a dependency exists in any dependency group."""
return is_dep_satisfied_in(
dep, in_=[dep for group in get_dep_groups().values() for dep in group]
)
Expand All @@ -206,7 +211,7 @@ def is_dep_in_any_group(dep: Dependency) -> bool:
def add_deps_to_group(
deps: list[Dependency], group: str, *, default: bool = True
) -> None:
"""Add a package as a non-build dependency using PEP 735 dependency groups.
"""Add dependencies to a named group using PEP 735 dependency groups.

Args:
deps: The dependencies to add to the group.
Expand Down
1 change: 1 addition & 0 deletions src/usethis/_file/pyproject_toml/requires_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class MissingRequiresPythonError(Exception):


def get_requires_python() -> SpecifierSet:
"""Get the requires-python constraint from pyproject.toml."""
pyproject = PyprojectTOMLManager().get()

try:
Expand Down
2 changes: 1 addition & 1 deletion src/usethis/_file/yaml/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def lcs_list_update(original: list[_T], new: list[_T]) -> None:


def _shared_id_sequences(*seqs: Sequence[object]) -> Sequence[list[int]]:
"""Map list elements to integers which are equal iff the objects are with __eq__."""
"""Map list elements to integers which are equal iff the objects are equal by value."""
# Don't use "in" because that would mean the elements must be hashable,
# which we don't want to require. This means we have to loop over every element,
# every time.
Expand Down
1 change: 1 addition & 0 deletions src/usethis/_integrations/project/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@


def get_source_dir_str() -> Literal["src", "."]:
"""Get the source directory as a string ('src' or '.')."""
src_dir = usethis_config.cpd() / "src"

if src_dir.exists() and src_dir.is_dir():
Expand Down
2 changes: 1 addition & 1 deletion src/usethis/_integrations/pydantic/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def fancy_model_dump(
reference: ModelRepresentation | None = None,
order_by_cls: dict[type[BaseModel], list[str]] | None = None,
) -> ModelRepresentation:
"""Like ``pydantic.model_dump`` but with bespoke formatting options.
"""Like `pydantic.model_dump` but with bespoke formatting options.

Args:
model: The model to dump. This can be a pydantic model or a representation of
Expand Down