From 623bf5a59b16ca7d76d73e4d7be6ed928e0615a9 Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Thu, 1 Oct 2026 01:54:12 +0200 Subject: [PATCH 1/2] Compare the public API with the WPT WebIDL --- tests/idl/README.md | 37 +++ tests/idl/__init__.py | 6 + tests/idl/__main__.py | 55 ++++ tests/idl/child.py | 35 +++ tests/idl/compare.py | 386 +++++++++++++++++++++++++ tests/idl/expectations.json | 562 ++++++++++++++++++++++++++++++++++++ tests/idl/expectations.py | 34 +++ tests/idl/spec.py | 173 +++++++++++ tests/idl/test_compare.py | 166 +++++++++++ tests/idl/test_idl.py | 37 +++ 10 files changed, 1491 insertions(+) create mode 100644 tests/idl/README.md create mode 100644 tests/idl/__init__.py create mode 100644 tests/idl/__main__.py create mode 100644 tests/idl/child.py create mode 100644 tests/idl/compare.py create mode 100644 tests/idl/expectations.json create mode 100644 tests/idl/expectations.py create mode 100644 tests/idl/spec.py create mode 100644 tests/idl/test_compare.py create mode 100644 tests/idl/test_idl.py diff --git a/tests/idl/README.md b/tests/idl/README.md new file mode 100644 index 0000000..7bfd82a --- /dev/null +++ b/tests/idl/README.md @@ -0,0 +1,37 @@ +# WebIDL comparison + +Compares the public API of the `webrtc` package with the WebIDL of the specifications it implements, taken from +`wpt/interfaces/`. Updating the WPT checkout brings in spec changes, and new differences fail the tests. + +It needs the WPT checkout and PythonMonkey (see [tests/wpt/README.md](../wpt/README.md)) and Python 3.10 or later; +otherwise the tests are skipped. The IDL is parsed by `webidl2.js` of the checkout, the parser of idlharness, in a +child process. + +## Running + +```sh +uv run pytest tests/idl # compare every definition with expectations.json +uv run python -m tests.idl # print every difference, + for new ones and - for gone ones +uv run python -m tests.idl update # record the current differences as expected +``` + +## What is compared + +`spec.FILES` lists the IDL files and which definitions to take from each. Every interface, dictionary and enum maps +to the object of the same name in `webrtc`: + +- enums: the values. +- dictionaries: every member has a snake_case name and a camelCase alias, required members have no default, and + annotations name the IDL types the member refers to. +- interfaces: attributes and methods by name and alias, read-only or writable attributes, static and async methods + (a promise is a coroutine or returns a future), `on` handlers against `_events`, maplike and iterable + declarations against the Python protocols. +- arguments of methods and constructors: names, order, optional and variadic ones, and types. A dictionary argument + is either one parameter or keyword parameters for its members, like `create_offer(*, ice_restart=False)`, whose + members are then checked one by one. Overloads match the one the signature fits best. +- public members the IDL doesn't have. + +## Expectations + +`expectations.json` lists the differences per definition. A definition fails when its differences change in either +direction, so after aligning the API or updating WPT, run `update` and commit the new expectations with the change. diff --git a/tests/idl/__init__.py b/tests/idl/__init__.py new file mode 100644 index 0000000..f13f15d --- /dev/null +++ b/tests/idl/__init__.py @@ -0,0 +1,6 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# diff --git a/tests/idl/__main__.py b/tests/idl/__main__.py new file mode 100644 index 0000000..e10d071 --- /dev/null +++ b/tests/idl/__main__.py @@ -0,0 +1,55 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Command line for the IDL comparison. + +python -m tests.idl print every difference, marking new (+) and gone (-) ones against expectations.json +python -m tests.idl update record the current differences as expected +""" + +from __future__ import annotations + +import argparse +import logging + +import webrtc +from tests.idl import expectations +from tests.idl.compare import compare +from tests.idl.spec import load + +logger = logging.getLogger(__name__) + + +def report(differences: dict[str, list[str]]) -> None: + expected = expectations.load() + for name in sorted(differences.keys() | expected.keys()): + changed = set(expectations.mismatches(expected.get(name, []), differences.get(name, []))) + items = sorted(set(differences.get(name, [])) | set(expected.get(name, []))) + logger.info(name) + for item in items: + mark = '+' if f'+ {item}' in changed else '-' if f'- {item}' in changed else ' ' + logger.info(' %s %s', mark, item) + total = sum(map(len, differences.values())) + logger.info('%d differences in %d definitions', total, len(differences)) + + +def main() -> None: + parser = argparse.ArgumentParser(prog='python -m tests.idl') + parser.add_argument('command', nargs='?', choices=['report', 'update'], default='report') + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format='%(message)s') + + differences = compare(load(), webrtc) + if args.command == 'update': + expectations.save(differences) + logger.info('recorded %d differences', sum(map(len, differences.values()))) + else: + report(differences) + + +if __name__ == '__main__': + main() diff --git a/tests/idl/child.py b/tests/idl/child.py new file mode 100644 index 0000000..39e72e9 --- /dev/null +++ b/tests/idl/child.py @@ -0,0 +1,35 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Parses IDL with webidl2.js of the WPT checkout, the parser idlharness uses. + +Reads ``{file name: IDL text}`` as JSON from stdin and writes the JSON AST of every definition, with its file name. +""" + +from __future__ import annotations + +import json +import sys + +import pythonmonkey as pm + +from tests.wpt.loader import WPT_ROOT + + +def main() -> None: + pm.eval((WPT_ROOT / 'resources' / 'webidl2' / 'lib' / 'webidl2.js').read_text()) + parse = pm.eval('(text) => JSON.stringify(globalThis.WebIDL2.parse(text))') + definitions = [] + for name, text in json.load(sys.stdin).items(): + for definition in json.loads(parse(text)): + definition['file'] = name + definitions.append(definition) + json.dump(definitions, sys.stdout) + + +if __name__ == '__main__': + main() diff --git a/tests/idl/compare.py b/tests/idl/compare.py new file mode 100644 index 0000000..f1a610a --- /dev/null +++ b/tests/idl/compare.py @@ -0,0 +1,386 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Finds the differences between the IDL definitions and the public API of the webrtc package. + +Every definition maps to the object of the same name in the package. Members and arguments map by their snake_case +name, and members need their camelCase alias too. A dictionary argument is either one parameter or keyword +parameters for its members, like ``create_offer(*, ice_restart=False)``. +""" + +from __future__ import annotations + +import ast +import dataclasses +import enum +import functools +import inspect +import re +import textwrap +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + + from tests.idl.spec import Definition, Node, Spec + +_BOUNDARY = re.compile(r'(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])') +_AWAITABLE = re.compile(r'\b(Future|Awaitable|Coroutine|Task)\b') +_P = inspect.Parameter + +# the Python protocol for maplike, setlike and iterable declarations +_PROTOCOLS = { + 'maplike': ('__getitem__', '__iter__', '__len__', '__contains__', 'get', 'keys', 'values', 'items'), + 'setlike': ('__iter__', '__len__', '__contains__'), + 'iterable': ('__iter__',), +} + + +def snake_case(name: str) -> str: + """``'insertDTMF'`` -> ``'insert_dtmf'``, the way the package names acronyms.""" + return _BOUNDARY.sub('_', name).lower() + + +@functools.cache +def _assigned(cls: type) -> frozenset[str]: + """The attributes the methods of a class assign to ``self``.""" + try: + tree = ast.parse(textwrap.dedent(inspect.getsource(cls))) + except (OSError, TypeError): + return frozenset() + names = set() + for node in ast.walk(tree): + targets = node.targets if isinstance(node, ast.Assign) else [getattr(node, 'target', None)] + names.update( + target.attr + for target in targets + if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == 'self' + ) + return frozenset(names) + + +def _names(cls: type) -> set[str]: + """The public names of a class and its instances.""" + names = set(dir(cls)) + for klass in cls.__mro__: + names |= set(inspect.get_annotations(klass)) | _assigned(klass) + return {name for name in names if not name.startswith('_')} + + +def _text(annotation: object) -> str | None: + """An annotation as text, the way ``from __future__ import annotations`` keeps it.""" + if annotation is _P.empty: + return None + return annotation if isinstance(annotation, str) else getattr(annotation, '__name__', str(annotation)) + + +def _is_async(function: Callable[..., Any]) -> bool: + """Whether a function is a coroutine function or returns an awaitable, like a future.""" + if inspect.iscoroutinefunction(function): + return True + return _AWAITABLE.search(_text(inspect.signature(function).return_annotation) or '') is not None + + +def _type_names(idl_type: Node) -> str: + inner = idl_type['idlType'] + return inner if isinstance(inner, str) else ' '.join(_type_names(item) for item in inner) + + +def _is_handler(member: Node) -> bool: + """Whether a member is an event handler attribute, which the package replaces with ``on(name)``.""" + return ( + member['type'] == 'attribute' + and member['name'].startswith('on') + and 'EventHandler' in _type_names(member['idlType']) + ) + + +def _default(label: str, *, required: bool, has_default: bool) -> list[str]: + if required and has_default: + return [f'{label}: should be required'] + if not required and not has_default: + return [f'{label}: should be optional'] + return [] + + +class _Types: + def __init__(self, spec: Spec) -> None: + self.spec = spec + + def lacks(self, label: str, idl_type: Node, annotation: object) -> list[str]: + """Checks that an annotation names the definitions the IDL type refers to.""" + text = _text(annotation) + if text is None: + return [] + missing = sorted(name for name in self.spec.named_types(idl_type) if not re.search(rf'\b{name}\b', text)) + return [f'{label}: type lacks {", ".join(missing)}'] if missing else [] + + +class _Arguments(_Types): + """Matches the arguments of one overload with the parameters of a signature.""" + + def __init__(self, spec: Spec, label: str, parameters: list[inspect.Parameter]) -> None: + super().__init__(spec) + self.label = label + self.parameters = parameters + self.unused = {parameter.name: parameter for parameter in parameters} + self.order: list[int] = [] + self.found: list[str] = [] + + def check(self, arguments: list[Node]) -> list[str]: + for index, argument in enumerate(arguments): + self.argument(index, argument) + if self.order != sorted(self.order): + self.found.append(f'{self.label}: arguments out of order') + for parameter in self.unused.values(): + prefix = {_P.VAR_POSITIONAL: '*', _P.VAR_KEYWORD: '**'}.get(parameter.kind, '') + self.found.append(f'{self.label}({prefix}{parameter.name}): extra argument') + return self.found + + def take(self, name: str) -> inspect.Parameter | None: + return self.unused.pop(snake_case(name), None) or self.unused.pop(name, None) + + def argument(self, index: int, argument: Node) -> None: + path = f'{self.label}({argument["name"]})' + parameter = self.take(argument['name']) + if parameter is None: + members = self.flattened(argument) + if members is not None: + self.members(f'{self.label}({argument["name"]}', members) + return + parameter = self.positional(index) + if parameter is None: + self.found.append(f'{path}: missing argument') + return + self.found.append(f'{path}: named {parameter.name}') + + if argument['variadic'] != (parameter.kind is _P.VAR_POSITIONAL): + self.found.append(f'{path}: should {"" if argument["variadic"] else "not "}be variadic') + elif parameter.kind is _P.KEYWORD_ONLY: + self.found.append(f'{path}: should be positional') + else: + self.order.append(self.parameters.index(parameter)) + has_default = parameter.default is not _P.empty or parameter.kind is _P.VAR_POSITIONAL + optional = argument['optional'] or argument['variadic'] + self.found += _default(path, required=not optional, has_default=has_default) + self.found += self.lacks(path, argument['idlType'], parameter.annotation) + + def positional(self, index: int) -> inspect.Parameter | None: + """The unused parameter at the position of an argument, which has another name then.""" + if index >= len(self.parameters): + return None + parameter = self.parameters[index] + if parameter.name not in self.unused or parameter.kind in {_P.KEYWORD_ONLY, _P.VAR_KEYWORD}: + return None + return self.unused.pop(parameter.name) + + def flattened(self, argument: Node) -> list[Node] | None: + """The members of a dictionary argument, if the signature takes them as parameters of their own.""" + dictionary = self.spec.dictionary(argument['idlType']) + if dictionary is None: + return None + members = self.spec.members(dictionary.name) + matched = [ + self.unused[name] + for member in members + for name in {member['name'], snake_case(member['name'])} & self.unused.keys() + ] + # a lone positional parameter named like a member but annotated as a dictionary is the argument, renamed + lone = len(matched) == 1 and matched[0].kind is not _P.KEYWORD_ONLY + if not matched or ( + lone and re.search(rf'\b(dict|Mapping|{dictionary.name})\b', _text(matched[0].annotation) or '') + ): + return None + return members + + def members(self, prefix: str, members: list[Node]) -> None: + for member in members: + path = f'{prefix}.{member["name"]})' + parameter = self.take(member['name']) + if parameter is None: + self.found.append(f'{path}: missing member') + continue + has_default = parameter.default is not _P.empty + self.found += _default(path, required=member['required'], has_default=has_default) + self.found += self.lacks(path, member['idlType'], parameter.annotation) + + +class _Class(_Types): + """The differences of one class from its definition.""" + + def __init__(self, spec: Spec, definition: Definition, cls: type) -> None: + super().__init__(spec) + self.definition = definition + self.cls = cls + self.names = _names(cls) + self.expected: set[str] = set() + self.found: list[str] = [] + + def resolve(self, idl_name: str, kind: str) -> str | None: + """Checks the snake_case name and the camelCase alias of a member, returning the one to inspect.""" + snake = snake_case(idl_name) + self.expected |= {idl_name, snake} + if snake not in self.names and idl_name not in self.names: + self.found.append(f'{idl_name}: missing {kind}') + return None + if snake not in self.names: + self.found.append(f'{idl_name}: missing snake_case name {snake}') + elif idl_name not in self.names: + self.found.append(f'{idl_name}: missing camelCase alias') + return snake if snake in self.names else idl_name + + def annotation(self, name: str) -> object: + for klass in self.cls.__mro__: + annotations = inspect.get_annotations(klass) + if name in annotations: + return annotations[name] + return _P.empty + + def extras(self) -> list[str]: + infrastructure = set() + for base in self.cls.__mro__[1:]: + if base.__name__ not in self.spec.definitions: + infrastructure |= _names(base) + extra = self.names - self.expected - infrastructure + # a snake_case name and its camelCase alias are one member + return [f'{name}: extra member' for name in extra if snake_case(name) == name or snake_case(name) not in extra] + + def check_enum(self) -> list[str]: + if not issubclass(self.cls, enum.Enum): + return ['is not an enum'] + values = {member.value for member in self.cls} + wanted = self.definition.values + return [f'missing value {value!r}' for value in wanted if value not in values] + [ + f'extra value {value!r}' for value in values - set(wanted) + ] + + def check_dictionary(self) -> list[str]: + fields = ( + {field.name: field for field in dataclasses.fields(self.cls)} if dataclasses.is_dataclass(self.cls) else {} + ) + for member in self.spec.members(self.definition.name): + name = self.resolve(member['name'], 'member') + if name is None: + continue + if name in fields: + field = fields[name] + has_default = ( + field.default is not dataclasses.MISSING or field.default_factory is not dataclasses.MISSING + ) + self.found += _default(member['name'], required=member['required'], has_default=has_default) + self.found += self.lacks(member['name'], member['idlType'], self.annotation(name)) + return self.found + self.extras() + + def check_interface(self) -> list[str]: + members = self.spec.members(self.definition.name) + operations: dict[str, list[Node]] = {} + for member in members: + if member['type'] == 'operation' and member['name']: + operations.setdefault(member['name'], []).append(member) + else: + self.check_member(member) + + self.check_events({member['name'][2:] for member in members if _is_handler(member)}) + constructors = [member for member in members if member['type'] == 'constructor'] + if constructors: + self.check_overloads('constructor', constructors, lambda: inspect.signature(self.cls)) + for name, overloads in operations.items(): + self.check_operation(name, overloads) + return self.found + self.extras() + + def check_member(self, member: Node) -> None: + kind, name = member['type'], member.get('name') + if kind in _PROTOCOLS: + self.expected |= set(_PROTOCOLS[kind]) + self.found += [f'{kind}: missing {method}' for method in _PROTOCOLS[kind] if not hasattr(self.cls, method)] + elif kind == 'const': + self.expected.add(name) + if name not in self.names: + self.found.append(f'{name}: missing constant') + elif _is_handler(member): + self.expected |= {name, snake_case(name)} + elif kind == 'attribute': + self.check_attribute(member) + + def check_events(self, events: set[str]) -> None: + actual = set(getattr(self.cls, '_events', ())) + self.found += [f'on{event}: missing event' for event in events - actual] + self.found += [f'on{event}: extra event' for event in actual - events] + + def check_attribute(self, member: Node) -> None: + idl_name = member['name'] + name = self.resolve(idl_name, 'attribute') + if name is None: + return + attribute = inspect.getattr_static(self.cls, name, None) + if inspect.isfunction(attribute): + self.found.append(f'{idl_name}: should be an attribute, not a method') + return + annotation = self.annotation(name) + if isinstance(attribute, property): + if member['readonly'] and attribute.fset is not None: + self.found.append(f'{idl_name}: should be read-only') + elif not member['readonly'] and attribute.fset is None: + self.found.append(f'{idl_name}: should be writable') + annotation = inspect.get_annotations(attribute.fget).get('return', _P.empty) + self.found += self.lacks(idl_name, member['idlType'], annotation) + + def check_operation(self, idl_name: str, overloads: list[Node]) -> None: + name = self.resolve(idl_name, 'method') + if name is None: + return + attribute = inspect.getattr_static(self.cls, name) + is_static = isinstance(attribute, (staticmethod, classmethod)) + function = attribute.__func__ if is_static else attribute + if not callable(function) or isinstance(attribute, property): + self.found.append(f'{idl_name}: should be a method') + return + static = overloads[0]['special'] == 'static' + if static != is_static: + self.found.append(f'{idl_name}: should {"" if static else "not "}be static') + promise = overloads[0]['idlType']['generic'] == 'Promise' + if promise != _is_async(function): + self.found.append(f'{idl_name}: should {"" if promise else "not "}be async') + + def signature() -> inspect.Signature: + result = inspect.signature(function) + if isinstance(attribute, staticmethod): + return result + return result.replace(parameters=list(result.parameters.values())[1:]) # self or cls + + self.check_overloads(idl_name, overloads, signature) + self.found += self.lacks(f'{idl_name}()', overloads[0]['idlType'], signature().return_annotation) + + def check_overloads(self, label: str, overloads: list[Node], signature: Callable[[], inspect.Signature]) -> None: + """Checks the arguments against the overload the signature matches best.""" + try: + parameters = list(signature().parameters.values()) + except (TypeError, ValueError): + return + results = [_Arguments(self.spec, label, parameters).check(overload['arguments']) for overload in overloads] + self.found += min(results, key=len) + + +def _differences(spec: Spec, definition: Definition, module: object) -> list[str]: + obj = getattr(module, definition.name, None) + if obj is None: + return [f'missing {definition.kind}'] + if not isinstance(obj, type): + return ['is not a class'] + checker = _Class(spec, definition, obj) + checks = {'enum': checker.check_enum, 'dictionary': checker.check_dictionary} + return checks.get(definition.kind, checker.check_interface)() + + +def compare(spec: Spec, module: object) -> dict[str, list[str]]: + """The differences of every definition that has some, by the name of the definition.""" + differences = {} + for name, definition in spec.definitions.items(): + found = sorted(set(_differences(spec, definition, module))) + if found: + differences[name] = found + return differences diff --git a/tests/idl/expectations.json b/tests/idl/expectations.json new file mode 100644 index 0000000..d4f1a32 --- /dev/null +++ b/tests/idl/expectations.json @@ -0,0 +1,562 @@ +{ + "AudioData": [ + "constructor(**options): extra argument", + "constructor(init): should be required" + ], + "AudioDataInit": [ + "transfer: missing member" + ], + "Blob": [ + "constructor(blobParts): named parts", + "constructor(blobParts): type lacks Blob", + "constructor(options.endings): missing member", + "stream: missing method", + "textStream: missing method" + ], + "BlobPropertyBag": [ + "missing dictionary" + ], + "CameraDevicePermissionDescriptor": [ + "missing dictionary" + ], + "ConstrainBooleanOrDOMStringParameters": [ + "missing dictionary" + ], + "ConstrainBooleanParameters": [ + "missing dictionary" + ], + "ConstrainDOMStringParameters": [ + "missing dictionary" + ], + "ConstrainDoubleRange": [ + "missing dictionary" + ], + "ConstrainULongRange": [ + "missing dictionary" + ], + "DOMRectInit": [ + "missing dictionary" + ], + "DOMRectReadOnly": [ + "fromRect: missing method", + "toJSON: missing method" + ], + "DeviceChangeEvent": [ + "missing interface" + ], + "DeviceChangeEventInit": [ + "missing dictionary" + ], + "EchoCancellationModeEnum": [ + "missing enum" + ], + "EncodedVideoChunkType": [ + "missing enum" + ], + "EndingType": [ + "missing enum" + ], + "InputDeviceInfo": [ + "missing interface" + ], + "KeyFrameRequestEvent": [ + "missing interface" + ], + "MediaDeviceInfo": [ + "missing interface" + ], + "MediaDeviceKind": [ + "missing enum" + ], + "MediaDevices": [ + "missing interface" + ], + "MediaStream": [ + "constructor(tracks): extra argument" + ], + "MediaStreamConstraints": [ + "missing dictionary" + ], + "MediaStreamTrack": [ + "content_hint: extra member" + ], + "MediaStreamTrackEvent": [ + "constructor(target): extra argument" + ], + "MediaStreamTrackEventInit": [ + "missing dictionary" + ], + "MediaStreamTrackHandle": [ + "missing interface" + ], + "MediaStreamTrackProcessor": [ + "constructor(**options): extra argument", + "constructor(init.track): should be required", + "constructor(init.track): type lacks MediaStreamTrackHandle" + ], + "MediaStreamTrackProcessorInit": [ + "track: type lacks MediaStreamTrackHandle" + ], + "MediaTrackCapabilities": [ + "backgroundBlur: missing member", + "facingMode: missing member", + "latency: missing member" + ], + "MediaTrackConstraintSet": [ + "missing dictionary" + ], + "MediaTrackConstraints": [ + "advanced: type lacks MediaTrackConstraintSet", + "aspectRatio: type lacks ConstrainDoubleRange", + "autoGainControl: type lacks ConstrainBooleanParameters", + "backgroundBlur: missing member", + "channelCount: type lacks ConstrainULongRange", + "deviceId: type lacks ConstrainDOMStringParameters", + "echoCancellation: type lacks ConstrainBooleanOrDOMStringParameters", + "facingMode: missing member", + "frameRate: type lacks ConstrainDoubleRange", + "groupId: type lacks ConstrainDOMStringParameters", + "height: type lacks ConstrainULongRange", + "latency: missing member", + "noiseSuppression: type lacks ConstrainBooleanParameters", + "resizeMode: type lacks ConstrainDOMStringParameters", + "sampleRate: type lacks ConstrainULongRange", + "sampleSize: type lacks ConstrainULongRange", + "width: type lacks ConstrainULongRange" + ], + "MediaTrackSettings": [ + "backgroundBlur: missing member", + "facingMode: missing member", + "latency: missing member" + ], + "MediaTrackSupportedConstraints": [ + "missing dictionary" + ], + "QueuingStrategy": [ + "missing dictionary" + ], + "RTCAnswerOptions": [ + "missing dictionary" + ], + "RTCAudioPlayoutStats": [ + "missing dictionary" + ], + "RTCAudioSourceStats": [ + "missing dictionary" + ], + "RTCCertificate": [ + "expired: extra member", + "generate: extra member" + ], + "RTCCertificateExpiration": [ + "missing dictionary" + ], + "RTCCertificateStats": [ + "missing dictionary" + ], + "RTCCodecStats": [ + "missing dictionary" + ], + "RTCConfiguration": [ + "always_negotiate_data_channels: extra member", + "port_range: extra member", + "rtp_header_encryption_policy: extra member" + ], + "RTCDTMFToneChangeEvent": [ + "constructor(target): extra argument" + ], + "RTCDTMFToneChangeEventInit": [ + "missing dictionary" + ], + "RTCDataChannelEvent": [ + "constructor(target): extra argument" + ], + "RTCDataChannelEventInit": [ + "missing dictionary" + ], + "RTCDataChannelStats": [ + "missing dictionary" + ], + "RTCDtlsFingerprint": [ + "algorithm: should be optional", + "value: should be optional" + ], + "RTCDtlsRole": [ + "missing enum" + ], + "RTCDtlsTransport": [ + "state: type lacks RTCDtlsTransportState" + ], + "RTCDtlsTransportState": [ + "missing enum" + ], + "RTCEncodedAudioFrame": [ + "missing interface" + ], + "RTCEncodedAudioFrameMetadata": [ + "missing dictionary" + ], + "RTCEncodedAudioFrameOptions": [ + "missing dictionary" + ], + "RTCEncodedFrameMetadata": [ + "missing dictionary" + ], + "RTCEncodedVideoFrame": [ + "missing interface" + ], + "RTCEncodedVideoFrameMetadata": [ + "missing dictionary" + ], + "RTCEncodedVideoFrameOptions": [ + "missing dictionary" + ], + "RTCError": [ + "constructor(init): named options", + "http_request_status_code: extra member", + "message: extra member" + ], + "RTCErrorEvent": [ + "constructor(target): extra argument" + ], + "RTCErrorEventInit": [ + "missing dictionary" + ], + "RTCErrorInit": [ + "http_request_status_code: extra member" + ], + "RTCIceCandidate": [ + "from_json: extra member", + "toJSON(): type lacks RTCIceCandidateInit" + ], + "RTCIceCandidateInit": [ + "missing dictionary" + ], + "RTCIceCandidatePairStats": [ + "missing dictionary" + ], + "RTCIceCandidateStats": [ + "missing dictionary" + ], + "RTCIceConnectionState": [ + "extra value 'max'" + ], + "RTCIceGatherOptions": [ + "missing dictionary" + ], + "RTCIceGathererState": [ + "missing enum" + ], + "RTCIceParameters": [ + "iceLite: missing member", + "password: should be optional", + "usernameFragment: should be optional" + ], + "RTCIceServer": [ + "credential_type: extra member" + ], + "RTCIceTransport": [ + "addRemoteCandidate(remoteCandidate): named candidate", + "addRemoteCandidate(remoteCandidate): should be optional", + "addRemoteCandidate(remoteCandidate): type lacks RTCIceCandidateInit", + "gatheringState: type lacks RTCIceGathererState", + "onerror: missing event", + "start(remoteParameters): should be optional" + ], + "RTCInboundRtpStreamStats": [ + "missing dictionary" + ], + "RTCLocalIceCandidateInit": [ + "missing dictionary" + ], + "RTCLocalSessionDescriptionInit": [ + "missing dictionary" + ], + "RTCMediaSourceStats": [ + "missing dictionary" + ], + "RTCOfferAnswerOptions": [ + "missing dictionary" + ], + "RTCOfferOptions": [ + "missing dictionary" + ], + "RTCOutboundRtpStreamStats": [ + "missing dictionary" + ], + "RTCPeerConnection": [ + "addIceCandidate(candidate): type lacks RTCIceCandidateInit", + "addTrack(streams): named stream", + "addTrack(streams): should be variadic", + "addTransceiver(init): type lacks RTCRtpTransceiverInit", + "createAnswer(options): missing argument", + "createAnswer(voice_activity_detection): extra argument", + "createDataChannel(dataChannelDict): named options", + "createOffer(voice_activity_detection): extra argument", + "generateCertificate(expires): extra argument", + "generateCertificate(keygenAlgorithm): named algorithm", + "generateCertificate(keygenAlgorithm): should be required", + "setLocalDescription(description): type lacks RTCLocalSessionDescriptionInit", + "setRemoteDescription(description): type lacks RTCSessionDescriptionInit" + ], + "RTCPeerConnectionIceErrorEvent": [ + "constructor(eventInitDict.address): should be optional", + "constructor(eventInitDict.errorText): should be optional", + "constructor(eventInitDict.port): should be optional", + "constructor(eventInitDict.url): should be optional", + "constructor(target): extra argument" + ], + "RTCPeerConnectionIceErrorEventInit": [ + "missing dictionary" + ], + "RTCPeerConnectionIceEvent": [ + "constructor(target): extra argument" + ], + "RTCPeerConnectionIceEventInit": [ + "missing dictionary" + ], + "RTCPeerConnectionStats": [ + "missing dictionary" + ], + "RTCQualityLimitationReason": [ + "missing enum" + ], + "RTCReceivedRtpStreamStats": [ + "missing dictionary" + ], + "RTCRemoteInboundRtpStreamStats": [ + "missing dictionary" + ], + "RTCRemoteOutboundRtpStreamStats": [ + "missing dictionary" + ], + "RTCRtpCapabilities": [ + "codecs: should be required", + "headerExtensions: should be required" + ], + "RTCRtpCodingParameters": [ + "missing dictionary" + ], + "RTCRtpEncodingParameters": [ + "adaptive_ptime: extra member" + ], + "RTCRtpHeaderExtensionCapability": [ + "direction: extra member" + ], + "RTCRtpParameters": [ + "missing dictionary" + ], + "RTCRtpReceiveParameters": [ + "codecs: should be required", + "headerExtensions: should be required", + "rtcp: should be required" + ], + "RTCRtpReceiver": [ + "transform: missing attribute" + ], + "RTCRtpSFrameDecryptor": [ + "missing interface" + ], + "RTCRtpSFrameEncryptor": [ + "missing interface" + ], + "RTCRtpSFrameEncryptorOptions": [ + "missing dictionary" + ], + "RTCRtpScriptTransform": [ + "missing interface" + ], + "RTCRtpScriptTransformType": [ + "missing enum" + ], + "RTCRtpScriptTransformer": [ + "missing interface" + ], + "RTCRtpSendParameters": [ + "codecs: should be required", + "degradation_preference: extra member", + "encodings: should be required", + "headerExtensions: should be required", + "rtcp: should be required" + ], + "RTCRtpSender": [ + "kind: extra member", + "replaceTrack(withTrack): named track", + "setParameters(key_frames): extra argument", + "setParameters(setParameterOptions): missing argument", + "transform: missing attribute" + ], + "RTCRtpStreamStats": [ + "missing dictionary" + ], + "RTCRtpTransceiver": [ + "currentDirection: type lacks RTCRtpTransceiverDirection", + "direction: type lacks RTCRtpTransceiverDirection", + "get_header_extensions_to_negotiate: extra member", + "get_negotiated_header_extensions: extra member", + "kind: extra member", + "set_header_extensions_to_negotiate: extra member", + "stopped: extra member" + ], + "RTCRtpTransceiverDirection": [ + "missing enum" + ], + "RTCRtpTransceiverInit": [ + "missing dictionary" + ], + "RTCSctpTransport": [ + "state: type lacks RTCSctpTransportState" + ], + "RTCSctpTransportState": [ + "missing enum" + ], + "RTCSentRtpStreamStats": [ + "missing dictionary" + ], + "RTCSessionDescription": [ + "toJSON(): type lacks RTCSessionDescriptionInit" + ], + "RTCSessionDescriptionInit": [ + "to_json: extra member" + ], + "RTCSetParameterOptions": [ + "missing dictionary" + ], + "RTCStatsIceCandidatePairState": [ + "missing enum" + ], + "RTCStatsReport": [ + "of_type: extra member" + ], + "RTCStatsType": [ + "missing enum" + ], + "RTCTrackEvent": [ + "constructor(eventInitDict.streams): should be optional", + "constructor(target): extra argument" + ], + "RTCTrackEventInit": [ + "missing dictionary" + ], + "RTCTransformEvent": [ + "missing interface" + ], + "RTCTransportStats": [ + "missing dictionary" + ], + "RTCVideoSourceStats": [ + "missing dictionary" + ], + "ReadableStream": [ + "constructor(strategy.size): missing member", + "from: missing method", + "getReader(options): missing argument", + "pipeThrough(options): should be optional", + "pipeThrough(options): type lacks StreamPipeOptions", + "pipeThrough(transform): type lacks ReadableWritablePair", + "pipeTo(options.signal): missing member", + "tee: missing method", + "values: extra member" + ], + "ReadableStreamDefaultController": [ + "enqueue(chunk): should be optional", + "error(e): named error" + ], + "ReadableStreamDefaultReader": [ + "read(): type lacks ReadableStreamReadResult" + ], + "ReadableStreamGetReaderOptions": [ + "missing dictionary" + ], + "ReadableStreamIteratorOptions": [ + "missing dictionary" + ], + "ReadableStreamReadResult": [ + "done: should be optional", + "value: should be optional" + ], + "ReadableStreamReaderMode": [ + "missing enum" + ], + "ReadableWritablePair": [ + "missing dictionary" + ], + "SFrameCipherSuite": [ + "missing enum" + ], + "SFrameDecryptorStream": [ + "missing interface" + ], + "SFrameEncryptorStream": [ + "missing interface" + ], + "SFrameTransformErrorEvent": [ + "missing interface" + ], + "SFrameTransformErrorEventInit": [ + "missing dictionary" + ], + "SFrameTransformErrorEventType": [ + "missing enum" + ], + "SFrameTransformOptions": [ + "missing dictionary" + ], + "SFrameType": [ + "missing enum" + ], + "StreamPipeOptions": [ + "missing dictionary" + ], + "TransformStream": [ + "constructor(readableStrategy): missing argument", + "constructor(writableStrategy): missing argument" + ], + "TransformStreamDefaultController": [ + "enqueue(chunk): should be optional", + "error(reason): named error" + ], + "VideoColorSpaceInit": [ + "missing dictionary" + ], + "VideoFacingModeEnum": [ + "missing enum" + ], + "VideoFrame": [ + "constructor(**options): extra argument", + "constructor(image): named source" + ], + "VideoFrameBufferInit": [ + "colorSpace: type lacks VideoColorSpaceInit", + "metadata: missing member", + "transfer: missing member", + "visibleRect: type lacks DOMRectInit" + ], + "VideoFrameCopyToOptions": [ + "colorSpace: missing member", + "rect: type lacks DOMRectInit" + ], + "VideoFrameInit": [ + "metadata: missing member", + "visibleRect: type lacks DOMRectInit" + ], + "VideoFrameMetadata": [ + "rtp_timestamp: extra member" + ], + "VideoResizeModeEnum": [ + "missing enum" + ], + "WorkerAndParameters": [ + "missing dictionary" + ], + "WritableStream": [ + "constructor(strategy.size): missing member" + ], + "WritableStreamDefaultController": [ + "error(e): named error", + "signal: missing attribute" + ], + "WritableStreamDefaultWriter": [ + "write(chunk): should be optional" + ] +} diff --git a/tests/idl/expectations.py b/tests/idl/expectations.py new file mode 100644 index 0000000..2b47110 --- /dev/null +++ b/tests/idl/expectations.py @@ -0,0 +1,34 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""The known differences between the IDL and the package, stored in expectations.json by definition name. + +Written by `python -m tests.idl update`. A test fails when the differences of a definition change in either +direction, so a fixed difference has to be recorded as well as a new one. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +PATH = Path(__file__).with_name('expectations.json') + + +def load() -> dict[str, list[str]]: + return json.loads(PATH.read_text()) if PATH.exists() else {} + + +def save(differences: dict[str, list[str]]) -> None: + PATH.write_text(json.dumps(dict(sorted(differences.items())), indent=2) + '\n') + + +def mismatches(expected: list[str], actual: list[str]) -> list[str]: + """The differences that are new, marked with +, and those that are gone, marked with -.""" + return [f'+ {item}' for item in sorted(set(actual) - set(expected))] + [ + f'- {item}' for item in sorted(set(expected) - set(actual)) + ] diff --git a/tests/idl/spec.py b/tests/idl/spec.py new file mode 100644 index 0000000..71af5cf --- /dev/null +++ b/tests/idl/spec.py @@ -0,0 +1,173 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""The interfaces, dictionaries and enums of the specifications the library implements, merged from WPT IDL files.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass, field +from importlib.util import find_spec +from typing import Any + +from tests.wpt.loader import WPT_ROOT + +# needs the WPT checkout, PythonMonkey to parse and inspect.get_annotations +AVAILABLE = sys.version_info >= (3, 10) and WPT_ROOT.is_dir() and find_spec('pythonmonkey') is not None + +# IDL files under wpt/interfaces, with the definitions to take from them: None takes all of them. Dictionaries and +# enums the taken definitions use come along, typedefs always do. +FILES: dict[str, set[str] | None] = { + 'webrtc.idl': None, + 'webrtc-ice.idl': None, + 'webrtc-priority.idl': None, + 'webrtc-svc.idl': None, + 'webrtc-stats.idl': None, + 'webrtc-encoded-transform.idl': None, + 'mediacapture-streams.idl': None, + 'mediacapture-transform.idl': None, + 'webcodecs.idl': {'VideoFrame', 'AudioData'}, + 'streams.idl': { + 'ReadableStream', + 'ReadableStreamDefaultReader', + 'ReadableStreamDefaultController', + 'WritableStream', + 'WritableStreamDefaultWriter', + 'WritableStreamDefaultController', + 'TransformStream', + 'TransformStreamDefaultController', + }, + 'FileAPI.idl': {'Blob'}, + 'geometry.idl': {'DOMRectReadOnly'}, + 'webcrypto.idl': set(), +} + +Node = dict[str, Any] + + +@dataclass +class Definition: + name: str + kind: str # interface, dictionary or enum + parent: str | None = None + members: list[Node] = field(default_factory=list) + values: list[str] = field(default_factory=list) + + +@dataclass +class Spec: + definitions: dict[str, Definition] + typedefs: dict[str, Node] + + def lineage(self, name: str) -> list[Definition]: + """The definition and its ancestors that are part of the spec, nearest first.""" + chain = [] + while name in self.definitions: + chain.append(self.definitions[name]) + name = self.definitions[name].parent + return chain + + def members(self, name: str) -> list[Node]: + """The members of a definition, inherited ones included.""" + return [member for definition in self.lineage(name) for member in definition.members] + + def named_types(self, idl_type: Node | list[Node] | str) -> set[str]: + """The definitions of the spec a type refers to, through unions, generics and typedefs.""" + if isinstance(idl_type, list): + return set().union(*(self.named_types(item) for item in idl_type)) + if isinstance(idl_type, dict): + return self.named_types(idl_type['idlType']) + if idl_type in self.typedefs: + return self.named_types(self.typedefs[idl_type]) + return {idl_type} if idl_type in self.definitions else set() + + def dictionary(self, idl_type: Node) -> Definition | None: + """The dictionary a type is, unless it's a union or a generic.""" + while not idl_type['union'] and not idl_type['generic'] and isinstance(idl_type['idlType'], str): + name = idl_type['idlType'] + if name in self.typedefs: + idl_type = self.typedefs[name] + continue + definition = self.definitions.get(name) + return definition if definition and definition.kind == 'dictionary' else None + return None + + +def load() -> Spec: + """The spec of :data:`FILES`.""" + return parse({name: (WPT_ROOT / 'interfaces' / name).read_text() for name in FILES}, FILES) + + +def parse(texts: dict[str, str], files: dict[str, set[str] | None]) -> Spec: + """Parses IDL texts by file name and keeps the definitions each file takes, as in :data:`FILES`.""" + nodes = json.loads( + subprocess.run( + [sys.executable, '-m', 'tests.idl.child'], + input=json.dumps(texts), + capture_output=True, + text=True, + check=True, + ).stdout + ) + spec = Spec(_merge(nodes), {node['name']: node['idlType'] for node in nodes if node['type'] == 'typedef'}) + taken = { + node['name'] + for node in nodes + if node.get('name') in spec.definitions + and not node.get('partial') + and (files[node['file']] is None or node['name'] in files[node['file']]) + } + return Spec({name: spec.definitions[name] for name in sorted(_used(spec, taken))}, spec.typedefs) + + +def _merge(nodes: list[Node]) -> dict[str, Definition]: + """The interfaces, dictionaries and enums, with the members of their partials and mixins.""" + definitions = { + node['name']: Definition( + node['name'], + node['type'], + node.get('inheritance'), + list(node.get('members', [])), + [value['value'] for value in node.get('values', [])], + ) + for node in nodes + if node['type'] in {'interface', 'dictionary', 'enum'} and not node.get('partial') + } + mixins: dict[str, list[Node]] = {} + for node in nodes: + if node['type'] == 'interface mixin': + mixins.setdefault(node['name'], []).extend(node['members']) + # partials and mixins extend definitions of any file, but only those that exist + for node in nodes: + if node.get('partial') and node['name'] in definitions: + definitions[node['name']].members.extend(node['members']) + elif node['type'] == 'includes' and node['target'] in definitions: + definitions[node['target']].members.extend(mixins.get(node['includes'], [])) + return definitions + + +def _used(spec: Spec, taken: set[str]) -> set[str]: + """The taken definitions, their ancestors and the dictionaries and enums they use.""" + wanted = set(taken) + queue = list(taken) + while queue: + definition = spec.definitions[queue.pop()] + used = {definition.parent} & spec.definitions.keys() + for member in definition.members: + types = [member.get('idlType')] + [argument['idlType'] for argument in member.get('arguments') or []] + used |= { + name + for idl_type in types + if idl_type + for name in spec.named_types(idl_type) + if spec.definitions[name].kind != 'interface' + } + queue += used - wanted + wanted |= used + return wanted diff --git a/tests/idl/test_compare.py b/tests/idl/test_compare.py new file mode 100644 index 0000000..043869f --- /dev/null +++ b/tests/idl/test_compare.py @@ -0,0 +1,166 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""The rules of the comparison, on small IDL and classes.""" + +from __future__ import annotations + +import enum +from collections import UserDict +from dataclasses import dataclass +from types import SimpleNamespace +from typing import TYPE_CHECKING + +import pytest + +from tests.idl.compare import compare, snake_case +from tests.idl.spec import AVAILABLE, parse + +if TYPE_CHECKING: + import asyncio + +pytestmark = pytest.mark.skipif(not AVAILABLE, reason='needs Python 3.10+, the WPT checkout and PythonMonkey') + +IDL = """ +enum Color { "red", "green" }; +dictionary Options { boolean loud = false; required DOMString name; }; +dictionary Point { double x; double y; }; +interface Base : EventTarget { readonly attribute Color color; }; +interface Thing : Base { + constructor(DOMString label, optional Options options = {}); + attribute unsigned long size; + readonly attribute DOMString sharedName; + attribute EventHandler onping; + Promise start(optional Options options = {}); + undefined move(Point point); + undefined add(DOMString... items); + undefined sendDTMF(DOMString tones); + static Thing create(); + undefined stopAll(); +}; +interface Report { readonly maplike; }; +interface Unused {}; +""" + + +def differences(**classes: type) -> dict[str, list[str]]: + return compare(parse({'test.idl': IDL}, {'test.idl': None}), SimpleNamespace(**classes)) + + +class Color(str, enum.Enum): + red = 'red' + blue = 'blue' + + +@dataclass +class Options: + name: str = '' + loud: bool = False + + +class Thing: + _events = ('ping', 'pong') + color: Color + + def __init__(self, label: str, *, loud: bool = False) -> None: + self._label = label + self._loud = loud + self.size = 0 + self.extra = 1 + + @property + def shared_name(self) -> str: + return '' + + @shared_name.setter + def shared_name(self, value: str) -> None: ... + + async def start(self, *, name: str, loud: bool = False) -> None: ... + + def stop_all(self) -> None: ... + + def move(self, point: dict[str, float], z: float = 0) -> None: ... + + def add(self, items: list[str]) -> None: ... + + def send_dtmf(self, tones: str) -> None: ... + + def create(self) -> Thing: ... + + +Thing.sharedName = Thing.shared_name +Thing.sendDTMF = Thing.send_dtmf + + +class Report(UserDict): + pass + + +def test_snake_case() -> None: + assert snake_case('insertDTMF') == 'insert_dtmf' + assert snake_case('toJSON') == 'to_json' + assert snake_case('sdpMLineIndex') == 'sdp_m_line_index' + + +def test_missing_definitions() -> None: + found = differences() + assert found['Unused'] == ['missing interface'] + assert found['Color'] == ['missing enum'] + assert found['Point'] == ['missing dictionary'] + + +def test_enum_values() -> None: + assert differences(Color=Color)['Color'] == ["extra value 'blue'", "missing value 'green'"] + + +def test_dictionary() -> None: + assert differences(Options=Options)['Options'] == ['name: should be required'] + + +def test_interface() -> None: + assert differences(Thing=Thing, Color=Color)['Thing'] == [ + 'add(items): should be optional', + 'add(items): should be variadic', + 'constructor(options.name): missing member', + 'create: should be static', + 'extra: extra member', + 'move(point): type lacks Point', + 'move(z): extra argument', + 'onpong: extra event', + 'sharedName: should be read-only', + 'stopAll: missing camelCase alias', + ] + + +def test_type_and_rename() -> None: + class Other: + color: str + + def move(self, where: Options, /) -> None: ... + + async def create(self) -> None: ... + + found = differences(Base=Other, Thing=type('Thing', (Other,), {})) + assert 'color: type lacks Color' in found['Base'] + assert 'move(point): named where' in found['Thing'] + assert 'create: should not be async' in found['Thing'] + assert 'create: should be static' in found['Thing'] + + +def test_maplike() -> None: + class Partial: + def __getitem__(self, key: str) -> object: ... + + assert 'Report' not in differences(Report=Report, Color=Color) + assert 'maplike: missing keys' in differences(Report=Partial)['Report'] + + +def test_future_counts_as_async() -> None: + class Thing: + def start(self) -> asyncio.Future[None]: ... + + assert 'start: should be async' not in differences(Thing=Thing)['Thing'] diff --git a/tests/idl/test_idl.py b/tests/idl/test_idl.py new file mode 100644 index 0000000..4732bc0 --- /dev/null +++ b/tests/idl/test_idl.py @@ -0,0 +1,37 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Compares the public API with the WebIDL of the WPT checkout and the differences with expectations.json. + +A definition fails when its differences change in either direction: run `python -m tests.idl update` after an +intended change and commit expectations.json with it. +""" + +from __future__ import annotations + +import pytest + +import webrtc +from tests.idl import expectations +from tests.idl.compare import compare +from tests.idl.spec import AVAILABLE, load + +pytestmark = pytest.mark.skipif(not AVAILABLE, reason='needs Python 3.10+, the WPT checkout and PythonMonkey') + +SPEC = load() if AVAILABLE else None +EXPECTED = expectations.load() + + +@pytest.fixture(scope='module') +def differences() -> dict[str, list[str]]: + return compare(SPEC, webrtc) + + +@pytest.mark.parametrize('name', sorted(set(SPEC.definitions) | set(EXPECTED)) if SPEC else []) +def test_definition(name: str, differences: dict[str, list[str]]) -> None: + problems = expectations.mismatches(EXPECTED.get(name, []), differences.get(name, [])) + assert not problems, '\n'.join([*problems, 'run `python -m tests.idl update` if the change is intended']) From 1ee544c25def39e3bff7ffeed4e8641a2134ec0d Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Thu, 1 Oct 2026 01:59:55 +0200 Subject: [PATCH 2/2] Fix path --- tests/idl/child.py | 2 +- tests/idl/spec.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/idl/child.py b/tests/idl/child.py index 39e72e9..5d3f17e 100644 --- a/tests/idl/child.py +++ b/tests/idl/child.py @@ -17,7 +17,7 @@ import pythonmonkey as pm -from tests.wpt.loader import WPT_ROOT +from tests.idl.spec import WPT_ROOT def main() -> None: diff --git a/tests/idl/spec.py b/tests/idl/spec.py index 71af5cf..b0cfd32 100644 --- a/tests/idl/spec.py +++ b/tests/idl/spec.py @@ -14,9 +14,10 @@ import sys from dataclasses import dataclass, field from importlib.util import find_spec +from pathlib import Path from typing import Any -from tests.wpt.loader import WPT_ROOT +WPT_ROOT = Path(__file__).resolve().parents[2] / 'wpt' # needs the WPT checkout, PythonMonkey to parse and inspect.get_annotations AVAILABLE = sys.version_info >= (3, 10) and WPT_ROOT.is_dir() and find_spec('pythonmonkey') is not None