Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ 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:
- "--output-file=docs/functions.txt"
- "--source-root=src/usethis"
- "--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.

140 changes: 140 additions & 0 deletions hooks/export-functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Export public module-level functions with docstrings from a Python package.

Recursively scans all Python source files under a package root directory for
public module-level functions with docstrings and writes a flat markdown bullet
list to an output file. Functions are listed in the order they appear in each
file, with files processed in sorted order. Class methods and nested functions
are not included. Functions without a docstring are skipped unless --strict is
used, in which case the script exits non-zero when any are found.
"""

from __future__ import annotations

import argparse
import ast
import sys
from pathlib import Path


def _module_name(source_file: Path, source_root: Path) -> str:
"""Derive a dotted module name from a file path, including the package name."""
# Use source_root.parent so the package directory name itself is part of the path.
rel = source_file.relative_to(source_root.parent)
parts = rel.with_suffix("").parts
# Drop __init__ from the tail so the module name matches the package path.
if parts and parts[-1] == "__init__":
parts = parts[:-1]
return ".".join(parts)


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

Only direct children of the module node are included (no class methods or
nested functions). 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 not None:
first_line = docstring.split("\n")[0].strip()
# Normalize RST-style double backticks to markdown single backticks
# so the output is compatible with prettier's markdown formatting.
first_line = first_line.replace("``", "`")
results.append((node.name, first_line if first_line else None))
else:
results.append((node.name, None))

return results


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 main() -> int:
parser = argparse.ArgumentParser(
description="Export public function reference from a Python package.",
)
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 for public functions.",
)
parser.add_argument(
"--strict",
action="store_true",
default=False,
help="Fail if any public module-level function lacks 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

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

for py_file in _collect_py_files(source_root):
try:
module = _module_name(py_file, source_root)
except ValueError:
print(
f"ERROR: {py_file} is not relative to source root {source_root}.",
file=sys.stderr,
)
return 1

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

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" - {path}:{name}", 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 being 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 the named 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 the named 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 being 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 it 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 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 the project and available via uv."""
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 version string of the installed uv tool."""
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]:
"""Context manager that opens all configuration file managers for coordinated I/O."""
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 message to the console, respecting quiet and alert-only settings."""
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, respecting quiet and alert-only settings."""
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 entry to the project metadata in pyproject.toml."""
ensure_pyproject_toml(author=False)

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