Coverage for src/ph/app.py: 95.3%

166 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-28 08:17 +0000

1"""Application entry point and non-interactive automation commands.""" 

2 

3import argparse 

4import curses 

5import locale 

6import logging 

7import sys 

8from collections.abc import Mapping, Sequence 

9from dataclasses import replace 

10from importlib.metadata import PackageNotFoundError, version 

11from pathlib import Path 

12 

13from ph.cache_policy import catalogue_ttl_seconds 

14from ph.compatibility import filter_supported_results 

15from ph.config import Config 

16from ph.downloader import DownloadError, download_files 

17from ph.frontend import request_game_frontend_refresh 

18from ph.logging_config import configure_logging_with_fallback 

19from ph.models import SearchResult 

20from ph.organizer import OrganizeError, detect_roms_directory, install_downloads 

21from ph.platforms import platform_catalogue, resolve_platform 

22from ph.preferences import load_preferences, preference_path 

23from ph.store import GameStore, StoreError 

24from ph.store_catalog import StoreCatalog 

25from ph.targets import DARKOS, LinuxTarget, TargetError 

26from ph.tui import TerminalTooSmall, run_tui 

27 

28LOGGER = logging.getLogger(__name__) 

29 

30 

31def build_parser() -> argparse.ArgumentParser: 

32 try: 

33 app_version = version("pocket-harbor") 

34 except PackageNotFoundError: 

35 app_version = "development" 

36 parser = argparse.ArgumentParser( 

37 prog="ph", 

38 description="Linux handheld library manager. Run without a command to open the TUI.", 

39 ) 

40 parser.add_argument("--version", action="version", version=f"ph {app_version}") 

41 parser.add_argument("--vimm-url", help="override PH_VIMM_BASE_URL for this run") 

42 parser.add_argument( 

43 "--store", 

44 default="vimm", 

45 help="download store identifier for CLI commands (default: vimm)", 

46 ) 

47 subparsers = parser.add_subparsers(dest="command") 

48 

49 subparsers.add_parser("tui", help="open the full-screen controller interface") 

50 

51 search_parser = subparsers.add_parser("search", help="search without opening the TUI") 

52 search_parser.add_argument("console", help="platform slug, service code, or short alias") 

53 search_parser.add_argument( 

54 "query", 

55 nargs="*", 

56 help="optional title prefix; omit it to list the platform catalogue", 

57 ) 

58 

59 download_parser = subparsers.add_parser("download", help="download detail URLs") 

60 download_parser.add_argument("urls", nargs="+", help="one or more detail-page URLs") 

61 download_parser.add_argument( 

62 "--platform", 

63 help="move completed files to this platform's ROM folder", 

64 ) 

65 download_parser.add_argument( 

66 "--directory", 

67 type=Path, 

68 help="temporary download directory (defaults to PH_DOWNLOAD_DIR)", 

69 ) 

70 download_parser.add_argument( 

71 "--roms-directory", 

72 type=Path, 

73 help="ROM root (uses the selected OS target defaults when omitted)", 

74 ) 

75 return parser 

76 

77 

78def main( 

79 argv: Sequence[str] | None = None, 

80 *, 

81 runtime_config: Config | None = None, 

82 runtime_environment: Mapping[str, str] | None = None, 

83) -> int: 

84 parser = build_parser() 

85 arguments = parser.parse_args(argv) 

86 try: 

87 config = runtime_config or Config.from_environment(runtime_environment) 

88 except TargetError as error: 

89 print(f"ph: {error}", file=sys.stderr) 

90 return 2 

91 if arguments.vimm_url: 

92 config = replace(config, vimm_base_url=arguments.vimm_url.rstrip("/")) 

93 preferences = load_preferences(preference_path(config.download_directory)) 

94 if preferences.network_timeout_seconds is not None: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true

95 config = replace(config, timeout_seconds=preferences.network_timeout_seconds) 

96 log_to_file = ( 

97 config.log_file is not None if preferences.log_to_file is None else preferences.log_to_file 

98 ) 

99 log_file = ( 

100 config.log_file or config.download_directory / "pocket-harbor.log" if log_to_file else None 

101 ) 

102 configure_logging_with_fallback( 

103 log_file, 

104 preferences.log_level or config.log_level, 

105 config.download_directory / "pocket-harbor.log" if log_to_file else None, 

106 ) 

107 LOGGER.info("Starting Pocket Harbor command=%s", arguments.command or "tui") 

108 LOGGER.debug( 

109 "Runtime configuration target=%s stores=%s download_directory=%s " 

110 "roms_directories=%s timeout=%s", 

111 config.target.target_id, 

112 config.enabled_stores, 

113 config.download_directory, 

114 config.roms_directories, 

115 config.timeout_seconds, 

116 ) 

117 store_catalog = StoreCatalog.from_config( 

118 config, 

119 catalogue_ttl_seconds(preferences.catalogue_ttl_days), 

120 ) 

121 

122 if arguments.command in (None, "tui"): 

123 if not store_catalog.stores: 

124 LOGGER.error("No download stores are enabled") 

125 parser.error("no download stores are enabled; check PH_STORES") 

126 if not sys.stdin.isatty() or not sys.stdout.isatty(): 

127 LOGGER.error("TUI requested without an interactive terminal") 

128 parser.error("the TUI needs an interactive terminal; use a subcommand for automation") 

129 try: 

130 run_tui(config) 

131 except (TerminalTooSmall, StoreError, curses.error, locale.Error, ValueError) as error: 

132 LOGGER.error("TUI stopped: %s", error) 

133 print(f"ph: {error}", file=sys.stderr) 

134 return 2 

135 except Exception: 

136 LOGGER.exception("TUI stopped because of an unexpected error") 

137 print( 

138 "ph: An unexpected error stopped the TUI. Check the diagnostic log.", 

139 file=sys.stderr, 

140 ) 

141 return 2 

142 LOGGER.info("TUI closed normally") 

143 return 0 

144 store = store_catalog.find(arguments.store) 

145 if store is None: 

146 available = ", ".join(item.store_id for item in store_catalog.stores) or "none" 

147 LOGGER.error("Unknown store requested: %s", arguments.store) 

148 parser.error(f"unknown store {arguments.store!r}; available stores: {available}") 

149 if arguments.command == "search": 

150 return _run_search( 

151 store, 

152 arguments.console, 

153 " ".join(arguments.query), 

154 config.target, 

155 ) 

156 if arguments.command == "download": 156 ↛ 166line 156 didn't jump to line 166 because the condition on line 156 was always true

157 directory = arguments.directory or config.download_directory 

158 return _run_download( 

159 store, 

160 config, 

161 arguments.urls, 

162 directory, 

163 arguments.platform, 

164 arguments.roms_directory, 

165 ) 

166 parser.print_help() 

167 return 0 

168 

169 

170def _run_search( 

171 store: GameStore, 

172 console: str, 

173 query: str, 

174 target: LinuxTarget = DARKOS, 

175) -> int: 

176 platform = resolve_platform(console, platform_catalogue(target)) 

177 if platform is None: 

178 print(f"Unknown platform {console!r}.", file=sys.stderr) 

179 return 2 

180 if not store.supports_platform(platform): 

181 print(f"{store.display_name} does not provide {platform.name}.", file=sys.stderr) 

182 return 2 

183 try: 

184 LOGGER.info( 

185 "Searching store=%s platform=%s query=%r", store.store_id, platform.alias, query 

186 ) 

187 results = filter_supported_results(store.search(store.platform_code(platform), query)) 

188 except (StoreError, ValueError) as error: 

189 LOGGER.warning( 

190 "Search failed store=%s platform=%s: %s", store.store_id, platform.alias, error 

191 ) 

192 print(f"Search failed: {error}", file=sys.stderr) 

193 return 1 

194 if not results: 

195 LOGGER.info( 

196 "Search returned no results store=%s platform=%s", store.store_id, platform.alias 

197 ) 

198 if query.strip(): 

199 print(f"No results found for {query!r} on {platform.name}.") 

200 else: 

201 print(f"No catalogue entries found for {platform.name}.") 

202 return 0 

203 _print_results(results) 

204 return 0 

205 

206 

207def _run_download( 

208 store: GameStore, 

209 config: Config, 

210 detail_urls: Sequence[str], 

211 directory: Path, 

212 platform_name: str | None, 

213 roms_directory: Path | None, 

214) -> int: 

215 installed_bios: list[Path] = [] 

216 try: 

217 LOGGER.info( 

218 "Starting download count=%d platform=%s", 

219 len(detail_urls), 

220 platform_name or "staging-only", 

221 ) 

222 media_urls = [store.download_request(url) for url in detail_urls] 

223 downloads = download_files( 

224 media_urls, 

225 directory, 

226 store.download_referrer, 

227 config.timeout_seconds, 

228 _print_progress, 

229 ) 

230 if platform_name: 

231 platform = resolve_platform(platform_name, platform_catalogue(config.target)) 

232 if platform is None: 

233 raise OrganizeError(f"Unknown platform {platform_name!r}.") 

234 configured_roots = (roms_directory,) if roms_directory else config.roms_directories 

235 root = detect_roms_directory(configured_roots or None, config.target.rom_roots) 

236 if root is None: 

237 raise OrganizeError("No ROM root found; pass --roms-directory.") 

238 downloads = install_downloads(downloads, platform, root, installed_bios.append) 

239 except (StoreError, DownloadError, OrganizeError, ValueError) as error: 

240 LOGGER.error("Download failed: %s", error) 

241 print(f"Download failed: {error}", file=sys.stderr) 

242 return 1 

243 for result in downloads: 

244 print(f"Completed: {result.path}") 

245 for bios_path in installed_bios: 

246 print(f"BIOS installed: {bios_path}") 

247 if not platform_name: 

248 print("Tip: use --platform to move completed files into the matching ROM folder.") 

249 else: 

250 request_game_frontend_refresh(target=config.target) 

251 LOGGER.info("Download completed files=%d bios=%d", len(downloads), len(installed_bios)) 

252 return 0 

253 

254 

255def _print_progress(label: str, current: int, total: int | None) -> None: 

256 if total: 

257 print("\r%s: %d%%" % (label, min(100, int(current * 100 / total))), end="", flush=True) 

258 if current >= total: 258 ↛ exitline 258 didn't return from function '_print_progress' because the condition on line 258 was always true

259 print() 

260 else: 

261 print("\r%s: %d KiB" % (label, current // 1024), end="", flush=True) 

262 

263 

264def _print_results(results: Sequence[SearchResult]) -> None: 

265 rows: list[Sequence[str]] = [] 

266 for result in results: 

267 rows.append( 

268 ( 

269 result.system or "-", 

270 result.title, 

271 result.region or "-", 

272 result.version or "-", 

273 result.link, 

274 ) 

275 ) 

276 _print_table(("SYSTEM", "TITLE", "REGION", "VERSION", "LINK"), rows) 

277 

278 

279def _print_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> None: 

280 all_rows = [tuple(headers)] + [tuple(str(value) for value in row) for row in rows] 

281 widths = [max(len(row[index]) for row in all_rows) for index in range(len(headers))] 

282 template = " ".join("{:<%d}" % width for width in widths) 

283 print(template.format(*all_rows[0])) 

284 print(template.format(*("-" * width for width in widths))) 

285 for row in all_rows[1:]: 

286 print(template.format(*row)) 

287 

288 

289if __name__ == "__main__": 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true

290 raise SystemExit(main())