diff --git a/tests/core/test_proxy_catalogue_cache.py b/tests/core/test_proxy_catalogue_cache.py index 43bed6d1..6af40381 100644 --- a/tests/core/test_proxy_catalogue_cache.py +++ b/tests/core/test_proxy_catalogue_cache.py @@ -35,3 +35,33 @@ def test_empty_catalogue_is_not_kept() -> None: fetch() fetch() assert len(calls) == 2 + + +def test_login_bound_providers_fetch_once_across_builds(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + from types import SimpleNamespace + + from unshackle.core.proxies.expressvpn import ExpressVPN + from unshackle.core.proxies.proton import ProtonVPN + + requests_made: list[str] = [] + + def express_request(self: ExpressVPN, method: str, url: str, **_: object) -> SimpleNamespace: + requests_made.append("express") + return SimpleNamespace(json=lambda: {"locations": [{"country_code": "ca"}]}) + + def proton_api(self: ProtonVPN, method: str, path: str) -> SimpleNamespace: + requests_made.append(path) + body = {"LogicalServers": [{"ExitCountry": "CA"}]} if path.endswith("logicals") else {"VPN": {"MaxTier": 0}} + return SimpleNamespace(json=lambda: body) + + monkeypatch.setattr(ExpressVPN, "get_srt", lambda self: "srt") + monkeypatch.setattr(ExpressVPN, "request", express_request) + monkeypatch.setattr(ProtonVPN, "api", proton_api) + + for _ in range(2): + assert ExpressVPN(cache_path=str(tmp_path / "e.json")).get_locations() == [{"country_code": "ca"}] + proton = ProtonVPN(cookie_path=str(tmp_path / "c.txt"), cache_path=str(tmp_path / "p.json")) + assert proton.get_logicals() == [{"ExitCountry": "CA"}] + assert proton.get_max_tier() == 0 + + assert sorted(requests_made) == ["/vpn", "/vpn/v1/logicals", "express"] diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index f6084c23..a1b9e7bb 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -272,8 +272,10 @@ def reload_config(app: web.Application, *, no_key: bool, serve_widevine: bool, s old_devices, new_devices = _device_stems(config.serve), _device_stems(serve) vars(config).update(vars(fresh)) from unshackle.core.api.handlers import reset_remote_pool + from unshackle.core.proxies.proxy import CATALOGUES reset_remote_pool() + CATALOGUES.clear() app["config"]["users"] = serve_config["users"] if serve_widevine: app["config"]["devices"] = _device_map(serve_config["devices"]) diff --git a/unshackle/core/proxies/expressvpn.py b/unshackle/core/proxies/expressvpn.py index 73e10193..592293bb 100644 --- a/unshackle/core/proxies/expressvpn.py +++ b/unshackle/core/proxies/expressvpn.py @@ -14,7 +14,7 @@ from typing import Any, Optional import requests from unshackle.core.config import config -from unshackle.core.proxies.proxy import Proxy +from unshackle.core.proxies.proxy import Proxy, cached_catalogue log = logging.getLogger("proxies.expressvpn") @@ -248,15 +248,19 @@ class ExpressVPN(Proxy): def get_locations(self) -> list[dict]: if self.locations is None: - srt = self.get_srt() - if not srt: - return [] - response = self.request( - "GET", f"{self.API_BASE}/ids2/locations", headers=self.api_headers(srt), params={"protocols": "proxy"} - ) - self.locations = response.json().get("locations", []) + self.locations = self.fetch_locations() return self.locations + @cached_catalogue + def fetch_locations(self) -> list[dict]: + srt = self.get_srt() + if not srt: + return [] + response = self.request( + "GET", f"{self.API_BASE}/ids2/locations", headers=self.api_headers(srt), params={"protocols": "proxy"} + ) + return response.json().get("locations", []) + def get_endpoints(self, location: dict) -> list[dict]: srt = self.get_srt() if not srt: diff --git a/unshackle/core/proxies/proton.py b/unshackle/core/proxies/proton.py index a9bc6552..cdc6aede 100644 --- a/unshackle/core/proxies/proton.py +++ b/unshackle/core/proxies/proton.py @@ -15,7 +15,7 @@ import requests from unshackle.core.config import config from unshackle.core.console import prompt_user -from unshackle.core.proxies.proxy import Proxy +from unshackle.core.proxies.proxy import Proxy, cached_catalogue log = logging.getLogger("proxies.proton") @@ -195,22 +195,29 @@ class ProtonVPN(Proxy): return chosen def get_logicals(self) -> list[dict]: - """Get Proton's server list. Only a list that loaded is cached, so a later session can still fetch it.""" - if self.logicals is None: - response = self.api("GET", "/vpn/v1/logicals") - if response is None: - return [] - self.logicals = response.json().get("LogicalServers") or [] + """Get Proton's server list. Only a list that loaded is kept, so a later call can still fetch it.""" + if not self.logicals: + self.logicals = self.fetch_logicals() return self.logicals + @cached_catalogue + def fetch_logicals(self) -> list[dict]: + response = self.api("GET", "/vpn/v1/logicals") + return (response.json().get("LogicalServers") or []) if response is not None else [] + def get_max_tier(self) -> int: """The account's plan tier (0=free, 2=plus). Caps which servers it can connect to.""" if self.max_tier is None: - response = self.api("GET", "/vpn") - vpn = (response.json().get("VPN") or {}) if response is not None else {} - self.max_tier = int(vpn.get("MaxTier") or 0) + self.max_tier = int(self.fetch_vpn().get("MaxTier") or 0) return self.max_tier + @cached_catalogue + def fetch_vpn(self) -> dict: + """The account's VPN section, kept only when it names the plan tier.""" + response = self.api("GET", "/vpn") + vpn = (response.json().get("VPN") or {}) if response is not None else {} + return vpn if "MaxTier" in vpn else {} + def get_browser_token(self) -> Optional[tuple[str, str]]: response = self.api("GET", f"/vpn/v1/browser/token?Duration={self.TOKEN_DURATION}") if response is None: diff --git a/unshackle/core/proxies/proxy.py b/unshackle/core/proxies/proxy.py index 6be90b54..4986c492 100644 --- a/unshackle/core/proxies/proxy.py +++ b/unshackle/core/proxies/proxy.py @@ -10,9 +10,10 @@ F = TypeVar("F", bound=Callable[..., Any]) def cached_catalogue(fetch: F) -> F: - """Keep a proxy provider's server catalogue for ``CATALOGUE_TTL`` seconds; the next call fetches an empty answer again. + """Keep a proxy provider's catalogue for ``CATALOGUE_TTL`` seconds; a failed (empty) answer is fetched again. A server builds its proxy providers on every request, and each build fetches the catalogue. + One config builds every provider, so a config reload clears the cache. """ @functools.wraps(fetch)