simplify repo

This commit is contained in:
Jev
2026-02-17 21:59:50 +01:00
parent 46e0ef9de7
commit 1f3c026b5b
23 changed files with 46 additions and 558 deletions
@@ -4,12 +4,7 @@ from __future__ import annotations
from importlib.metadata import PackageNotFoundError, version
from {{ cookiecutter.package_name }}.config import get_config
from {{ cookiecutter.package_name }}.config.settings import Settings
try:
__version__ = version("{{ cookiecutter.project_slug }}")
except PackageNotFoundError: # pragma: no cover - only in editable/uninstalled mode
except PackageNotFoundError: # pragma: no cover
__version__ = "0.0.0"
__all__ = ["Settings", "__version__", "get_config"]
@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Annotated
import typer
app = typer.Typer(no_args_is_help=True)
def _version_callback(value: bool) -> None:
if value:
from importlib.metadata import version
print(version("{{ cookiecutter.project_slug }}"))
raise typer.Exit()
@app.callback()
def main(
_version: Annotated[
bool,
typer.Option("--version", "-v", help="Show version and exit.", callback=_version_callback),
] = False,
) -> None:
"""{{ cookiecutter.description }}"""
@app.command()
def hello(name: str = "world") -> None:
"""Say hello."""
print(f"Hello, {name}!")
@@ -1 +0,0 @@
"""CLI package for {{ cookiecutter.project_name }}."""
@@ -1,33 +0,0 @@
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()
@@ -1,24 +0,0 @@
from __future__ import annotations
import tomllib
from typing import NoReturn
import typer
from pydantic import ValidationError
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."""
try:
return get_config()
except (FileNotFoundError, tomllib.TOMLDecodeError, ValidationError) as exc:
exit_with_error(str(exc))
def exit_with_error(message: str, code: int = 1) -> NoReturn:
"""Print a concise error and exit with a non-zero status."""
typer.secho(message, fg=typer.colors.RED, err=True)
raise typer.Exit(code)
@@ -1,48 +0,0 @@
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, load_config
from {{ cookiecutter.package_name }}.config import 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."""
config = load_config()
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)
@@ -1,24 +0,0 @@
from __future__ import annotations
from typing import Annotated
import typer
from {{ cookiecutter.package_name }}.cli.common import load_config
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",
greeting: Annotated[
str | None,
typer.Option("--greeting", "-g", help="Greeting prefix; overrides config value."),
] = None,
) -> None:
"""Print a greeting."""
config = load_config()
message = format_greeting(name=name, greeting=greeting or config.app.greeting)
print(message)
@@ -1,5 +0,0 @@
"""Command implementations for {{ cookiecutter.project_name }}."""
from {{ cookiecutter.package_name }}.commands.hello import format_greeting
__all__ = ["format_greeting"]
@@ -1,6 +0,0 @@
from __future__ import annotations
def format_greeting(name: str, greeting: str = "Hello") -> str:
"""Return a greeting message."""
return f"{greeting}, {name}!"
@@ -1,29 +0,0 @@
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()
@@ -1,29 +0,0 @@
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
@@ -1,98 +0,0 @@
from __future__ import annotations
import os
import tomllib
from pathlib import Path
from pydantic import BaseModel, Field
from .paths import ENV_CONFIG_VAR, config_file_path, data_dir_path
class AppConfig(BaseModel):
model_config = {"frozen": True, "extra": "forbid"}
greeting: str = "Hello"
class DatabaseConfig(BaseModel):
model_config = {"frozen": True, "extra": "forbid"}
path: str = Field(default_factory=lambda: str(data_dir_path() / "data.db"))
class Settings(BaseModel):
model_config = {"frozen": True, "extra": "forbid"}
app: AppConfig = AppConfig()
database: DatabaseConfig = DatabaseConfig()
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 key, value in payload.items():
if isinstance(value, dict):
_write_table(lines, key, value)
else:
lines.append(f"{key} = {_toml_value(value)}")
return "\n".join(lines).rstrip() + "\n"
def _write_table(lines: list[str], table_name: str, values: dict[str, object]) -> None:
lines.append(f"[{table_name}]")
scalar_items: list[tuple[str, object]] = []
nested_items: list[tuple[str, dict[str, object]]] = []
for key, value in values.items():
if isinstance(value, dict):
nested_items.append((key, value))
else:
scalar_items.append((key, value))
for key, value in scalar_items:
lines.append(f"{key} = {_toml_value(value)}")
for key, nested in nested_items:
lines.append("")
_write_table(lines, f"{table_name}.{key}", nested)
lines.append("")
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 +0,0 @@
"""Data models for {{ cookiecutter.project_name }}."""
@@ -1 +0,0 @@
"""Utility helpers for {{ cookiecutter.project_name }}."""
@@ -1,17 +0,0 @@
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)