perf: lazy-load services and proxy catalogues, faster namespace strip

load_xml strips namespaces without QName; services import on first use and proxy providers fetch their catalogue on first query; the font scan is skipped when no subtitle names a font and is cached per process.

Serve CPU -40% and track requests -28% on 20 MB manifests; CLI startup 2.7 s -> 1.2 s; --list 27-46% faster across six services tested.
This commit is contained in:
imSp4rky
2026-11-04 11:12:11 -07:00
parent b3d586a590
commit 6ca2503a5f
18 changed files with 389 additions and 81 deletions
+4 -2
View File
@@ -1069,8 +1069,10 @@ The returned proxy is HTTPS on **port 443**.
methods: methods:
- `__init__(self, **kwargs)` receives its `proxy_providers` sub-dict splatted as - `__init__(self, **kwargs)` receives its `proxy_providers` sub-dict splatted as
keyword arguments, so YAML keys map 1:1 to constructor parameters. Do authentication keyword arguments, so YAML keys map 1:1 to constructor parameters. Check the
and catalogue pre-fetching here. configuration here, but make no request: every `dl` run builds every configured
proxy provider, even for a run with no proxy. Read the server or location catalogue
through a `functools.cached_property`, so the first query fetches it.
- `__repr__(self)` is a human summary, conventionally `"{n} Countries ({m} Servers)"`. - `__repr__(self)` is a human summary, conventionally `"{n} Countries ({m} Servers)"`.
- `get_proxy(self, query)` returns a Requests-compatible proxy URI, or `None` only - `get_proxy(self, query)` returns a Requests-compatible proxy URI, or `None` only
when the query was valid but no proxy exists. Raise on genuine errors or bad queries. when the query was valid but no proxy exists. Raise on genuine errors or bad queries.
+22 -2
View File
@@ -3,6 +3,7 @@ split, the named or bare provider lookup, and the line that reports which proxy
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from typing import Iterator, Optional from typing import Iterator, Optional
@@ -188,10 +189,29 @@ def test_bare_query_skips_a_provider_that_raises(caplog: pytest.LogCaptureFixtur
pick_proxy([Broken()], "us") pick_proxy([Broken()], "us")
def test_named_query_passes_the_provider_error_on() -> None: def test_named_query_passes_the_provider_error_on_as_a_value_error() -> None:
class Broken: class Broken:
def get_proxy(self, query: str) -> str: def get_proxy(self, query: str) -> str:
raise ConnectionError("api down") raise ConnectionError("api down")
with pytest.raises(ConnectionError, match="api down"): with pytest.raises(ValueError, match="The proxy provider broken failed for us: api down") as raised:
pick_proxy([Broken()], "us", "broken") pick_proxy([Broken()], "us", "broken")
assert isinstance(raised.value.__cause__, ConnectionError)
def test_named_query_names_the_provider_for_a_bad_catalogue_response() -> None:
class Broken:
def get_proxy(self, query: str) -> str:
raise json.JSONDecodeError("Expecting value", "", 0)
with pytest.raises(ValueError, match="The proxy provider broken failed for us: Expecting value"):
pick_proxy([Broken()], "us", "broken")
def test_named_query_passes_a_query_error_on_unchanged() -> None:
class Strict:
def get_proxy(self, query: str) -> str:
raise ValueError("no server for us150")
with pytest.raises(ValueError, match="^no server for us150$"):
pick_proxy([Strict()], "us150", "strict")
+93 -8
View File
@@ -4,6 +4,7 @@ by a bare query and reported by a query that names it."""
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
import pytest import pytest
import requests import requests
@@ -15,17 +16,14 @@ from unshackle.core.proxies.resolve import Unavailable, find_provider, load_prox
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
CREDENTIALS = {"username": "a" * 24, "password": "b" * 24} CREDENTIALS = {"username": "a" * 24, "password": "b" * 24}
LOGIN_CREDENTIALS = {"username": "someone@example.com", "password": "hunter2"}
OTHERS = ("basic", "expressvpn", "protonvpn", "windscribevpn", "gluetun", "hola", "controld") OTHERS = ("basic", "expressvpn", "protonvpn", "windscribevpn", "gluetun", "hola", "controld")
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def nordvpn_down_surfshark_up(monkeypatch: pytest.MonkeyPatch) -> None: def nordvpn_misconfigured_surfshark_up(monkeypatch: pytest.MonkeyPatch) -> None:
def refuse() -> list[dict]:
raise requests.ConnectionError("no route to api.nordvpn.com")
clusters = [{"countryCode": "US", "connectionName": "us-dal.prod.surfshark.com", "location": "Dallas"}] clusters = [{"countryCode": "US", "connectionName": "us-dal.prod.surfshark.com", "location": "Dallas"}]
monkeypatch.setattr(config, "proxy_providers", {"nordvpn": CREDENTIALS, "surfsharkvpn": CREDENTIALS}) monkeypatch.setattr(config, "proxy_providers", {"nordvpn": LOGIN_CREDENTIALS, "surfsharkvpn": CREDENTIALS})
monkeypatch.setattr(NordVPN, "get_countries", staticmethod(refuse))
monkeypatch.setattr(SurfsharkVPN, "get_countries", staticmethod(lambda: clusters)) monkeypatch.setattr(SurfsharkVPN, "get_countries", staticmethod(lambda: clusters))
@@ -49,7 +47,7 @@ def test_query_naming_another_provider_still_works() -> None:
@pytest.mark.parametrize("name", ["nordvpn", "NordVPN"]) @pytest.mark.parametrize("name", ["nordvpn", "NordVPN"])
def test_query_naming_the_failed_provider_reports_why(name: str) -> None: def test_query_naming_the_failed_provider_reports_why(name: str) -> None:
with pytest.raises(ValueError, match="NordVPN could not load: no route to api.nordvpn.com"): with pytest.raises(ValueError, match="NordVPN could not load: NordVPN needs its Service Credentials"):
resolve_proxy(f"{name}:us", load_proxy_providers(OTHERS)) resolve_proxy(f"{name}:us", load_proxy_providers(OTHERS))
@@ -59,10 +57,29 @@ def test_query_naming_an_unknown_provider_lists_the_loaded_ones() -> None:
def test_raise_errors_raises_the_load_failure() -> None: def test_raise_errors_raises_the_load_failure() -> None:
with pytest.raises(requests.ConnectionError): with pytest.raises(ValueError, match="Service Credentials"):
load_proxy_providers(OTHERS, raise_errors=True) load_proxy_providers(OTHERS, raise_errors=True)
def test_catalogue_is_fetched_on_first_use_and_a_failed_fetch_fails_only_its_query(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = []
def refuse() -> list[dict]:
calls.append(1)
raise requests.ConnectionError("no route to api.nordvpn.com")
monkeypatch.setattr(config, "proxy_providers", {"nordvpn": CREDENTIALS, "surfsharkvpn": CREDENTIALS})
monkeypatch.setattr(NordVPN, "get_countries", staticmethod(refuse))
nordvpn, surfshark = load_proxy_providers(OTHERS)
assert isinstance(nordvpn, NordVPN) and not calls
assert resolve_proxy("us", [nordvpn, surfshark]).endswith("@us-dal.prod.surfshark.com:443")
with pytest.raises(ValueError, match="nordvpn failed for us: no route to api.nordvpn.com"):
resolve_proxy("nordvpn:us", [nordvpn, surfshark])
def test_bare_query_order_follows_config_key_order_with_controld_last(monkeypatch: pytest.MonkeyPatch) -> None: def test_bare_query_order_follows_config_key_order_with_controld_last(monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.core.proxies import ControlD from unshackle.core.proxies import ControlD
@@ -88,3 +105,71 @@ def test_rest_path_loads_windscribe_but_not_gluetun(monkeypatch: pytest.MonkeyPa
assert any(isinstance(p, WindscribeVPN) for p in providers) assert any(isinstance(p, WindscribeVPN) for p in providers)
assert not any(isinstance(p, Gluetun) or getattr(p, "name", None) == "Gluetun" for p in providers) assert not any(isinstance(p, Gluetun) or getattr(p, "name", None) == "Gluetun" for p in providers)
assert resolve_proxy("windscribevpn:us", providers).endswith("@us-central-1.example:443") assert resolve_proxy("windscribevpn:us", providers).endswith("@us-central-1.example:443")
def build_controld() -> Any:
from unshackle.core.proxies import ControlD
return ControlD(token="t")
def build_hola(monkeypatch: pytest.MonkeyPatch) -> Any:
from unshackle.core import binaries
from unshackle.core.proxies import Hola
monkeypatch.setattr(binaries, "HolaProxy", "hola-proxy")
return Hola()
@pytest.mark.parametrize(
("name", "fetch", "attr"),
[
("NordVPN", "get_countries", "countries"),
("WindscribeVPN", "get_countries", "countries"),
("SurfsharkVPN", "get_countries", "countries"),
("ControlD", "get_locations", "catalogue"),
("Hola", "get_countries", "countries"),
],
)
def test_construction_makes_no_request_and_the_first_read_fetches_once(
name: str, fetch: str, attr: str, monkeypatch: pytest.MonkeyPatch
) -> None:
from unshackle.core import proxies
cls = getattr(proxies, name)
calls: list[int] = []
def counted(*args: Any) -> list[dict]:
calls.append(1)
return [{"country": "US"}]
is_static = isinstance(vars(cls)[fetch], staticmethod)
monkeypatch.setattr(cls, fetch, staticmethod(counted) if is_static else counted)
if name == "ControlD":
provider = build_controld()
elif name == "Hola":
provider = build_hola(monkeypatch)
else:
provider = cls(**CREDENTIALS)
assert not calls
assert getattr(provider, attr) == [{"country": "US"}]
getattr(provider, attr)
assert calls == [1]
def test_health_probe_reads_each_proxy_catalogue(monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.core.api.handlers import run_health_checks
calls: list[int] = []
def countries() -> list[dict]:
calls.append(1)
return [{"serverCount": 3}]
monkeypatch.setattr(config, "key_vaults", [])
monkeypatch.setattr(config, "proxy_providers", {"nordvpn": CREDENTIALS})
monkeypatch.setattr(NordVPN, "get_countries", staticmethod(countries))
detail = {c["id"]: c for c in run_health_checks()}["proxies"]["detail"]
assert calls == [1]
assert "NordVPN (1 Country (3 Servers))" in detail
+143
View File
@@ -0,0 +1,143 @@
"""Services import on first use: a command pays only for the service it runs."""
from __future__ import annotations
import logging
import sys
import threading
from pathlib import Path
import click
import pytest
from aiohttp import web
from unshackle.core import binaries, services
from unshackle.core.config import config
pytestmark = pytest.mark.unit
EXAMPLE_SOURCE = """\
import click
class EXAMPLE:
ALIASES = ("ex",)
@staticmethod
def get_binaries():
return [{"name": "ExampleTool", "desc": "example dependency"}]
@staticmethod
@click.command()
def cli():
pass
"""
@pytest.fixture
def two_services(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
for tag, body in (("EXAMPLE", EXAMPLE_SOURCE), ("OTHER", "raise ImportError('boom')\n")):
(tmp_path / tag).mkdir()
(tmp_path / tag / "__init__.py").write_text(body)
monkeypatch.setattr(services, "SERVICES", [tmp_path / t / "__init__.py" for t in ("EXAMPLE", "OTHER")])
monkeypatch.setattr(services, "MODULES", {})
monkeypatch.setattr(services, "ALIASES", {})
monkeypatch.setattr(services, "LOAD_ERRORS", [])
monkeypatch.setattr(services, "SHADOWED", [])
monkeypatch.setattr(services, "DIRTY_REPOS", [])
monkeypatch.setattr(services, "SUMMARY_LOGGED", False)
monkeypatch.setattr(binaries, "_registered_binaries", {})
monkeypatch.setattr(binaries, "__binaries", dict(getattr(binaries, "__binaries")))
yield
for name in ("EXAMPLE", "OTHER"):
sys.modules.pop(name, None)
def test_a_tag_imports_only_its_own_service(two_services: None) -> None:
assert services.Services.get_tag("example") == "EXAMPLE"
assert services.MODULES == {}
assert services.Services.load("EXAMPLE").__name__ == "EXAMPLE"
assert list(services.MODULES) == ["EXAMPLE"] and not services.LOAD_ERRORS
assert [d["name"] for d in binaries.get_registered_dependencies()] == ["ExampleTool"]
def test_an_alias_imports_services_until_one_matches(two_services: None) -> None:
assert services.Services.get_tag("ex") == "EXAMPLE"
assert list(services.MODULES) == ["EXAMPLE"]
def test_a_failed_import_fails_only_its_own_tag_and_is_not_retried(two_services: None) -> None:
with pytest.raises(KeyError):
services.Services.load("OTHER")
assert len(services.LOAD_ERRORS) == 1 and services.LOAD_ERRORS[0].startswith("OTHER: ")
services.ensure_loaded("OTHER")
assert len(services.LOAD_ERRORS) == 1
services.check_load_errors("EXAMPLE")
with pytest.raises(click.ClickException, match="Failed to load 1 service"):
services.check_load_errors("OTHER")
def test_get_command_runs_a_good_service_beside_a_broken_one(two_services: None) -> None:
group = services.Services(name="dl")
ctx = click.Context(group)
with pytest.raises(click.ClickException, match="OTHER: failed to import"):
group.get_command(ctx, "OTHER")
assert group.get_command(ctx, "EXAMPLE").name == "EXAMPLE"
def test_get_tag_does_not_wait_for_a_reload_on_a_failed_tag(two_services: None) -> None:
services.ensure_loaded("EXAMPLE")
services.ensure_loaded("OTHER")
held, release = threading.Event(), threading.Event()
def reload() -> None:
with services.RELOAD_LOCK:
held.set()
release.wait()
holder = threading.Thread(target=reload)
holder.start()
try:
assert held.wait(5)
result: list[str] = []
lookup = threading.Thread(target=lambda: result.append(services.Services.get_tag("nosuch")))
lookup.start()
lookup.join(5)
assert result == ["nosuch"]
finally:
release.set()
holder.join()
def test_load_all_imports_every_service(two_services: None) -> None:
services.load_all()
assert list(services.MODULES) == ["EXAMPLE"]
assert services.ALIASES == {"EXAMPLE": ("ex",)}
assert services.failed_tags(services.LOAD_ERRORS) == {"OTHER"}
def test_env_dependencies_list_every_service_binary(two_services: None) -> None:
from unshackle.commands.env import get_dependencies
assert "ExampleTool" in {dep["name"] for dep in get_dependencies()}
def test_serve_startup_imports_every_service(two_services: None, monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.commands.serve import _install_service_refresh
monkeypatch.setattr(config, "serve", {})
_install_service_refresh(web.Application())
assert list(services.MODULES) == ["EXAMPLE"]
def test_a_run_of_one_tag_names_its_duplicate(two_services: None, caplog: pytest.LogCaptureFixture) -> None:
services.SHADOWED[:] = [
"EXAMPLE: using /a/EXAMPLE, ignoring /b/EXAMPLE",
"OTHER: using /a/OTHER, ignoring /b/OTHER",
]
with caplog.at_level(logging.INFO, logger="services"):
services.check_load_errors("EXAMPLE")
assert "EXAMPLE: 1 duplicate(s) ignored" in caplog.text
assert "OTHER" not in caplog.text
+2
View File
@@ -607,6 +607,8 @@ class dl:
Returns: Returns:
Tuple of (fonts_attached_count, missing_fonts_list) Tuple of (fonts_attached_count, missing_fonts_list)
""" """
if not font_names:
return 0, []
system_fonts = get_system_fonts() system_fonts = get_system_fonts()
font_count = 0 font_count = 0
+2 -1
View File
@@ -13,7 +13,7 @@ from unshackle.core import binaries
from unshackle.core.config import POSSIBLE_CONFIG_PATHS, config, config_path from unshackle.core.config import POSSIBLE_CONFIG_PATHS, config, config_path
from unshackle.core.console import console, listing_table, print_wide from unshackle.core.console import console, listing_table, print_wide
from unshackle.core.constants import context_settings from unshackle.core.constants import context_settings
from unshackle.core.services import Services from unshackle.core.services import Services, load_all
from unshackle.core.temp import TASK_PREFIX, is_stale from unshackle.core.temp import TASK_PREFIX, is_stale
@@ -134,6 +134,7 @@ def get_dependencies() -> list[dict]:
}, },
] ]
load_all()
for reg in binaries.get_registered_dependencies(): for reg in binaries.get_registered_dependencies():
deps.append( deps.append(
{ {
+6 -1
View File
@@ -76,6 +76,7 @@ def _install_service_refresh(app: web.Application) -> None:
from unshackle.core import services from unshackle.core import services
log = logging.getLogger("serve") log = logging.getLogger("serve")
services.load_all()
services.log_load_issues() services.log_load_issues()
services.record_loaded_commits() services.record_loaded_commits()
interval = int(config.serve.get("services_refresh_interval", 0) or 0) interval = int(config.serve.get("services_refresh_interval", 0) or 0)
@@ -575,7 +576,11 @@ def serve(
app["config"] = serve_config app["config"] = serve_config
app["debug_api"] = debug_api app["debug_api"] = debug_api
if not cdm_only: if cdm_only:
from unshackle.core import services
services.load_all()
else:
_install_session_store(app) _install_session_store(app)
_install_service_refresh(app) _install_service_refresh(app)
_install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready) _install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready)
+2 -1
View File
@@ -3020,10 +3020,11 @@ def run_health_checks(checks: Optional[List[Dict[str, Any]]] = None) -> List[Dic
checks.append(health_check("bad_keys", "bad content key flags", bad_keys_probe)) checks.append(health_check("bad_keys", "bad content key flags", bad_keys_probe))
def proxy_probe() -> tuple[str, str]: def proxy_probe() -> tuple[str, str]:
"""Render each proxy provider, because the render fetches its catalogue from the provider."""
providers = initialize_proxy_providers(raise_errors=True, quiet=True) providers = initialize_proxy_providers(raise_errors=True, quiet=True)
if not providers: if not providers:
return "ok", "none configured" return "ok", "none configured"
return "ok", ", ".join(type(p).__name__ for p in providers) return "ok", ", ".join(f"{type(p).__name__} ({p!r})" for p in providers)
if config.proxy_providers: if config.proxy_providers:
checks.append(health_check("proxies", "proxy providers", proxy_probe, config_secrets(config.proxy_providers))) checks.append(health_check("proxies", "proxy providers", proxy_probe, config_secrets(config.proxy_providers)))
+6 -2
View File
@@ -9,6 +9,7 @@ import socket
import socketserver import socketserver
import threading import threading
import time import time
from functools import cached_property
from typing import Optional from typing import Optional
from urllib.parse import urlsplit from urllib.parse import urlsplit
@@ -361,7 +362,10 @@ class ControlD(Proxy):
self.resolvers = dict(self.configured) self.resolvers = dict(self.configured)
self.last_profile: Optional[str] = None self.last_profile: Optional[str] = None
self.catalogue = self.get_locations() @cached_property
def catalogue(self) -> list[dict]:
"""Fetched on first use, so a run that wants no proxy makes no request."""
return self.get_locations()
def __repr__(self) -> str: def __repr__(self) -> str:
countries = len({x["country"] for x in self.catalogue}) countries = len({x["country"] for x in self.catalogue})
@@ -377,7 +381,7 @@ class ControlD(Proxy):
response.raise_for_status() response.raise_for_status()
return response.json()["body"]["proxies"] return response.json()["body"]["proxies"]
except Exception as e: except Exception as e:
# never fail the command: all providers load up front, even for a run that wants no proxy # never fail the command: a bare query falls through to the next proxy provider
log.warning(f"Could not read Control D's locations: {e}") log.warning(f"Could not read Control D's locations: {e}")
return [] return []
+5 -1
View File
@@ -1,6 +1,7 @@
import random import random
import re import re
import subprocess import subprocess
from functools import cached_property
from typing import Optional from typing import Optional
from unshackle.core import binaries from unshackle.core import binaries
@@ -16,7 +17,10 @@ class Hola(Proxy):
raise EnvironmentError("hola-proxy executable not found but is required for the Hola proxy provider.") raise EnvironmentError("hola-proxy executable not found but is required for the Hola proxy provider.")
self.binary = binaries.HolaProxy self.binary = binaries.HolaProxy
self.countries = self.get_countries() @cached_property
def countries(self) -> list[dict[str, str]]:
"""Fetched on first use, so a run that wants no proxy makes no request."""
return self.get_countries()
def __repr__(self) -> str: def __repr__(self) -> str:
countries = len(self.countries) countries = len(self.countries)
+5 -1
View File
@@ -1,6 +1,7 @@
import json import json
import random import random
import re import re
from functools import cached_property
from typing import Optional, Union from typing import Optional, Union
import requests import requests
@@ -33,7 +34,10 @@ class NordVPN(Proxy):
self.server_map = server_map or {} self.server_map = server_map or {}
self.last_hostname: Optional[str] = None self.last_hostname: Optional[str] = None
self.countries = self.get_countries() @cached_property
def countries(self) -> list[dict]:
"""Fetched on first use, so a run that wants no proxy makes no request."""
return self.get_countries()
def __repr__(self) -> str: def __repr__(self) -> str:
countries = len(self.countries) countries = len(self.countries)
+4 -3
View File
@@ -12,7 +12,7 @@ F = TypeVar("F", bound=Callable[..., Any])
def cached_catalogue(fetch: F) -> F: def cached_catalogue(fetch: F) -> F:
"""Keep a proxy provider's catalogue for ``CATALOGUE_TTL`` seconds; a failed (empty) answer is fetched 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. A server builds its proxy providers on every request, and the first query on each one reads the catalogue.
One config builds every provider, so a config reload clears the cache. One config builds every provider, so a config reload clears the cache.
""" """
@@ -33,9 +33,10 @@ class Proxy:
@abstractmethod @abstractmethod
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
""" """
The constructor initializes the Service using passed configuration data. The constructor initialises the proxy provider from its configuration data.
Any authorization or pre-fetching of data should be done here. Check the configuration here, but make no request: every run builds every configured proxy provider,
even a run with no proxy. Fetch a catalogue on first use, through a ``functools.cached_property``.
""" """
@abstractmethod @abstractmethod
+12 -5
View File
@@ -3,12 +3,14 @@
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import json
import logging import logging
import re import re
from typing import Any, List, Optional from typing import Any, List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
import click import click
import requests
from click.core import ParameterSource from click.core import ParameterSource
from unshackle.core.proxies.proxy import Proxy from unshackle.core.proxies.proxy import Proxy
@@ -58,6 +60,7 @@ def load_proxy_providers(exclude: tuple[str, ...] = (), raise_errors: bool = Fal
Each proxy provider loads on its own. One that fails is logged and kept as Unavailable, so a bare Each proxy provider loads on its own. One that fails is logged and kept as Unavailable, so a bare
query skips it and a query that names it reports why. *raise_errors* raises the failure instead. query skips it and a query that names it reports why. *raise_errors* raises the failure instead.
The first query fetches a catalogue, not this function.
*exclude* holds lowercase class names to leave out. *exclude* holds lowercase class names to leave out.
""" """
from unshackle.core import binaries from unshackle.core import binaries
@@ -106,7 +109,7 @@ def load_proxy_providers(exclude: tuple[str, ...] = (), raise_errors: bool = Fal
if provider is None: if provider is None:
continue continue
if not quiet: if not quiet:
log.info(f"Loaded {name}: {provider}") log.info(f"Loaded {name}")
except Exception as e: except Exception as e:
if raise_errors: if raise_errors:
raise raise
@@ -122,8 +125,7 @@ def initialize_proxy_providers(raise_errors: bool = False, quiet: bool = False)
Load the proxy providers for the REST API and the remote service client. Load the proxy providers for the REST API and the remote service client.
Gluetun is not offered on this path, because it starts a local Docker container. *quiet* drops the Gluetun is not offered on this path, because it starts a local Docker container. *quiet* drops the
per-provider summary lines: rendering a proxy provider asks some of them for their server catalogue per-provider summary lines.
over the network, which a repeated caller must not pay for.
""" """
proxy_providers = load_proxy_providers(("gluetun",), raise_errors, quiet) proxy_providers = load_proxy_providers(("gluetun",), raise_errors, quiet)
if not quiet and not proxy_providers: if not quiet and not proxy_providers:
@@ -154,12 +156,17 @@ def pick_proxy(proxy_providers: List[Any], query: str, provider_name: Optional[s
""" """
Get a proxy provider and its proxy URI: from the named one, or else from the first one that answers. Get a proxy provider and its proxy URI: from the named one, or else from the first one that answers.
A named proxy provider's error goes to the caller. In a bare query, a proxy provider that fails is A named proxy provider's error goes to the caller as a ValueError. In a bare query, a proxy provider that fails is
logged and skipped, so a later one can still answer. Raises ValueError when no proxy provider answers. logged and skipped, so a later one can still answer. Raises ValueError when no proxy provider answers.
""" """
if provider_name: if provider_name:
provider = find_provider(proxy_providers, provider_name) provider = find_provider(proxy_providers, provider_name)
uri = provider.get_proxy(query) try:
uri = provider.get_proxy(query)
except Exception as e:
if isinstance(e, ValueError) and not isinstance(e, (json.JSONDecodeError, requests.RequestException)):
raise
raise ValueError(f"The proxy provider {provider_name} failed for {query}: {e}") from e
if not uri: if not uri:
raise ValueError(f"The proxy provider {provider_name} had no proxy for {query}") raise ValueError(f"The proxy provider {provider_name} had no proxy for {query}")
return provider, uri return provider, uri
+6 -2
View File
@@ -6,7 +6,7 @@ import re
import socket import socket
import ssl import ssl
import threading import threading
from functools import cache, partial from functools import cache, cached_property, partial
from typing import Optional from typing import Optional
import certifi import certifi
@@ -119,7 +119,11 @@ class SurfsharkVPN(Proxy):
self.server_map[COUNTRY_CODE_ALIASES.get(country, country) + sep + city] = server self.server_map[COUNTRY_CODE_ALIASES.get(country, country) + sep + city] = server
self.last_server: Optional[tuple[str, str]] = None self.last_server: Optional[tuple[str, str]] = None
self.countries = self.get_countries()
@cached_property
def countries(self) -> list[dict]:
"""Fetched on first use, so a run that wants no proxy makes no request."""
return self.get_countries()
def __repr__(self) -> str: def __repr__(self) -> str:
countries = len(set(x.get("country") for x in self.countries if x.get("country"))) countries = len(set(x.get("country") for x in self.countries if x.get("country")))
+5 -1
View File
@@ -1,6 +1,7 @@
import json import json
import random import random
import re import re
from functools import cached_property
from typing import Iterator, Optional from typing import Iterator, Optional
import requests import requests
@@ -33,7 +34,10 @@ class WindscribeVPN(Proxy):
self.server_map = server_map or {} self.server_map = server_map or {}
self.last_hostname: Optional[str] = None self.last_hostname: Optional[str] = None
self.countries = self.get_countries() @cached_property
def countries(self) -> list[dict]:
"""Fetched on first use, so a run that wants no proxy makes no request."""
return self.get_countries()
def __repr__(self) -> str: def __repr__(self) -> str:
countries = len(set(x.get("country_code") for x in self.countries if x.get("country_code"))) countries = len(set(x.get("country_code") for x in self.countries if x.get("country_code")))
+59 -38
View File
@@ -108,24 +108,6 @@ def load_service(path: Path) -> type[Service]:
) from e ) from e
def load_services(paths: list[Path]) -> tuple[dict[str, type[Service]], list[str]]:
"""Load every Service, returning the good ones plus a list of load errors.
Importing this module must never raise: several commands import it, and Python
does not cache a failed import, so raising here would re-run and re-report for
every command. Instead we collect failures and let the caller surface them once,
cleanly, when it uses the services.
"""
modules: dict[str, type[Service]] = {}
errors: list[str] = []
for path in paths:
try:
modules[path.parent.stem] = load_service(path)
except Exception as e:
errors.append(str(e))
return modules, errors
def register_service_binaries(modules: dict[str, type[Service]]) -> None: def register_service_binaries(modules: dict[str, type[Service]]) -> None:
"""Register custom binary dependencies declared by services via get_binaries().""" """Register custom binary dependencies declared by services via get_binaries()."""
@@ -144,15 +126,46 @@ def register_service_binaries(modules: dict[str, type[Service]]) -> None:
log.warning(f"Failed to register custom binaries for service {tag}: {e}") log.warning(f"Failed to register custom binaries for service {tag}: {e}")
MODULES, LOAD_ERRORS = load_services(SERVICES) MODULES: dict[str, type[Service]] = {}
register_service_binaries(MODULES) LOAD_ERRORS: list[str] = []
ALIASES: dict[str, tuple[str, ...]] = {}
ALIASES = {tag: getattr(module, "ALIASES", ()) for tag, module in MODULES.items()}
PENDING: set[str] = set() PENDING: set[str] = set()
PENDING_SINCE: dict[str, float] = {} PENDING_SINCE: dict[str, float] = {}
LOADED_COMMITS: dict[str, str] = {} LOADED_COMMITS: dict[str, str] = {}
RELOAD_LOCK = threading.Lock() RELOAD_LOCK = threading.RLock()
def ensure_loaded(tag: str) -> type[Service] | None:
"""Import one discovered service on first use; None when the tag is unknown or its import failed.
Startup imports no service: a command that runs one service pays only for that one.
A failed import is kept in ``LOAD_ERRORS`` and is not tried again until a reload.
"""
service = MODULES.get(tag)
if service is not None or tag in failed_tags(LOAD_ERRORS):
return service
with RELOAD_LOCK:
if tag in MODULES or tag in failed_tags(LOAD_ERRORS):
return MODULES.get(tag)
path = next((path for path in SERVICES if path.parent.stem == tag), None)
if path is None:
return None
try:
service = load_service(path)
except Exception as e:
LOAD_ERRORS.append(str(e))
return None
MODULES[tag] = service
ALIASES[tag] = getattr(service, "ALIASES", ())
register_service_binaries({tag: service})
return service
def load_all() -> None:
"""Import every discovered service, for the callers that need all of them (help, serve, env)."""
for tag in Services.get_tags():
ensure_loaded(tag)
def record_loaded_commits(tags: Iterable[str] | None = None) -> None: def record_loaded_commits(tags: Iterable[str] | None = None) -> None:
@@ -320,7 +333,7 @@ def log_load_issues() -> None:
log.error(f"Service skipped: {err}") log.error(f"Service skipped: {err}")
def check_load_errors() -> None: def check_load_errors(tag: str | None = None) -> None:
"""Log a one-line load summary (once) and raise a single clean error on failures. """Log a one-line load summary (once) and raise a single clean error on failures.
unshackle calls this when it needs the services (listing/resolving), so Click unshackle calls this when it needs the services (listing/resolving), so Click
@@ -328,23 +341,27 @@ def check_load_errors() -> None:
command that imports this module. This function summarises the duplicate services command that imports this module. This function summarises the duplicate services
(a tag that an earlier, higher-priority source also gives, and which unshackle (a tag that an earlier, higher-priority source also gives, and which unshackle
ignores). The full list - naming the path used and the duplicate ignored - is ignores). The full list - naming the path used and the duplicate ignored - is
debug-only. debug-only. With *tag*, only that service's load error counts, and the summary names
only a duplicate of that tag.
""" """
if DIRTY_REPOS: if DIRTY_REPOS:
joined = "\n".join(f" - {p}" for p in DIRTY_REPOS) joined = "\n".join(f" - {p}" for p in DIRTY_REPOS)
raise click.ClickException("Service repo has local changes, not refreshing:\n" + joined) raise click.ClickException("Service repo has local changes, not refreshing:\n" + joined)
errors = [err for err in LOAD_ERRORS if tag is None or err.startswith(f"{tag}: ")]
if errors:
joined = "\n".join(f" - {err}" for err in errors)
raise click.ClickException(f"Failed to load {len(errors)} service(s):\n{joined}")
global SUMMARY_LOGGED global SUMMARY_LOGGED
if not SUMMARY_LOGGED: if SUMMARY_LOGGED:
SUMMARY_LOGGED = True return
summary = f"Loaded {len(MODULES)} services" SUMMARY_LOGGED = True
if SHADOWED: shadows = SHADOWED if tag is None else [shadow for shadow in SHADOWED if shadow.startswith(f"{tag}: ")]
summary += f" ({len(SHADOWED)} duplicate(s) ignored)" if tag is None:
log.info(summary) log.info(f"Loaded {len(MODULES)} services" + (f" ({len(SHADOWED)} duplicate(s) ignored)" if SHADOWED else ""))
for shadow in SHADOWED: elif shadows:
log.debug("%s", shadow) log.info(f"{tag}: {len(shadows)} duplicate(s) ignored")
if LOAD_ERRORS: for shadow in shadows:
joined = "\n".join(f" - {err}" for err in LOAD_ERRORS) log.debug("%s", shadow)
raise click.ClickException(f"Failed to load {len(LOAD_ERRORS)} service(s):\n{joined}")
ROUTING_PARAMS = ("remote", "server") ROUTING_PARAMS = ("remote", "server")
@@ -395,12 +412,12 @@ class Services(click.Group):
if remote_tag not in tags: if remote_tag not in tags:
tags.append(remote_tag) tags.append(remote_tag)
return tags return tags
load_all()
check_load_errors() check_load_errors()
return Services.get_tags() return Services.get_tags()
def get_command(self, ctx: click.Context, name: str) -> click.Command: def get_command(self, ctx: click.Context, name: str) -> click.Command:
"""Load the Service and return the Click CLI method.""" """Load the Service and return the Click CLI method."""
check_load_errors()
tag = Services.get_tag(name) tag = Services.get_tag(name)
if Services.is_import(ctx): if Services.is_import(ctx):
@@ -411,6 +428,8 @@ class Services(click.Group):
if remote: if remote:
return Services.make_remote_command(name, routing.get("server")) return Services.make_remote_command(name, routing.get("server"))
ensure_loaded(tag)
check_load_errors(tag)
try: try:
service = Services.load(tag) service = Services.load(tag)
except KeyError as e: except KeyError as e:
@@ -608,6 +627,8 @@ class Services(click.Group):
return tag return tag
for tag in tags: for tag in tags:
if tag not in ALIASES:
ensure_loaded(tag)
if any(value_lower == alias.lower() for alias in ALIASES.get(tag, ())): if any(value_lower == alias.lower() for alias in ALIASES.get(tag, ())):
return tag return tag
@@ -616,7 +637,7 @@ class Services(click.Group):
@staticmethod @staticmethod
def load(tag: str) -> type[Service]: def load(tag: str) -> type[Service]:
"""Load a Service module by Service tag.""" """Load a Service module by Service tag."""
module = MODULES.get(tag) module = ensure_loaded(tag)
if module: if module:
return module return module
+2 -1
View File
@@ -897,9 +897,10 @@ def get_unix_fonts() -> dict[str, Path]:
return fonts return fonts
@cache
def get_system_fonts() -> dict[str, Path]: def get_system_fonts() -> dict[str, Path]:
""" """
Get system fonts as a mapping of font family names to font file paths. Get system fonts as a mapping of font family names to font file paths, scanned once per process.
On Windows: Uses registry to get font display names On Windows: Uses registry to get font display names
On Linux/macOS: Scans standard font directories and extracts family names using fontTools On Linux/macOS: Scans standard font directories and extracts family names using fontTools
+11 -12
View File
@@ -5,20 +5,19 @@ from lxml.etree import ElementTree
def load_xml(xml: Union[str, bytes]) -> ElementTree: def load_xml(xml: Union[str, bytes]) -> ElementTree:
"""Safely parse XML data to an ElementTree, without namespaces in tags.""" """Safely parse XML data to an ElementTree, without namespaces in tags.
The strip uses string operations, not etree.QName: on a 20 MB manifest the QName walk costs several times the parse.
"""
if not isinstance(xml, bytes): if not isinstance(xml, bytes):
xml = xml.encode("utf8") xml = xml.encode("utf8")
root = etree.fromstring(xml) root = etree.fromstring(xml)
for elem in root.getiterator(): for elem in root.iter(etree.Element):
if not hasattr(elem.tag, "find"): tag = elem.tag
# e.g. comment elements if tag[0] == "{":
continue elem.tag = tag.partition("}")[2]
elem.tag = etree.QName(elem).localname attrib = elem.attrib
for name, value in elem.attrib.items(): for name in [name for name in attrib if name[0] == "{"]:
local_name = etree.QName(name).localname attrib[name.partition("}")[2]] = attrib.pop(name)
if local_name == name:
continue
del elem.attrib[name]
elem.attrib[local_name] = value
etree.cleanup_namespaces(root) etree.cleanup_namespaces(root)
return root return root