Skip to content

Commit

Permalink
feat: typer help text from docstring
Browse files Browse the repository at this point in the history
  • Loading branch information
kiyoon committed Dec 25, 2024
1 parent 76d4f58 commit 2c00c0e
Show file tree
Hide file tree
Showing 13 changed files with 134 additions and 7 deletions.
2 changes: 1 addition & 1 deletion deps/lock/aarch64-apple-darwin/.requirements.in.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5eff1dc8175ed5e2d809a694bc2a3e212dbca386f32b1565a18e5c22058fa941 requirements.in
1763ca8d914cd3a965a738deea92337b4b4d86fe55942de08eb52cb600954c86 requirements.in
2 changes: 2 additions & 0 deletions deps/lock/aarch64-apple-darwin/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# uv pip compile requirements.in -o /Users/kiyoon/project/python-project-template-2024/deps/lock/aarch64-apple-darwin/requirements.txt --python-platform aarch64-apple-darwin --python-version 3.10
click==8.1.7
# via typer
docstring-parser==0.16
# via -r requirements.in
markdown-it-py==3.0.0
# via rich
mdurl==0.1.2
Expand Down
2 changes: 2 additions & 0 deletions deps/lock/aarch64-apple-darwin/requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ cycler==0.12.1
# via matplotlib
defusedxml==0.7.1
# via cairosvg
docstring-parser==0.16
# via -r requirements.in
exceptiongroup==1.2.2
# via pytest
fonttools==4.53.0
Expand Down
2 changes: 1 addition & 1 deletion deps/lock/x86_64-apple-darwin/.requirements.in.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5eff1dc8175ed5e2d809a694bc2a3e212dbca386f32b1565a18e5c22058fa941 requirements.in
1763ca8d914cd3a965a738deea92337b4b4d86fe55942de08eb52cb600954c86 requirements.in
2 changes: 2 additions & 0 deletions deps/lock/x86_64-apple-darwin/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# uv pip compile requirements.in -o /Users/kiyoon/project/python-project-template-2024/deps/lock/x86_64-apple-darwin/requirements.txt --python-platform x86_64-apple-darwin --python-version 3.10
click==8.1.7
# via typer
docstring-parser==0.16
# via -r requirements.in
markdown-it-py==3.0.0
# via rich
mdurl==0.1.2
Expand Down
2 changes: 2 additions & 0 deletions deps/lock/x86_64-apple-darwin/requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ cycler==0.12.1
# via matplotlib
defusedxml==0.7.1
# via cairosvg
docstring-parser==0.16
# via -r requirements.in
exceptiongroup==1.2.2
# via pytest
fonttools==4.53.0
Expand Down
2 changes: 1 addition & 1 deletion deps/lock/x86_64-manylinux_2_28/.requirements.in.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5eff1dc8175ed5e2d809a694bc2a3e212dbca386f32b1565a18e5c22058fa941 requirements.in
1763ca8d914cd3a965a738deea92337b4b4d86fe55942de08eb52cb600954c86 requirements.in
2 changes: 2 additions & 0 deletions deps/lock/x86_64-manylinux_2_28/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# uv pip compile requirements.in -o /Users/kiyoon/project/python-project-template-2024/deps/lock/x86_64-manylinux_2_28/requirements.txt --python-platform x86_64-manylinux_2_28 --python-version 3.10
click==8.1.7
# via typer
docstring-parser==0.16
# via -r requirements.in
markdown-it-py==3.0.0
# via rich
mdurl==0.1.2
Expand Down
2 changes: 2 additions & 0 deletions deps/lock/x86_64-manylinux_2_28/requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ cycler==0.12.1
# via matplotlib
defusedxml==0.7.1
# via cairosvg
docstring-parser==0.16
# via -r requirements.in
exceptiongroup==1.2.2
# via pytest
fonttools==4.53.0
Expand Down
1 change: 1 addition & 0 deletions deps/requirements.in
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
rich>=13.0.0
tqdm>=4.0.0
typer>=0.12.4 # python 3.11 `SomeType | None` support
docstring_parser # typer help text from_docstring
python-dotenv
platformdirs
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ ml_project = ["template.env"] # CHANGE (name of the importing module name)
[tool.setuptools.packages.find]
where = ["src"]

[tool.projector.pip_compile]
[tool.projector.pip-compile]
# https://github.com/deargen/workflows/blob/master/python-projector
requirements_in_dir = "deps"
requirements_out_dir = "deps/lock"
python_platforms = ["x86_64-manylinux_2_28", "aarch64-apple-darwin", "x86_64-apple-darwin"]
requirements-in-dir = "deps"
requirements-out-dir = "deps/lock"
python-platforms = ["x86_64-manylinux_2_28", "aarch64-apple-darwin", "x86_64-apple-darwin"]

[tool.pytest.ini_options]
addopts = "--cov=ml_project" # CHANGE (name of the importing module name)
Expand Down
105 changes: 105 additions & 0 deletions src/ml_project/cli/docstring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# https://github.com/fastapi/typer/issues/336#issuecomment-2434726193
# Add typer help text from docstring
import inspect
from collections.abc import Callable
from functools import wraps
from typing import Annotated, Any, get_args, get_origin

import docstring_parser
import typer


def from_docstring(command: Callable) -> Callable:
"""
A decorator that applies help texts from the function's docstring to Typer arguments/options.
It will only apply the help text if no help text has been explicitly set in the Typer argument/option.
Args:
command (Callable): The function to decorate.
Returns:
Callable: The decorated function with help texts applied, without overwriting existing settings.
"""
if command.__doc__ is None:
return command

# Parse the docstring and extract parameter descriptions
docstring = docstring_parser.parse(command.__doc__)
param_help = {param.arg_name: param.description for param in docstring.params}

# The commands's full help text (summary + long description)
command_help = (
f"{docstring.short_description or ''}\n\n{docstring.long_description or ''}"
)

# Get the signature of the original function
sig = inspect.signature(command)
parameters = sig.parameters

@wraps(command)
def wrapper(**kwargs: Any) -> Any:
return command(**kwargs)

# Prepare a new mapping for parameters
new_parameters = []

for name, param in parameters.items():
help_text = param_help.get(
name, ""
) # Get help text from docstring if available

param_type = (
param.annotation if param.annotation is not inspect.Parameter.empty else str
) # Default to str if no annotation

# Handle Annotated (e.g., Annotated[int, typer.Argument()] or Annotated[str, typer.Option()])
# Check if the parameter uses Annotated
if get_origin(param_type) is Annotated:
param_type, *metadata = get_args(param_type)
# Iterate through the metadata to find Typer's Argument or Option
new_metadata = []
for m in metadata:
if isinstance(m, typer.models.ArgumentInfo | typer.models.OptionInfo): # noqa: SIM102
# Only add help text if it's not already set
if not m.help:
m.help = help_text
new_metadata.append(m)

# Rebuild the annotated type with updated metadata (python 3.11)
# new_param = param.replace(annotation=Annotated[param_type, *new_metadata])
# for python 3.8, this is not perfect ...
new_param = param.replace(annotation=Annotated[param_type, new_metadata[0]])

# If it's an Option or Argument directly (e.g., a: int = typer.Option(...))
elif isinstance(
param.default, typer.models.ArgumentInfo | typer.models.OptionInfo
):
if not param.default.help:
param.default.help = help_text
new_param = param

else: # noqa: PLR5501
# If the parameter has no default, treat it as an Argument
if param.default is inspect.Parameter.empty:
new_param = param.replace(
default=typer.Argument(..., help=help_text), annotation=param_type
)
else:
# If the parameter has a default, treat it as an Option
new_param = param.replace(
default=typer.Option(param.default, help=help_text),
annotation=param_type,
)

new_parameters.append(new_param)

# Create a new signature with updated parameters
new_sig = sig.replace(parameters=new_parameters)

# Apply the new signature to the wrapper function
wrapper.__signature__ = new_sig

wrapper.__doc__ = command_help.strip()

return wrapper
9 changes: 9 additions & 0 deletions src/ml_project/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from rich.prompt import Prompt
from rich.syntax import Syntax

from .docstring import from_docstring

app = typer.Typer(
no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}
)
Expand All @@ -35,6 +37,9 @@ def common(

@app.command()
def health():
"""
Check the health of the environment, like binaries and environment variables.
"""
from .. import setup_logging
from ..health import main as health_main

Expand All @@ -43,9 +48,13 @@ def health():


@app.command()
@from_docstring
def config(config_dir: Path | None = None):
"""
Copy the template .env file to the config directory.
Args:
config_dir: `.env` dir. Default is either project root or user config dir (~/.config/{APPNAME}).
"""
from dotenv import dotenv_values, set_key
from platformdirs import user_config_path
Expand Down

0 comments on commit 2c00c0e

Please sign in to comment.