# Copyright (c) Microsoft Corporation. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json import pathlib import re import subprocess from sys import stderr from typing import Any, Dict, List, Set, Union, get_args, get_origin, get_type_hints from urllib.parse import urljoin from playwright._impl._helper import to_snake_case enum_regex = r"^\"[^\"]+\"(?:\|\"[^\"]+\")+$" union_regex = r"^[^\|]+(?:\|[^\|]+)+$" class DocumentationProvider: def __init__(self, is_async: bool) -> None: self.is_async = is_async self.api: Any = {} self.links: Dict[str, str] = {} self.printed_entries: List[str] = [] process_output = subprocess.run( ["python", "-m", "playwright", "print-api-json"], check=True, capture_output=True, ) self.api = json.loads(process_output.stdout) self.errors: Set[str] = set() self.class_aliases: Dict[str, str] = { "Disposable": "AsyncContextManager" if is_async else "SyncContextManager", "DisposableStub": ( "AsyncContextManager" if is_async else "SyncContextManager" ), } self._patch_case() def _patch_case(self) -> None: self.classes = {} for clazz in self.api: if not works_for_python(clazz) and clazz["name"] not in self.class_aliases: continue members = {} self.classes[clazz["name"]] = clazz events = [] for member in clazz["members"]: if not works_for_python(member): continue member_name = member["name"] new_name = name_or_alias(member) self._add_link(member["kind"], clazz["name"], member_name, new_name) if member["kind"] == "event": events.append(member) else: new_name = to_snake_case(new_name) member["name"] = new_name members[new_name] = member apply_type_or_override(member) if "args" in member: args = {} for arg in member["args"]: if not works_for_python(arg): continue if arg["name"] == "options": for option in arg["type"]["properties"]: if not works_for_python(option): continue option = self_or_override(option) option_name = to_snake_case(name_or_alias(option)) option["name"] = option_name option["required"] = False args[option_name] = option else: arg = self_or_override(arg) arg_name = to_snake_case(name_or_alias(arg)) arg["name"] = arg_name args[arg_name] = arg member["args"] = args clazz["members"] = members clazz["events"] = events def _add_link(self, kind: str, clazz: str, member: str, alias: str) -> None: match = re.match(r"(JS|CDP|[A-Z])([^.]+)", clazz) if not match: raise Exception("Invalid class " + clazz) var_name = to_snake_case(f"{match.group(1).lower()}{match.group(2)}") new_name = to_snake_case(alias) if kind == "event": new_name = new_name.lower() self.links[f"[`event: {clazz}.{member}`]"] = ( f"`{var_name}.on('{new_name}')`" ) elif kind == "property": self.links[f"[`property: {clazz}.{member}`]"] = f"`{var_name}.{new_name}`" else: self.links[f"[`method: {clazz}.{member}`]"] = f"`{var_name}.{new_name}()`" def print_entry( self, class_name: str, method_name: str, signature: Dict[str, Any] = None, is_property: bool = False, ) -> None: if class_name in ["BindingCall"] or method_name in [ "pid", ]: return original_method_name = method_name self.printed_entries.append(f"{class_name}.{method_name}") clazz = self.classes[class_name] method = clazz["members"].get(method_name) if not method and "extends" in clazz: superclass = self.classes.get(clazz["extends"]) if superclass: method = superclass["members"].get(method_name) fqname = f"{class_name}.{method_name}" if not method: self.errors.add(f"Method not documented: {fqname}") return doc_is_property = ( not method.get("async") and not len(method["args"]) and "type" in method ) if ( method["name"].startswith("is_") or method["name"].startswith("as_") or method["name"] == "connect_to_server" ): doc_is_property = False if doc_is_property != is_property: self.errors.add(f"Method vs property mismatch: {fqname}") return indent = " " * 8 print(f'{indent}"""{class_name}.{to_snake_case(original_method_name)}') if method.get("comment"): print(f"{indent}{self.beautify_method_comment(method['comment'], indent)}") signature_no_return = {**signature} if signature else None if signature_no_return and "return" in signature_no_return: del signature_no_return["return"] # Collect a list of all names, flatten options. args = method["args"] if signature and signature_no_return: print("") print(" Parameters") print(" ----------") for [name, value] in signature.items(): name = to_snake_case(name) if name == "return": continue original_name = name doc_value = args.get(name) if name in args: del args[name] if not doc_value: self.errors.add(f"Parameter not documented: {fqname}({name}=)") else: code_type = self.serialize_python_type(value, "in") print(f"{indent}{to_snake_case(original_name)} : {code_type}") if doc_value.get("comment"): print( f"{indent} {self.indent_paragraph(self.render_links(doc_value['comment']), f'{indent} ')}" ) if doc_value.get("deprecated"): print( f"{indent} Deprecated: {self.render_links(doc_value['deprecated'])}" ) self.compare_types(code_type, doc_value, f"{fqname}({name}=)", "in") if ( signature and "return" in signature and str(signature["return"]) != "" ): value = signature["return"] doc_value = method self.compare_types(value, doc_value, f"{fqname}(return=)", "out") print("") print(" Returns") print(" -------") print(f" {self.serialize_python_type(value, 'out')}") print(f'{indent}"""') for name in args: if args[name].get("deprecated"): continue self.errors.add( f"Parameter not implemented: {class_name}.{method_name}({name}=)" ) def print_events(self, class_name: str) -> None: clazz = self.classes[class_name] events = clazz["events"] if events: doc = [] for event_type in ["on", "once"]: return_type = ( "typing.Union[typing.Awaitable[None], None]" if self.is_async else "None" ) for event in events: func_arg = self.serialize_doc_type(event["type"], "") if func_arg.startswith("{"): func_arg = "typing.Dict" if "Union[" in func_arg: func_arg = func_arg.replace("Union[", "typing.Union[") doc.append(" @typing.overload") doc.append( f" def {event_type}(self, event: Literal['{event['name'].lower()}'], f: typing.Callable[['{func_arg}'], '{return_type}']) -> None:" ) doc.append( f' """{self.beautify_method_comment(event["comment"], " " * 8)}"""' ) if len(events) == 1: doc.append(" @typing.overload") doc.append( f" def {event_type}(self, event: str, f: typing.Callable[...,{return_type}]) -> None: ..." ) doc.append("") doc.append( f" def {event_type}(self, event: str, f: typing.Callable[...,{return_type}]) -> None:" ) doc.append(f" return super().{event_type}(event=event,f=f)") print("\n".join(doc)) def print_event_overloads(self, class_name: str, method_name: str) -> None: """Emit ``@typing.overload`` stubs for ``expect_event`` / ``wait_for_event`` keyed on ``Literal`` event names with their payload types from api.json, so pyright/mypy can narrow the return type at call sites. Must be called right before the implementation signature is emitted. """ if class_name not in self.classes: return events = self.classes[class_name].get("events") or [] if not events: return is_expect = method_name == "expect_event" async_prefix = "async " if not is_expect and self.is_async else "" if is_expect: ctx_mgr = ( "AsyncEventContextManager" if self.is_async else "EventContextManager" ) for event in events: payload = self.serialize_doc_type(event["type"], "") if payload.startswith("{"): payload = "typing.Dict" if "Union[" in payload: payload = payload.replace("Union[", "typing.Union[") return_type = f'{ctx_mgr}["{payload}"]' if is_expect else f'"{payload}"' event_literal = event["name"].lower() print(" @typing.overload") print(f" {async_prefix}def {method_name}(") print(" self,") print(f' event: typing.Literal["{event_literal}"],') print( f' predicate: typing.Optional[typing.Callable[["{payload}"], bool]] = None,' ) print(" *,") print(" timeout: typing.Optional[float] = None,") print(f" ) -> {return_type}: ...") print("") # Catch-all overload for non-literal event names — keeps pyright happy # with `event: str` callers without falling through to `Unknown`. catchall_return = f"{ctx_mgr}[typing.Any]" if is_expect else "typing.Any" print(" @typing.overload") print(f" {async_prefix}def {method_name}(") print(" self,") print(" event: str,") print(" predicate: typing.Optional[typing.Callable[..., bool]] = None,") print(" *,") print(" timeout: typing.Optional[float] = None,") print(f" ) -> {catchall_return}: ...") print("") def indent_paragraph(self, p: str, indent: str) -> str: lines = p.split("\n") result = [lines[0]] for line in lines[1:]: result.append(indent + line) return "\n".join(result) def beautify_method_comment(self, comment: str, indent: str) -> str: comment = self.filter_out_redundant_python_code_snippets(comment) comment = comment.replace("\\", "\\\\") comment = comment.replace('"', '\\"') lines = comment.split("\n") result = [] skip_example = False last_was_blank = True for line in lines: if not line.strip(): last_was_blank = True continue match = re.match(r"\s*```(.+)", line) if match: lang = match[1] if lang in ["html", "yml", "sh", "py", "python"]: skip_example = False elif lang == "python " + ("async" if self.is_async else "sync"): skip_example = False line = "```py" else: skip_example = True if not skip_example: if last_was_blank: last_was_blank = False result.append("") result.append(self.render_links(line)) if skip_example and line.strip() == "```": skip_example = False comment = self.indent_paragraph("\n".join(result), indent) return self.resolve_playwright_dev_links(comment) def filter_out_redundant_python_code_snippets(self, comment: str) -> str: groups = [] current_group = [] lines = comment.split("\n") start_pos = None for i in range(len(lines)): line = lines[i].strip() if line.startswith("```py"): start_pos = i elif line == "```" and start_pos is not None: current_group.append((start_pos, i)) start_pos = None elif ( (line.startswith("```") or i == len(lines) - 1) and start_pos is None and len(current_group) == 2 ): groups.append(current_group) current_group = [] groups.reverse() for first_pos, second_pos in groups: # flake8: noqa: E203 second_snippet_is_async = "await" in lines[second_pos[0] : second_pos[1]] if second_snippet_is_async == self.is_async: # flake8: noqa: E203 del lines[first_pos[0] : first_pos[1] + 1] else: # flake8: noqa: E203 del lines[second_pos[0] : second_pos[1] + 1] return "\n".join(lines) def resolve_playwright_dev_links(self, comment: str) -> str: def replace_callback(m: re.Match) -> str: link_text = m.group(1) link_href = m.group(2) resolved = urljoin( "https://playwright.dev/python/docs/api/", link_href.replace(".md", "") ) return f"[{link_text}]({resolved})" # matches against internal markdown links which start with '.'/'..' # e.g. [Playwright](./class-foobar.md) return re.sub(r"\[([^\]]+)\]\((\.[^\)]+)\)", replace_callback, comment) def render_links(self, comment: str) -> str: for [old, new] in self.links.items(): comment = comment.replace(old, new) return comment def make_optional(self, text: str) -> str: if text.startswith("Union["): if text.endswith("None]"): return text return text[:-1] + ", None]" return f"Union[{text}, None]" def compare_types( self, value: Any, doc_value: Any, fqname: str, direction: str ) -> None: if "(arg=)" in fqname or "(pageFunction=)" in fqname: return code_type = self.serialize_python_type(value, direction) doc_type = self.serialize_doc_type(doc_value["type"], direction) if not doc_value["required"]: doc_type = self.make_optional(doc_type) if doc_type != code_type: if ( code_type in self.class_aliases and doc_type == self.class_aliases[code_type] ): return self.errors.add( f"Parameter type mismatch in {fqname}: documented as {doc_type}, code has {code_type}" ) def serialize_python_type(self, value: Any, direction: str) -> str: str_value = str(value) if isinstance(value, list): return f"[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), value)))}]" if str_value == "": return "Error" if str_value == "": return "None" if str_value == "": return "datetime.datetime" match = re.match(r"^$", str_value) if match: return match.group(1) if str_value == str(pathlib.Path): return "pathlib.Path" match = re.match( r"playwright._impl._event_context_manager.EventContextManagerImpl\[playwright._impl.[^.]+.(.*)\]", str_value, ) if match: return "EventContextManager[" + match.group(1) + "]" match = re.match(r"^$", str_value) if match and "_api_structures" not in str_value and "_errors" not in str_value: if match.group(1) == "EventContextManagerImpl": return "EventContextManager" class_name = match.group(1) if class_name in self.class_aliases: return self.class_aliases[class_name] return class_name match = re.match(r"^typing\.(\w+)$", str_value) if match: return match.group(1) origin = get_origin(value) args = get_args(value) hints = None try: hints = get_type_hints(value) except Exception: pass if hints: signature: List[str] = [] for [name, value] in hints.items(): signature.append( f"{name}: {self.serialize_python_type(value, direction)}" ) return f"{{{', '.join(signature)}}}" if origin == Union: args = get_args(value) if not self.is_async: # Sync API doesn't accept awaitable callbacks; drop the # Awaitable arm so docstring types match the sync signature. args = tuple( a for a in args if str(get_origin(a)) != "" ) if len(args) == 1: return self.serialize_python_type(args[0], direction) if len(args) == 2 and str(args[1]) == "": return self.make_optional( self.serialize_python_type(args[0], direction) ) ll = list(map(lambda a: self.serialize_python_type(a, direction), args)) ll.sort(key=lambda item: "}" if item == "None" else item) return f"Union[{', '.join(ll)}]" if str(origin) == "": args = get_args(value) return f"Dict[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), args)))}]" if str(origin) == "": args = get_args(value) return f"Sequence[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), args)))}]" if str(origin) == "": args = get_args(value) list_type = "Sequence" if direction == "in" else "List" return f"{list_type}[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), args)))}]" if str(origin) == "": args = get_args(value) return f"Callable[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), args)))}]" if str(origin) == "": return "Pattern[str]" if str(origin) == "typing.Literal": args = get_args(value) if len(args) == 1: return '"' + self.serialize_python_type(args[0], direction) + '"' body = ", ".join( list( map( lambda a: '"' + self.serialize_python_type(a, direction) + '"', args, ) ) ) return f"Union[{body}]" return str_value def serialize_doc_type(self, type: Any, direction: str) -> str: result = self.inner_serialize_doc_type(type, direction) return result def inner_serialize_doc_type(self, type: Any, direction: str) -> str: if type["name"] == "Promise": if "templates" not in type: return "Any" type = type["templates"][0] if "union" in type: ll = [self.serialize_doc_type(t, direction) for t in type["union"]] ll.sort(key=lambda item: "}" if item == "None" else item) for i in range(len(ll)): if ll[i].startswith("Union["): ll[i] = ll[i][6:-1] return f"Union[{', '.join(ll)}]" type_name = type["name"] if type_name == "path": if direction == "in": return "Union[pathlib.Path, str]" else: return "pathlib.Path" if type_name == "function" and "args" not in type: return "Callable" if type_name == "function": return_type = "Any" if type.get("returnType"): return_type = self.serialize_doc_type(type["returnType"], direction) return f"Callable[[{', '.join(self.serialize_doc_type(t, direction) for t in type['args'])}], {return_type}]" if "templates" in type: base = type_name if type_name == "Array": base = "Sequence" if direction == "in" else "List" if type_name == "Object" or type_name == "Map": base = "Dict" return f"{base}[{', '.join(self.serialize_doc_type(t, direction) for t in type['templates'])}]" if type_name == "Object" and "properties" in type: items = [] for p in type["properties"]: items.append( (p["name"]) + ": " + ( self.serialize_doc_type(p["type"], direction) if p["required"] else self.make_optional( self.serialize_doc_type(p["type"], direction) ) ) ) return f"{{{', '.join(items)}}}" if type_name == "boolean": return "bool" if type_name == "long": return "int" if type_name.lower() == "string": return "str" if type_name == "any" or type_name == "unknown" or type_name == "Serializable": return "Any" if type_name == "Object": return "Dict" if type_name == "Function": return "Callable" if type_name == "Buffer" or type_name == "ReadStream": return "bytes" if type_name == "Date": return "datetime.datetime" if type_name == "URL": return "str" if type_name == "RegExp": return "Pattern[str]" if type_name == "null" or type_name == "void": return "None" if type_name == "EvaluationArgument": return "Dict" if type_name in self.class_aliases: return self.class_aliases[type_name] return type["name"] def print_remainder(self) -> None: for [class_name, clazz] in self.classes.items(): for [member_name, member] in clazz["members"].items(): if member.get("deprecated"): continue if class_name in ["Error", "FormData"]: continue entry = f"{class_name}.{member_name}" if entry not in self.printed_entries: self.errors.add(f"Method not implemented: {entry}") with open("scripts/expected_api_mismatch.txt") as f: for line in f.readlines(): sline = line.strip() if not len(sline) or sline.startswith("#"): continue if sline in self.errors: self.errors.remove(sline) else: print("No longer there: " + sline, file=stderr) if len(self.errors) > 0: for error in self.errors: print(error, file=stderr) exit(1) def works_for_python(item: Any) -> bool: return not item["langs"].get("only") or "python" in item["langs"]["only"] def name_or_alias(item: Any) -> str: alias = ( item["langs"].get("aliases").get("python") if item["langs"].get("aliases") else None ) return alias or item["name"] def self_or_override(item: Any) -> Any: override = ( item["langs"].get("overrides").get("python") if item["langs"].get("overrides") else None ) return override or item def apply_type_or_override(member: Any) -> Any: if member["langs"].get("types") and member["langs"]["types"].get("python"): member["type"] = member["langs"]["types"]["python"]