Click CLI patterns: command groups, options/arguments, Jinja2 templating, atomic file operations, pyproject.toml packaging, and CliRunner testing.
Provides Click-based CLI implementation patterns for framework-less Python tools. Agents reference this pack when generating code for Python CLI applications using Click, Jinja2, and PyYAML. All examples target Python 3.9+ compatibility (from __future__ import annotations mandatory, no match/case, no X | Y at runtime).
Supplements architecture and layer-templates knowledge packs with Click CLI-specific conventions.
my_cli/
├── pyproject.toml
├── src/
│ └── my_cli/
│ ├── __init__.py
│ ├── __main__.py # python -m my_cli entrypoint
│ ├── cli.py # Click groups and commands (thin layer)
│ ├── commands/ # One module per command group
│ │ ├── __init__.py
│ │ ├── init_cmd.py
│ │ ├── build_cmd.py
│ │ └── validate_cmd.py
│ ├── core/ # Business logic (no Click dependency)
│ │ ├── __init__.py
│ │ ├── config.py # Configuration loading
│ │ ├── renderer.py # Jinja2 template rendering
│ │ ├── file_ops.py # Atomic file operations
│ │ └── validator.py # Validation rules
│ └── templates/ # Jinja2 templates (package data)
│ ├── dockerfile.j2
│ └── config.yaml.j2
├── tests/
│ ├── conftest.py
│ ├── test_cli.py
│ ├── commands/
│ │ └── test_init_cmd.py
│ └── core/
│ ├── test_config.py
│ └── test_renderer.py
└── README.md
__main__.pyfrom __future__ import annotations
from my_cli.cli import main
if __name__ == "__main__":
main()
pyproject.toml (Complete)[build-system]
requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "my-cli"
version = "1.0.0"
requires-python = ">=3.9"
dependencies = [
"click>=8.1",
"jinja2>=3.1",
"pyyaml>=6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"ruff>=0.4",
"mypy>=1.10",
]
[project.scripts]
my-cli = "my_cli.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
my_cli = ["templates/*.j2"]
[tool.ruff]
target-version = "py39"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "TCH"]
[tool.mypy]
python_version = "3.9"
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
filterwarnings = ["error", "ignore::DeprecationWarning"]
| Layer | Responsibility | Click Dependency |
|---|---|---|
cli.py | Define groups, wire commands | YES |
commands/ | Parse CLI args, call core, format output | YES |
core/ | Business logic, file I/O, rendering | NO |
templates/ | Jinja2 template files | NO |
Golden Rule: core/ NEVER imports from click. All Click-specific code stays in cli.py and commands/.
from __future__ import annotations
from pathlib import Path
from typing import Optional
import click
from my_cli.core.config import CliConfig, load_config
@click.group()
@click.option(
"--config",
"-c",
"config_path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=None,
help="Path to configuration file.",
)
@click.option("--verbose", "-v", is_flag=True, default=False, help="Enable verbose output.")
@click.version_option(package_name="my-cli")
@click.pass_context
def main(ctx: click.Context, config_path: Optional[Path], verbose: bool) -> None:
"""My CLI tool — generates project scaffolding."""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
ctx.obj["config"] = load_config(config_path)
from __future__ import annotations
from pathlib import Path
import click
@click.command()
@click.argument("project_name")
@click.option(
"--output-dir",
"-o",
type=click.Path(file_okay=False, path_type=Path),
default=Path("."),
help="Output directory.",
)
@click.option(
"--template",
"-t",
type=click.Choice(["minimal", "standard", "full"], case_sensitive=False),
default="standard",
help="Project template to use.",
)
@click.option("--dry-run", is_flag=True, default=False, help="Show what would be created.")
@click.pass_context
def init(
ctx: click.Context,
project_name: str,
output_dir: Path,
template: str,
dry_run: bool,
) -> None:
"""Initialize a new project from template."""
config: CliConfig = ctx.obj["config"]
verbose: bool = ctx.obj["verbose"]
target = output_dir / project_name
if target.exists() and not dry_run:
raise click.ClickException(f"Directory already exists: {target}")
result = scaffold_project(
name=project_name,
target=target,
template_name=template,
config=config,
dry_run=dry_run,
)
if verbose:
for file_path in result.created_files:
click.echo(f" Created: {file_path}")
click.secho(f"Project 'my-cli-tool' initialized.", fg="green")
from __future__ import annotations
from my_cli.commands.init_cmd import init
from my_cli.commands.build_cmd import build
from my_cli.commands.validate_cmd import validate
main.add_command(init)
main.add_command(build)
main.add_command(validate)
from __future__ import annotations
import re
import click
def validate_project_name(
ctx: click.Context,
param: click.Parameter,
value: str,
) -> str:
if not re.match(r"^[a-z][a-z0-9_-]*$", value):
raise click.BadParameter(
f"Must start with lowercase letter, "
f"contain only [a-z0-9_-]: {value}"
)
return value
@click.command()
@click.argument("name", callback=validate_project_name)
def init(name: str) -> None:
"""Initialize project."""
...
from __future__ import annotations
import click
@click.command()
@click.option(
"--author",
prompt="Author name",
help="Author name for project metadata.",
)
@click.option(
"--license",
"license_type",
type=click.Choice(["MIT", "Apache-2.0", "GPL-3.0"]),
prompt="License type",
default="MIT",
help="License type.",
)
def init(author: str, license_type: str) -> None:
"""Initialize project with author info."""
...
from __future__ import annotations
import click
@click.command()
@click.argument("target")
@click.option("--force", is_flag=True, default=False, help="Skip confirmation.")
def clean(target: str, force: bool) -> None:
"""Remove generated files."""
if not force:
click.confirm(f"Delete all files in '{target}'?", abort=True)
perform_clean(target)
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import yaml
@dataclass(frozen=True)
class TemplateConfig:
name: str
source_dir: Path
variables: dict[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
class CliConfig:
project_root: Path
templates: list[TemplateConfig] = field(default_factory=list)
default_template: str = "standard"
author: str = ""
license_type: str = "MIT"
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
import click
import yaml
from my_cli.core.config import CliConfig, TemplateConfig
CONFIG_FILENAME = "my-cli.yaml"
def load_config(config_path: Optional[Path] = None) -> CliConfig:
"""Load config: defaults → file → env → CLI args."""
raw = _load_defaults()
file_path = config_path or _find_config_file()
if file_path is not None:
raw = _merge(raw, _load_yaml(file_path))
raw = _apply_env_overrides(raw)
return _parse_config(raw)
def _load_defaults() -> dict[str, object]:
return {
"default_template": "standard",
"author": "",
"license_type": "MIT",
"templates": [],
}
def _find_config_file() -> Optional[Path]:
"""Search CWD, then app config dir."""
cwd_config = Path.cwd() / CONFIG_FILENAME
if cwd_config.is_file():
return cwd_config
app_dir = Path(click.get_app_dir("my-cli"))
global_config = app_dir / CONFIG_FILENAME
if global_config.is_file():
return global_config
return None
def _load_yaml(path: Path) -> dict[str, object]:
with path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise click.ClickException(
f"Config must be a YAML mapping: {path}"
)
return data
def _apply_env_overrides(
raw: dict[str, object],
) -> dict[str, object]:
env_author = os.environ.get("MY_CLI_AUTHOR")
if env_author is not None:
raw["author"] = env_author
env_license = os.environ.get("MY_CLI_LICENSE")
if env_license is not None:
raw["license_type"] = env_license
return raw
def _merge(
base: dict[str, object],
override: dict[str, object],
) -> dict[str, object]:
result = {**base}
result.update(override)
return result
def _parse_config(raw: dict[str, object]) -> CliConfig:
templates = [
TemplateConfig(
name=t["name"],
source_dir=Path(t["source_dir"]),
variables=t.get("variables", {}),
)
for t in raw.get("templates", [])
]
return CliConfig(
project_root=Path.cwd(),
templates=templates,
default_template=str(raw.get("default_template", "standard")),
author=str(raw.get("author", "")),
license_type=str(raw.get("license_type", "MIT")),
)
# my-cli.yaml
default_template: standard