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

1import importlib 

2import json 

3import os 

4import pathlib 

5import sys 

6import threading 

7import time 

8import urllib.request 

9from typing import Annotated 

10 

11import packaging.version 

12import typer 

13from rich import print 

14 

15from rendercv import __version__ 

16 

17VERSION_CHECK_TTL_SECONDS = 86400 # 24 hours 

18 

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) 

25 

26 

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() 

38 

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() 

45 

46 

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" 

60 

61 

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" 

65 

66 

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 

76 

77 

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 

89 

90 

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 

102 

103 

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) 

109 

110 

111def warn_if_new_version_is_available() -> None: 

112 """Check for a newer RenderCV version using a stale-while-revalidate cache. 

113 

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() 

120 

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() 

124 

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 

137 

138 

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" 

145 

146 # Build full module path: <parent_pkg>.foo_command.foo_command 

147 full_module = f"{__package__}.{folder_name}.{py_file_name}" 

148 

149 module = importlib.import_module(full_module)