nvda-addon-testkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ """Work out which NVDA launcher to download for a given channel.
2
+
3
+ Stable and beta come from nvaccess's update-check endpoint, which returns
4
+ `key: value` lines and always reports the newest build regardless of the
5
+ version we claim to be running. Alpha has no such endpoint -- passing
6
+ versionType=alpha returns a 404 -- so it is resolved by scraping the snapshot
7
+ directory index.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import urllib.request
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+
17
+ from .errors import LauncherResolutionError
18
+
19
+ CHANNELS: tuple[str, ...] = ("stable", "beta", "alpha")
20
+
21
+ _UPDATE_CHECK_URL = (
22
+ "https://download.nvaccess.org/nvdaUpdateCheck"
23
+ "?autoCheck=false&versionType={channel}&version=2019.1"
24
+ )
25
+ _SNAPSHOT_INDEX_URL = "https://download.nvaccess.org/snapshots/alpha/"
26
+ _RELEASE_URL = "https://download.nvaccess.org/releases/{version}/nvda_{version}.exe"
27
+
28
+ _SNAPSHOT_RE = re.compile(r"nvda_snapshot_alpha-(\d+),([0-9a-f]+)\.exe")
29
+ _PINNED_RE = re.compile(r"^\d{4}\.\d+(\.\d+)?(beta\d+|rc\d+)?$")
30
+
31
+ _USER_AGENT = "nvda-addon-testkit"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LauncherInfo:
36
+ """Everything needed to fetch and verify one NVDA launcher.
37
+
38
+ `sha1` is None for alpha snapshots and pinned versions, which publish no
39
+ digest. Callers must treat None as "unverifiable", never as "verified".
40
+ """
41
+
42
+ channel: str
43
+ version: str
44
+ url: str
45
+ sha1: str | None
46
+ api_version: str | None
47
+ api_compat_to: str | None
48
+
49
+
50
+ def fetch_url(url: str) -> bytes:
51
+ request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
52
+ with urllib.request.urlopen(request, timeout=60) as response:
53
+ return response.read()
54
+
55
+
56
+ def _parse_update_check(body: str) -> dict[str, str]:
57
+ fields: dict[str, str] = {}
58
+ for line in body.splitlines():
59
+ key, separator, value = line.partition(":")
60
+ if separator:
61
+ fields[key.strip()] = value.strip()
62
+ return fields
63
+
64
+
65
+ def _resolve_released(channel: str, fetch: Callable[[str], bytes]) -> LauncherInfo:
66
+ url = _UPDATE_CHECK_URL.format(channel=channel)
67
+ fields = _parse_update_check(fetch(url).decode("utf-8", errors="replace"))
68
+ launcher_url = fields.get("launcherUrl")
69
+ if not launcher_url:
70
+ raise LauncherResolutionError(
71
+ f"The update-check endpoint returned no launcherUrl for channel {channel!r}. "
72
+ f"Got fields: {sorted(fields)}"
73
+ )
74
+ return LauncherInfo(
75
+ channel=channel,
76
+ version=fields.get("version", "unknown"),
77
+ url=launcher_url,
78
+ sha1=fields.get("launcherHash"),
79
+ api_version=fields.get("apiVersion"),
80
+ api_compat_to=fields.get("apiCompatTo"),
81
+ )
82
+
83
+
84
+ def _resolve_alpha(fetch: Callable[[str], bytes]) -> LauncherInfo:
85
+ html = fetch(_SNAPSHOT_INDEX_URL).decode("utf-8", errors="replace")
86
+ matches = _SNAPSHOT_RE.findall(html)
87
+ if not matches:
88
+ raise LauncherResolutionError(
89
+ f"Found no alpha snapshot filenames at {_SNAPSHOT_INDEX_URL}. "
90
+ "The directory layout may have changed."
91
+ )
92
+ revision, commit = max(matches, key=lambda pair: int(pair[0]))
93
+ filename = f"nvda_snapshot_alpha-{revision},{commit}.exe"
94
+ return LauncherInfo(
95
+ channel="alpha",
96
+ version=f"alpha-{revision},{commit}",
97
+ url=_SNAPSHOT_INDEX_URL + filename,
98
+ sha1=None,
99
+ api_version=None,
100
+ api_compat_to=None,
101
+ )
102
+
103
+
104
+ def resolve_launcher(
105
+ channel: str = "stable",
106
+ *,
107
+ fetch: Callable[[str], bytes] = fetch_url,
108
+ ) -> LauncherInfo:
109
+ """Resolve a channel name, or a pinned version like "2026.1.1", to a launcher."""
110
+ if channel in ("stable", "beta"):
111
+ return _resolve_released(channel, fetch)
112
+ if channel == "alpha":
113
+ return _resolve_alpha(fetch)
114
+ if _PINNED_RE.match(channel):
115
+ return LauncherInfo(
116
+ channel="pinned",
117
+ version=channel,
118
+ url=_RELEASE_URL.format(version=channel),
119
+ sha1=None,
120
+ api_version=None,
121
+ api_compat_to=None,
122
+ )
123
+ raise LauncherResolutionError(
124
+ f"{channel!r} is not a known channel. Expected one of {CHANNELS} "
125
+ "or a pinned version such as '2026.1.1'."
126
+ )
@@ -0,0 +1,122 @@
1
+ """XML-RPC transport to the spy add-on.
2
+
3
+ Standard library only, on both ends: NVDA's bundled Python has xmlrpc, so the
4
+ spy add-on can be dependency-free. Every call carries the session token as its
5
+ first argument, which is what stops a stale NVDA from a previous run quietly
6
+ answering this run's questions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ import xmlrpc.client
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ from .errors import AuthError, RpcError, WaitTimeout
18
+ from .process import Handshake
19
+
20
+ _DEFAULT_INTERVAL = 0.05
21
+
22
+ #: Ceiling on any single call. Must exceed the longest server-side operation
23
+ #: (addons_install allows 120s). Finite matters more than the exact value: a
24
+ #: wedged NVDA would otherwise block forever and hang CI with no diagnostic.
25
+ _SOCKET_TIMEOUT = 300.0
26
+
27
+
28
+ class _TimeoutTransport(xmlrpc.client.Transport):
29
+ def __init__(self, timeout: float) -> None:
30
+ super().__init__(use_builtin_types=True)
31
+ self._timeout = timeout
32
+
33
+ def make_connection(self, host):
34
+ connection = super().make_connection(host)
35
+ connection.timeout = self._timeout
36
+ return connection
37
+
38
+
39
+ class RpcClient:
40
+ def __init__(
41
+ self,
42
+ port: int,
43
+ token: str,
44
+ *,
45
+ timeout_scale: float = 1.0,
46
+ socket_timeout: float = _SOCKET_TIMEOUT,
47
+ ) -> None:
48
+ self.port = port
49
+ self.token = token
50
+ self.timeout_scale = timeout_scale
51
+ self._proxy = xmlrpc.client.ServerProxy(
52
+ f"http://127.0.0.1:{port}",
53
+ allow_none=True,
54
+ use_builtin_types=True,
55
+ transport=_TimeoutTransport(socket_timeout * timeout_scale),
56
+ )
57
+ # ServerProxy reuses a single HTTP connection, which isn't safe for
58
+ # concurrent requests (e.g. wait_for polling while another thread emits).
59
+ self._lock = threading.Lock()
60
+
61
+ @classmethod
62
+ def from_handshake(
63
+ cls, handshake: Handshake, *, token: str, timeout_scale: float = 1.0
64
+ ) -> RpcClient:
65
+ return cls(handshake.port, token, timeout_scale=timeout_scale)
66
+
67
+ def call(self, method: str, *args: Any) -> Any:
68
+ try:
69
+ with self._lock:
70
+ return getattr(self._proxy, method)(self.token, *args)
71
+ except xmlrpc.client.Fault as fault:
72
+ message = fault.faultString
73
+ if "AUTH:" in message:
74
+ raise AuthError(
75
+ f"The spy rejected our token calling {method!r}. "
76
+ "A stale NVDA from a previous run is the usual cause. "
77
+ f"Remote said: {message}"
78
+ ) from fault
79
+ raise RpcError(f"{method}() failed inside NVDA: {message}") from fault
80
+ except xmlrpc.client.ProtocolError as error:
81
+ # Not an OSError, so it would otherwise escape untranslated.
82
+ raise RpcError(
83
+ f"The spy answered {method!r} with HTTP {error.errcode} "
84
+ f"{error.errmsg} at {error.url}."
85
+ ) from error
86
+ except OSError as error:
87
+ raise RpcError(
88
+ f"Could not reach the spy on 127.0.0.1:{self.port} calling {method!r}. "
89
+ "NVDA has probably died. "
90
+ f"Transport error: {error}"
91
+ ) from error
92
+
93
+ def poll_until(
94
+ self,
95
+ fn: Callable[[], Any],
96
+ *,
97
+ timeout: float,
98
+ description: str,
99
+ interval: float = _DEFAULT_INTERVAL,
100
+ last_seen: Callable[[], Any] | None = None,
101
+ ) -> Any:
102
+ """Poll `fn` until it returns something truthy, or the deadline passes."""
103
+ scaled = timeout * self.timeout_scale
104
+ deadline = time.monotonic() + scaled
105
+ while True:
106
+ result = fn()
107
+ if result:
108
+ return result
109
+ if time.monotonic() >= deadline:
110
+ observed = None
111
+ if last_seen is not None:
112
+ try:
113
+ observed = last_seen()
114
+ except RpcError:
115
+ observed = "(unavailable: NVDA unreachable)"
116
+ raise WaitTimeout(description, scaled, observed)
117
+ time.sleep(interval)
118
+
119
+ def close(self) -> None:
120
+ transport = getattr(self._proxy, "_ServerProxy__transport", None)
121
+ if transport is not None:
122
+ transport.close()
@@ -0,0 +1,68 @@
1
+ """Configuration from [tool.nvda-testkit] in pyproject.toml, plus CLI overrides.
2
+
3
+ tomllib is stdlib from 3.11, which is the floor -- no external TOML dependency.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import tomllib
9
+ from dataclasses import dataclass, replace
10
+ from pathlib import Path
11
+ from typing import cast
12
+
13
+ _KEY_MAP = {
14
+ "nvda-channel": "channel",
15
+ "addon-bundle": "addon_bundle",
16
+ "modules": "modules",
17
+ "allow-eval": "allow_eval",
18
+ "timeout-scale": "timeout_scale",
19
+ "keep-portable": "keep_portable",
20
+ "out-dir": "out_dir",
21
+ }
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class TestkitSettings:
26
+ channel: str = "stable"
27
+ addon_bundle: str | None = None
28
+ modules: tuple[str, ...] = ()
29
+ allow_eval: bool = False
30
+ timeout_scale: float = 1.0
31
+ keep_portable: bool = False
32
+ out_dir: Path = Path("testOutput")
33
+
34
+
35
+ def _coerce(field: str, value):
36
+ if field == "modules":
37
+ return tuple(value)
38
+ if field == "out_dir":
39
+ return Path(value)
40
+ if field == "timeout_scale":
41
+ return float(value)
42
+ if field in ("allow_eval", "keep_portable"):
43
+ return bool(value)
44
+ return value
45
+
46
+
47
+ def load_settings(
48
+ pyproject: Path | None = None,
49
+ overrides: dict | None = None,
50
+ ) -> TestkitSettings:
51
+ path = Path(pyproject) if pyproject is not None else Path("pyproject.toml")
52
+ values: dict = {}
53
+
54
+ if path.is_file():
55
+ with path.open("rb") as handle:
56
+ document = tomllib.load(handle)
57
+ section = document.get("tool", {}).get("nvda-testkit", {})
58
+ for key, value in section.items():
59
+ field = _KEY_MAP.get(key)
60
+ if field:
61
+ values[field] = _coerce(field, value)
62
+
63
+ # A None override means "not specified on the command line", not "clear it".
64
+ for field, value in (overrides or {}).items():
65
+ if value is not None:
66
+ values[field] = _coerce(field, value)
67
+
68
+ return cast(TestkitSettings, replace(TestkitSettings(), **values))
@@ -0,0 +1,74 @@
1
+ """Host-side representation of a captured speech sequence.
2
+
3
+ Deliberately not NVDA's own SpeechSequence: these come off the wire as plain
4
+ dicts, and the point is that a test can assert on them without importing
5
+ anything from NVDA.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SpeechItem:
17
+ kind: str
18
+ text: str | None = None
19
+ command_type: str | None = None
20
+ fields: dict[str, Any] = field(default_factory=dict)
21
+ raw: str | None = None
22
+
23
+ @classmethod
24
+ def from_payload(cls, payload: dict) -> SpeechItem:
25
+ if payload.get("kind") == "text":
26
+ return cls(kind="text", text=payload.get("text", ""))
27
+ return cls(
28
+ kind="command",
29
+ command_type=payload.get("type"),
30
+ fields=dict(payload.get("fields") or {}),
31
+ raw=payload.get("repr"),
32
+ )
33
+
34
+ def __repr__(self) -> str:
35
+ if self.kind == "text":
36
+ return f"text({self.text!r})"
37
+ return f"{self.command_type}({self.fields})"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class SpeechSequence:
42
+ items: tuple[SpeechItem, ...]
43
+ timestamp: float = 0.0
44
+ cancelled: bool = False
45
+
46
+ @property
47
+ def text(self) -> str:
48
+ # Space-join then collapse. NVDA emits both ["Install", "button"] and
49
+ # ["Install", " ", "button"]; a plain " ".join doubles the space in the
50
+ # second shape and breaks the regex a test author would naturally write.
51
+ joined = " ".join(item.text for item in self.items if item.kind == "text" and item.text)
52
+ return re.sub(r"\s+", " ", joined).strip()
53
+
54
+ def commands(self, type_name: str) -> list[SpeechItem]:
55
+ return [item for item in self.items if item.command_type == type_name]
56
+
57
+ def matches(self, pattern: str, *, flags: int = re.IGNORECASE) -> bool:
58
+ return re.search(pattern, self.text, flags) is not None
59
+
60
+ def __repr__(self) -> str:
61
+ suffix = " [cancelled]" if self.cancelled else ""
62
+ return f"<SpeechSequence {self.text!r}{suffix}>"
63
+
64
+
65
+ def parse_sequence(payload: dict) -> SpeechSequence:
66
+ return SpeechSequence(
67
+ items=tuple(SpeechItem.from_payload(item) for item in payload.get("items") or []),
68
+ timestamp=float(payload.get("timestamp") or 0.0),
69
+ cancelled=bool(payload.get("cancelled")),
70
+ )
71
+
72
+
73
+ def parse_sequences(payloads: list[dict]) -> list[SpeechSequence]:
74
+ return [parse_sequence(payload) for payload in payloads]
@@ -0,0 +1,24 @@
1
+ """Locate the spy add-on bundle shipped inside the wheel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .errors import ProvisionError
8
+
9
+ BUNDLE_NAME = "nvda-testkit-spy.nvda-addon"
10
+
11
+
12
+ def spy_bundle_path() -> Path:
13
+ bundle = Path(__file__).parent / "_spy" / BUNDLE_NAME
14
+ return bundle
15
+
16
+
17
+ def require_spy_bundle() -> Path:
18
+ bundle = spy_bundle_path()
19
+ if not bundle.is_file():
20
+ raise ProvisionError(
21
+ f"The spy add-on bundle is missing at {bundle}. "
22
+ "In a source checkout, run: python tools/build_spy.py"
23
+ )
24
+ return bundle