Coverage for src / rendercv / cli / app.py: 100%
80 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
1import importlib
2import json
3import os
4import pathlib
5import sys
6import threading
7import time
8import urllib.request
9from typing import Annotated
11import packaging.version
12import typer
13from rich import print
15from rendercv import __version__
17VERSION_CHECK_TTL_SECONDS = 86400 # 24 hours
19app = typer.Typer(
20 rich_markup_mode="rich",
21 # to make `rendercv --version` work:
22 invoke_without_command=True,
23 context_settings={"help_option_names": ["-h", "--help"]},
24)
27@app.callback()
28def cli_command_no_args(
29 ctx: typer.Context,
30 version_requested: Annotated[
31 bool | None, typer.Option("--version", "-v", help="Show the version")
32 ] = None,
33):
34 """RenderCV is a command-line tool for rendering CVs from YAML input files. For more
35 information, see https://docs.rendercv.com.
36 """
37 warn_if_new_version_is_available()
39 if version_requested:
40 print(f"RenderCV v{__version__}")
41 elif ctx.invoked_subcommand is None:
42 # No command was provided, show help
43 print(ctx.get_help())
44 raise typer.Exit()
47def get_cache_dir() -> pathlib.Path:
48 """Return the platform-appropriate cache directory for RenderCV."""
49 if sys.platform == "win32":
50 base = pathlib.Path(
51 os.environ.get("LOCALAPPDATA", pathlib.Path.home() / "AppData" / "Local")
52 )
53 elif sys.platform == "darwin":
54 base = pathlib.Path.home() / "Library" / "Caches"
55 else:
56 base = pathlib.Path(
57 os.environ.get("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")
58 )
59 return base / "rendercv"
62def get_version_cache_file() -> pathlib.Path:
63 """Return the path to the version check cache file."""
64 return get_cache_dir() / "version_check.json"
67def read_version_cache() -> dict | None:
68 """Read the cached version check data, or None if unavailable/corrupt."""
69 try:
70 data = json.loads(get_version_cache_file().read_text(encoding="utf-8"))
71 if isinstance(data, dict) and "last_check" in data and "latest_version" in data:
72 return data
73 except (OSError, json.JSONDecodeError, KeyError):
74 pass
75 return None
78def write_version_cache(version_string: str) -> None:
79 """Write the latest version string and current timestamp to the cache file."""
80 cache_file = get_version_cache_file()
81 try:
82 cache_file.parent.mkdir(parents=True, exist_ok=True)
83 cache_file.write_text(
84 json.dumps({"last_check": time.time(), "latest_version": version_string}),
85 encoding="utf-8",
86 )
87 except OSError:
88 pass
91def fetch_latest_version_from_pypi() -> str | None:
92 """Fetch the latest RenderCV version string from PyPI, or None on failure."""
93 url = "https://pypi.org/pypi/rendercv/json"
94 try:
95 with urllib.request.urlopen(url, timeout=5) as response:
96 data = response.read()
97 encoding = response.info().get_content_charset("utf-8")
98 json_data = json.loads(data.decode(encoding))
99 return json_data["info"]["version"]
100 except (OSError, json.JSONDecodeError, KeyError, ValueError):
101 return None
104def fetch_and_cache_latest_version() -> None:
105 """Fetch the latest version from PyPI and write it to the cache file."""
106 version_string = fetch_latest_version_from_pypi()
107 if version_string:
108 write_version_cache(version_string)
111def warn_if_new_version_is_available() -> None:
112 """Check for a newer RenderCV version using a stale-while-revalidate cache.
114 Why:
115 Uses a disk cache with background refresh so the CLI never blocks on
116 network I/O. If the cache is stale or missing, a daemon thread refreshes
117 it for the next invocation.
118 """
119 cache = read_version_cache()
121 if not cache or (time.time() - cache["last_check"]) >= VERSION_CHECK_TTL_SECONDS:
122 thread = threading.Thread(target=fetch_and_cache_latest_version, daemon=True)
123 thread.start()
125 if cache:
126 try:
127 latest = packaging.version.Version(cache["latest_version"])
128 current = packaging.version.Version(__version__)
129 if current < latest:
130 print(
131 "\n[bold yellow]A new version of RenderCV is available!"
132 f" You are using v{__version__}, and the latest version"
133 f" is v{latest}.[/bold yellow]\n"
134 )
135 except packaging.version.InvalidVersion:
136 pass
139# Auto import all commands so that they are registered with the app:
140cli_folder_path = pathlib.Path(__file__).parent
141for file in cli_folder_path.rglob("*_command.py"):
142 # Enforce folder structure: ./name_command/name_command.py
143 folder_name = file.parent.name # e.g. "foo_command"
144 py_file_name = file.stem # e.g. "foo_command"
146 # Build full module path: <parent_pkg>.foo_command.foo_command
147 full_module = f"{__package__}.{folder_name}.{py_file_name}"
149 module = importlib.import_module(full_module)