Skip to content
Closed
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", "--strict"]
language: system
always_run: true
pass_filenames: false
priority: 0
- repo: local
hooks:
- id: deptry
Expand Down
201 changes: 201 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions hooks/export-functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Export public functions with docstrings from Python source files to a reference file.

Scans all Python source files under ``--source-root`` for public functions with a
docstring and writes a flat markdown bullet list to an output file. Functions are
listed in the order they appear in each source file; files are processed in sorted
order. Functions without a docstring are silently skipped unless ``--strict`` is
passed, in which case the hook fails and reports them.
"""

from __future__ import annotations

import argparse
import ast
import sys
from pathlib import Path

# Relative paths (from the source root) to exclude from scanning and strict
# enforcement.
AUTO_EXCLUDED = {
"_version.py", # Typically generated by dynamic VCS-based versioning tools.
}


def _module_name(source_file: Path, source_root: Path) -> str:
"""Derive a dotted module name from a file path relative to the source root."""
rel = source_file.relative_to(source_root)
parts = rel.with_suffix("").parts
return ".".join(parts)


def _collect_source_files(source_root: Path) -> list[Path]:
"""Collect all .py files under *source_root*, sorted and filtered."""
return sorted(
p
for p in source_root.rglob("*.py")
if p.name != "__init__.py"
and p.relative_to(source_root).as_posix() not in AUTO_EXCLUDED
)


def _get_public_functions(
path: Path,
) -> list[tuple[str, str | None]]:
"""Return (name, first_docstring_line_or_None) for each public function.

All module-level public functions are returned in source order.
"""
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.iter_child_nodes(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if node.name.startswith("_"):
continue
docstring = ast.get_docstring(node)
if docstring is None:
results.append((node.name, None))
else:
first_line = docstring.split("\n")[0].strip()
results.append((node.name, first_line or None))

return results


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

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

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

source_files = _collect_source_files(source_root)
missing: list[tuple[Path, str]] = []
bullets: list[str] = []

for source_path in source_files:
module = _module_name(source_path, source_root)

for func_name, first_line in _get_public_functions(source_path):
if first_line is None:
missing.append((source_path, func_name))
continue
bullets.append(f"- `{func_name}()` (`{module}`) — {first_line}")

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)} public 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
1 change: 1 addition & 0 deletions src/usethis/_backend/poetry/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@


def is_poetry_used() -> bool:
"""Check if Poetry is used in the project."""
pyproject_toml_manager = PyprojectTOMLManager()

return (
Expand Down
2 changes: 2 additions & 0 deletions src/usethis/_backend/uv/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@


def add_dep_to_group_via_uv(dep: Dependency, group: str):
"""Add a dependency to a dependency group using uv."""
try:
call_uv_subprocess(
["add", "--group", group, str(dep)],
Expand All @@ -30,6 +31,7 @@ def add_dep_to_group_via_uv(dep: Dependency, group: str):


def remove_dep_from_group_via_uv(dep: Dependency, group: str):
"""Remove a dependency from a dependency group using uv."""
try:
call_uv_subprocess(["remove", "--group", group, str(dep)], change_toml=True)
except UVSubprocessFailedError as err:
Expand Down
1 change: 1 addition & 0 deletions src/usethis/_backend/uv/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@


def is_uv_used() -> bool:
"""Check if uv is used in the project."""
pyproject_toml_manager = PyprojectTOMLManager()

return (
Expand Down
1 change: 1 addition & 0 deletions src/usethis/_backend/uv/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@


def ensure_uv_lock() -> None:
"""Ensure a uv lock file exists, creating one if necessary."""
if not (usethis_config.cpd() / "uv.lock").exists():
tick_print("Writing 'uv.lock'.")
call_uv_subprocess(["lock"], change_toml=False)
3 changes: 3 additions & 0 deletions src/usethis/_backend/uv/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@


def get_available_uv_python_versions() -> set[str]:
"""Get the set of all Python versions available via uv."""
output = call_uv_subprocess(["python", "list", "--all-versions"], change_toml=False)

return {
Expand All @@ -23,6 +24,7 @@ def get_available_uv_python_versions() -> set[str]:


def get_supported_uv_minor_python_versions() -> list[PythonVersion]:
"""Get the minor Python versions supported by both uv and the project."""
try:
requires_python = get_requires_python()
except (MissingRequiresPythonError, PyprojectTOMLNotFoundError):
Expand Down Expand Up @@ -60,4 +62,5 @@ def _parse_python_version_from_uv_output(version: str) -> str:


def uv_python_pin(version: str) -> None:
"""Pin the Python version for the project using uv."""
call_uv_subprocess(["python", "pin", version], change_toml=False)
1 change: 1 addition & 0 deletions src/usethis/_backend/uv/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@


def get_uv_version() -> str:
"""Get the installed uv version, falling back to a default if unavailable."""
try:
json_str = call_uv_subprocess(
["self", "version", "--output-format=json"],
Expand Down
1 change: 1 addition & 0 deletions src/usethis/_config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

@contextlib.contextmanager
def files_manager() -> Iterator[None]:
"""Provide a context manager that activates all configuration file managers."""
with (
PyprojectTOMLManager(),
SetupCFGManager(),
Expand Down
8 changes: 8 additions & 0 deletions src/usethis/_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@


def plain_print(msg: str | Exception) -> None:
"""Print a plain unstyled message to the console."""
msg = str(msg)

if not (
Expand All @@ -38,6 +39,7 @@ def plain_print(msg: str | Exception) -> None:


def table_print(table: Table) -> None:
"""Print a Rich table to the console."""
if not (
usethis_config.quiet
or usethis_config.alert_only
Expand All @@ -47,6 +49,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 +62,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 +71,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 +84,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 +101,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 +110,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
1 change: 1 addition & 0 deletions src/usethis/_core/author.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def add_author(
email: str | None = None,
overwrite: bool = False,
):
"""Add an author to the project's pyproject.toml."""
ensure_pyproject_toml(author=False)

tick_print(f"Setting '{name}' as an author.")
Expand Down
Loading