refactor template
This commit is contained in:
@@ -1,3 +1,12 @@
|
||||
"""{{ cookiecutter.project_name }} - {{ cookiecutter.description }}"""
|
||||
|
||||
__version__ = "{{ cookiecutter.version }}"
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
try:
|
||||
__version__ = version("{{ cookiecutter.project_slug }}")
|
||||
except PackageNotFoundError: # pragma: no cover - only in editable/uninstalled mode
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""CLI package for {{ cookiecutter.project_name }}."""
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from {{ cookiecutter.package_name }}.cli.config import app as config_app
|
||||
from {{ cookiecutter.package_name }}.cli.hello import app as hello_app
|
||||
from {{ cookiecutter.package_name }}.utils.logging import setup_logging
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
app.add_typer(config_app, name="config")
|
||||
app.add_typer(hello_app, name="hello")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
version: Annotated[
|
||||
bool,
|
||||
typer.Option("--version", "-v", help="Show version and exit."),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Run the CLI application."""
|
||||
setup_logging()
|
||||
if version:
|
||||
from importlib.metadata import version as get_version
|
||||
|
||||
print(get_version("{{ cookiecutter.project_slug }}"))
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from {{ cookiecutter.package_name }}.config import get_config
|
||||
from {{ cookiecutter.package_name }}.config.settings import Settings
|
||||
|
||||
|
||||
def load_config() -> Settings:
|
||||
"""Load configuration once at the CLI boundary."""
|
||||
return get_config()
|
||||
|
||||
|
||||
def exit_with_error(message: str, code: int = 1) -> None:
|
||||
"""Print a concise error and exit with a non-zero status."""
|
||||
typer.secho(message, fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code)
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from {{ cookiecutter.package_name }}.cli.common import exit_with_error
|
||||
from {{ cookiecutter.package_name }}.config import get_config, get_config_source
|
||||
from {{ cookiecutter.package_name }}.config.paths import config_file_path
|
||||
from {{ cookiecutter.package_name }}.config.settings import write_default_config
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def show() -> None:
|
||||
"""Print the effective configuration and source path."""
|
||||
try:
|
||||
config = get_config()
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
exit_with_error(str(exc))
|
||||
return
|
||||
|
||||
source = get_config_source()
|
||||
payload = {
|
||||
"source": str(source) if source else "<defaults>",
|
||||
"config": config.model_dump(),
|
||||
}
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
@app.command()
|
||||
def init(
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--path",
|
||||
help="Write the default config to a specific path.",
|
||||
),
|
||||
] = None,
|
||||
overwrite: Annotated[
|
||||
bool,
|
||||
typer.Option("--overwrite", help="Overwrite existing file."),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Write a default config to the XDG location."""
|
||||
target = path or config_file_path()
|
||||
if target.exists() and not overwrite:
|
||||
exit_with_error(f"Config already exists at {target}. Use --overwrite to replace it.")
|
||||
write_default_config(target)
|
||||
typer.secho(f"Wrote default config to {target}", fg=typer.colors.GREEN)
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from {{ cookiecutter.package_name }}.commands import format_greeting
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def say(
|
||||
name: Annotated[str, typer.Option("--name", "-n", help="Name to greet.")] = "World",
|
||||
) -> None:
|
||||
"""Print a greeting."""
|
||||
print(format_greeting(name))
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Command implementations for {{ cookiecutter.project_name }}."""
|
||||
|
||||
from {{ cookiecutter.package_name }}.commands.hello import format_greeting
|
||||
|
||||
__all__ = ["format_greeting"]
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def format_greeting(name: str) -> str:
|
||||
"""Return a greeting message."""
|
||||
return f"Hello, {name}!"
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from .settings import Settings, load_settings
|
||||
|
||||
CONFIG_SOURCE: Path | None = None
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_config() -> Settings:
|
||||
"""Return the cached Settings instance."""
|
||||
global CONFIG_SOURCE
|
||||
settings, source = load_settings()
|
||||
CONFIG_SOURCE = source
|
||||
return settings
|
||||
|
||||
|
||||
def get_config_source() -> Path | None:
|
||||
"""Return the path used to load config, if any."""
|
||||
return CONFIG_SOURCE
|
||||
|
||||
|
||||
def clear_config_cache() -> None:
|
||||
"""Clear cached settings for tests or reload scenarios."""
|
||||
global CONFIG_SOURCE
|
||||
CONFIG_SOURCE = None
|
||||
get_config.cache_clear()
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
APP_NAME = "{{ cookiecutter.project_slug }}"
|
||||
ENV_CONFIG_VAR = "{{ cookiecutter.package_name | upper }}_CONFIG"
|
||||
|
||||
|
||||
def xdg_config_dir() -> Path:
|
||||
"""Return the XDG config directory."""
|
||||
root = os.environ.get("XDG_CONFIG_HOME")
|
||||
return Path(root) if root else Path.home() / ".config"
|
||||
|
||||
|
||||
def xdg_data_dir() -> Path:
|
||||
"""Return the XDG data directory."""
|
||||
root = os.environ.get("XDG_DATA_HOME")
|
||||
return Path(root) if root else Path.home() / ".local" / "share"
|
||||
|
||||
|
||||
def config_file_path() -> Path:
|
||||
"""Return the default config file path."""
|
||||
return xdg_config_dir() / APP_NAME / "config.toml"
|
||||
|
||||
|
||||
def data_dir_path() -> Path:
|
||||
"""Return the default data directory path."""
|
||||
return xdg_data_dir() / APP_NAME
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .paths import ENV_CONFIG_VAR, config_file_path
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
model_config = {"frozen": True, "extra": "forbid"}
|
||||
|
||||
greeting: str = "Hello"
|
||||
|
||||
|
||||
def load_settings() -> tuple[Settings, Path | None]:
|
||||
"""Load settings and return the source path, if any."""
|
||||
env_path = os.environ.get(ENV_CONFIG_VAR)
|
||||
if env_path:
|
||||
path = Path(env_path).expanduser()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {path}")
|
||||
data = tomllib.loads(path.read_text())
|
||||
return Settings.model_validate(data), path
|
||||
|
||||
default_path = config_file_path()
|
||||
if default_path.exists():
|
||||
data = tomllib.loads(default_path.read_text())
|
||||
return Settings.model_validate(data), default_path
|
||||
|
||||
return Settings(), None
|
||||
|
||||
|
||||
def write_default_config(path: Path) -> None:
|
||||
"""Write default config to a file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = Settings().model_dump()
|
||||
content = _to_toml(payload)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
def _to_toml(payload: dict[str, object]) -> str:
|
||||
lines: list[str] = []
|
||||
for section, values in payload.items():
|
||||
if isinstance(values, dict):
|
||||
lines.append(f"[{section}]")
|
||||
for key, value in values.items():
|
||||
lines.append(f"{key} = {_toml_value(value)}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"{section} = {_toml_value(values)}")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _toml_value(value: object) -> str:
|
||||
if isinstance(value, str):
|
||||
escaped = value.replace("\\", "\\\\").replace('"', "\\\"")
|
||||
return f'"{escaped}"'
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
raise TypeError(f"Unsupported TOML value: {value!r}")
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Core functionality for {{ cookiecutter.project_name }}."""
|
||||
|
||||
|
||||
def say_hello(name: str = "World") -> None:
|
||||
"""Print a hello message."""
|
||||
print(f"Hello, {name}!")
|
||||
@@ -0,0 +1 @@
|
||||
"""Data models for {{ cookiecutter.project_name }}."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility helpers for {{ cookiecutter.project_name }}."""
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import coloredlogs # type: ignore[import-untyped]
|
||||
|
||||
|
||||
def setup_logging(level: str | None = None) -> None:
|
||||
"""Configure application logging."""
|
||||
level = level or os.environ.get("LOGLEVEL", "INFO")
|
||||
coloredlogs.install(
|
||||
level=level,
|
||||
fmt="%(asctime)s %(name)s %(levelname)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
Reference in New Issue
Block a user