Coverage for src/ph/store.py: 100.0%
60 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
1"""Store contracts shared by the TUI, CLI, and concrete download sources."""
3from abc import ABC, abstractmethod
4from collections.abc import Callable
5from pathlib import Path
6from typing import ClassVar
8from ph.models import MediaDownload, Platform, SearchResult
9from ph.store_cache import GameCatalogueCache, StoreCacheStatus
11type CatalogProgress = Callable[[int, int], None]
12USER_AGENT = (
13 "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 "
14 "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
15)
18class StoreError(RuntimeError):
19 """A user-facing store network or response error."""
21 def __init__(self, message: str, status_code: int | None = None) -> None:
22 super().__init__(message)
23 self.status_code = status_code
26class GameStore(ABC):
27 """A searchable source that can resolve its own game detail links."""
29 store_id: ClassVar[str]
30 display_name: ClassVar[str]
31 description: ClassVar[str]
32 timeout_seconds: float
34 def _configure_catalogue_cache(
35 self,
36 cache_directory: Path | None,
37 ttl_seconds: int,
38 ) -> None:
39 self._catalogue_cache = (
40 GameCatalogueCache(cache_directory, self.store_id, self.base_url, ttl_seconds)
41 if cache_directory is not None
42 else None
43 )
45 def _load_catalogue(
46 self,
47 system_code: str,
48 catalog_progress: CatalogProgress | None,
49 *,
50 force: bool = False,
51 ) -> tuple[SearchResult, ...]:
52 def fetcher() -> list[SearchResult]:
53 return self._fetch_catalogue(system_code, catalog_progress)
55 cache = getattr(self, "_catalogue_cache", None)
56 if cache is None:
57 return tuple(fetcher())
58 return cache.get_or_fetch(system_code, fetcher, force=force)
60 def _fetch_catalogue(
61 self,
62 system_code: str,
63 catalog_progress: CatalogProgress | None,
64 ) -> list[SearchResult]:
65 raise StoreError(f"{self.display_name} does not expose a cacheable catalogue.")
67 def refresh_catalogue(
68 self,
69 system_code: str,
70 catalog_progress: CatalogProgress | None = None,
71 ) -> list[SearchResult]:
72 """Force-download and atomically replace one platform catalogue."""
74 return list(self._load_catalogue(system_code, catalog_progress, force=True))
76 def catalogue_cache_status(self, system_code: str) -> StoreCacheStatus | None:
77 """Return local cache state for one platform without network access."""
79 cache = getattr(self, "_catalogue_cache", None)
80 if cache is None:
81 return None
82 return cache.status(system_code)
84 def catalogue_cache_file_count(self) -> int:
85 """Return how many structured catalogue files exist for this store."""
87 cache = getattr(self, "_catalogue_cache", None)
88 if cache is None:
89 return 0
90 return len(cache.cached_files())
92 def set_catalogue_ttl(self, ttl_seconds: int) -> None:
93 """Apply a changed cache lifetime without rebuilding the store client."""
95 cache = getattr(self, "_catalogue_cache", None)
96 if cache is not None:
97 cache.ttl_seconds = ttl_seconds
99 def set_network_timeout(self, timeout_seconds: float) -> None:
100 """Apply a changed network timeout without rebuilding the store client."""
102 self.timeout_seconds = timeout_seconds
104 @property
105 @abstractmethod
106 def base_url(self) -> str:
107 """Return the configured root URL for this store."""
109 @property
110 @abstractmethod
111 def download_referrer(self) -> str:
112 """Return the HTTP referrer expected while downloading store media."""
114 @abstractmethod
115 def platform_code(self, platform: Platform) -> str:
116 """Translate shared platform metadata to this store's identifier."""
118 def supports_platform(self, platform: Platform) -> bool:
119 """Return whether this store can search the shared platform."""
121 return True
123 @abstractmethod
124 def search(
125 self,
126 system_code: str,
127 query: str,
128 catalog_progress: CatalogProgress | None = None,
129 ) -> list[SearchResult]:
130 """Search this store using its platform identifier and title prefix."""
132 @abstractmethod
133 def validate_detail_url(self, url: str) -> bool:
134 """Return whether a detail URL belongs to this store."""
136 @abstractmethod
137 def retrieve_download_url(self, detail_url: str) -> str:
138 """Resolve one store detail page to its downloadable media URL."""
140 def download_request(self, detail_url: str) -> MediaDownload:
141 """Resolve download metadata; direct-download stores only need the URL."""
143 return MediaDownload(self.retrieve_download_url(detail_url))