Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/on-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,10 @@ jobs:
run: |
export DISPLAY=:99.0
Xvfb -ac :99 -screen 0 1280x1024x16 > /dev/null 2>&1 &
# Shard 1 is pinned to the latest Robot Framework, so the extra condition is what runs
# the unit tests against the supported floor as well. Keep one entry of each rf-version.
- name: Run pytests
if: matrix.shard == 1
if: matrix.shard == 1 || (matrix.rf-version == '7.1.1' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13')
run: |
invoke utest
- name: Run Node.js unit tests with coverage
Expand Down
46 changes: 46 additions & 0 deletions Browser/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Browser Library

The keyword library itself. This context is about **how a keyword call reaches a keyword
body** — there are two routes into the same keyword, and they behave differently on purpose.

## Language

**Robot Framework path**:
A keyword call arriving through `run_keyword`, with arguments already converted by Robot
Framework before the library sees them.
_Avoid_: RF path, dynamic API path

**Python path**:
A keyword call arriving by attribute access on the library instance, from a plain Python
script with no Robot Framework run.
_Avoid_: direct call, native call

**Keyword table**:
The mapping the **Robot Framework path** reads (`self.keywords`), keyed by Robot name.
_Avoid_: keyword dict, keyword registry

**Attribute table**:
The mapping the **Python path** reads (`self.attributes`), keyed by both the method name and
the Robot name, so one keyword may appear twice.
_Avoid_: attribute dict

**Argument conversion**:
Turning a plain value into the keyword's declared type using Robot Framework's own
converters — `"middle"` into `MouseButton.middle`, `"1.5s"` into a `timedelta`.
_Avoid_: coercion, casting, parsing

## Relationships

- Both tables hold the *same* keyword methods, stored twice by PythonLibCore. They are
independent: changing one does not change the other.
- **Argument conversion** happens before the **Robot Framework path** reaches the keyword
table, and inside the **attribute table** for the **Python path**.
- Trace groups and failure screenshots exist only on the **Robot Framework path**. This is a
known, accepted difference, not a defect.

## Example dialogue

> **Dev:** "I added conversion for Python callers — won't that convert twice under Robot
> Framework?"
> **Domain expert:** "No. Robot Framework reads the keyword table and never touches the
> attribute table, so the Robot Framework path never enters the wrapper at all."
18 changes: 17 additions & 1 deletion Browser/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import sys
import time
import types
from collections.abc import Iterator
from concurrent.futures._base import Future
from copy import copy
from datetime import timedelta
Expand Down Expand Up @@ -61,6 +62,7 @@
)
from .keywords.crawling import Crawling
from .playwright import Playwright
from .python_arguments import add_argument_conversion
from .utils import (
AutoClosingLevel,
PlaywrightLogTypes,
Expand Down Expand Up @@ -581,6 +583,7 @@ def __init__( # noqa: PLR0915

translation_file = self._get_translation(language)
DynamicCore.__init__(self, libraries, translation_file)
add_argument_conversion(self)

self.scope_stack["timeout"] = SettingsStack(
self.convert_timeout(timeout),
Expand Down Expand Up @@ -1315,13 +1318,26 @@ def _unlink(self, file: Path): # to ease unit testing
return False
return True

@staticmethod
def _iter_module_names() -> Iterator[str]:
for importer in pkgutil.iter_importers():
# A Windows console script such as ``robot.exe`` is a zip archive on sys.path.
# Which may fail, therefore own wrapper.
try:
modules = list(pkgutil.iter_importer_modules(importer))
except KeyError as error:
logger.debug(f"Could not list modules of {importer}: {error}")
continue
for name, _ in modules:
yield name

@staticmethod
def _get_translation(language: str | None) -> Path | None:
if not language:
return None
discovered_plugins = {
name: importlib.import_module(name)
for _, name, _ in pkgutil.iter_modules()
for name in Browser._iter_module_names()
if name.startswith("robotframework_browser_translation")
}
lang = language.lower()
Expand Down
133 changes: 133 additions & 0 deletions Browser/python_arguments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Copyright 2017- Robot Framework Foundation
#
# 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 inspect
from collections.abc import Callable, Mapping
from functools import wraps
from typing import NamedTuple

from robot.running.arguments.typeconverters import TypeConverter
from robotlibcore import DynamicCore # type: ignore

from .utils.data_types import RobotTypeConverter


def _detached(value):
"""Return a value the converter may rewrite without touching the caller's own object."""
if isinstance(value, Mapping):
return _rebuild(value, {key: _detached(item) for key, item in value.items()})
if isinstance(value, (list, tuple, set, frozenset)):
return _rebuild(value, [_detached(item) for item in value])
return value


def _rebuild(original, items):
"""Put copied items back into a container of the caller's own type."""
try:
return type(original)(items)
except TypeError:
return items if isinstance(original, Mapping) else original


class _ArgumentConverter(NamedTuple):
"""A parameter that needs converting, and everything needed to convert it."""

name: str
converter: TypeConverter
kind: inspect._ParameterKind

def convert(self, value):
"""Convert one bound argument, or its elements when it is *args / **kwargs."""
if value is None:
return None
# For *args and **kwargs the type hint is the item type, so convert each element
# rather than the tuple or dict holding them.
if self.kind is inspect.Parameter.VAR_POSITIONAL:
return tuple(self._convert_one(item) for item in value)
if self.kind is inspect.Parameter.VAR_KEYWORD:
return {key: self._convert_one(item) for key, item in value.items()}
return self._convert_one(value)

def _convert_one(self, value):
# A Python caller who means "nothing" passes the None object. Never convert it: on a
# `str` hint Robot Framework turns None into the string "None".
if value is None:
return None
return self.converter.convert(name=self.name, value=_detached(value))


def converting_proxy(bound_method: Callable, types: dict) -> Callable:
"""Return a proxy that converts plain Python values to the declared keyword types.

Keywords with nothing convertible are returned unchanged, so an attribute lookup on
them costs exactly what it costs today.
"""
if not types:
return bound_method
signature = inspect.signature(bound_method)
plan = [
_ArgumentConverter(name, converter, signature.parameters[name].kind)
for name, hint in types.items()
if name in signature.parameters
and (converter := RobotTypeConverter.converter_for(hint))
]
if not plan:
return bound_method

# @wraps is required, not cosmetic: `rfbrowser translate` reads __name__ and __doc__ off
# these entries and checksums the doc. Dropping it silently changes every translation
# checksum in the project.
@wraps(bound_method)
def wrapper(*args, **kwargs):
try:
bound = signature.bind(*args, **kwargs)
except TypeError:
return bound_method(*args, **kwargs)
for argument in plan:
if argument.name in bound.arguments:
bound.arguments[argument.name] = argument.convert(
bound.arguments[argument.name]
)
return bound_method(*bound.args, **bound.kwargs)

return wrapper


def add_argument_conversion(library: DynamicCore) -> None:
"""Give the Python path argument conversion, leaving the Robot Framework path alone.

Robot Framework reaches keywords through the keyword table (``library.keywords``);
Python reaches them through the attribute table (``library.attributes``). The two are
separate dicts holding the same bound methods, so rebuilding only the attribute table
leaves the Robot Framework execution path byte-for-byte unchanged. That is why
conversion appears on a keyword the module defining it knows nothing about.

Types are resolved through the keyword table and cached by the bound method itself:
``keywords_spec`` is keyed by Robot name only, while ``attributes`` is keyed by both the
method name and the Robot name. Looking types up by ``__name__`` raises for every
``@keyword(name=...)`` keyword, and wrapping per attribute entry would build two
wrappers for the keywords that carry an alias.

Must be called after ``DynamicCore.__init__``, which is what fills both tables.
"""
types_by_method = {
keyword: library.get_keyword_types(keyword_name)
for keyword_name, keyword in library.keywords.items()
}
wrapped: dict = {}
for name, keyword in list(library.attributes.items()):
if keyword not in wrapped:
wrapped[keyword] = converting_proxy(
keyword, types_by_method.get(keyword, {})
)
library.attributes[name] = wrapped[keyword]
2 changes: 1 addition & 1 deletion Browser/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
try:
from robot.api.types import Secret
except ImportError:
# Robot Framework 7.4.0 and earlier do not have Secret type.
# Robot Framework versions earlier than 7.4.0 do not have Secret type.
# Remove when Robot Framework 7.4.0+ is the minimum requirement.
class Secret: # type: ignore
"""Encapsulates secrets to avoid them being shown in Robot Framework logs.
Expand Down
16 changes: 16 additions & 0 deletions CONTEXT-MAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Context Map

## Contexts

- [Browser Library](./Browser/CONTEXT.md) — the keyword library itself: how a keyword call
reaches a keyword body, from Robot Framework and from plain Python
- [Test App Rich Logging](./atest/CONTEXT.md) — correlating Robot Framework test execution
with HTTP server activity in the acceptance test setup

## Relationships

- **Test App Rich Logging → Browser Library**: the acceptance suite drives the Browser Library
over the **Robot Framework path** while the Test App records what the browser did. It
observes the library from outside and shares no vocabulary with it.

System-wide decisions live in [`docs/adr/`](./docs/adr/).
File renamed without changes.
6 changes: 1 addition & 5 deletions atest/test/10_retest/Browser_New_Context_Test.robot
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,4 @@ Create New Persistent Context With RecordVideo.Dir As Path And Validate That Rec

Verify Video File Type
[Arguments] ${dir_type}
IF ${PYTHON_314}
Should Match Regexp ${dir_type} <class 'str'>
ELSE
Should Match Regexp ${dir_type} <class 'pathlib.+Path'> # Python 3.13 need mode wired regex
END
Should Match Regexp ${dir_type} <class 'str'>
57 changes: 57 additions & 0 deletions docs/adr/0006-python-argument-conversion-via-attribute-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Python argument conversion by rebuilding the attribute table

Calling a Browser keyword from plain Python gave no argument conversion at all, so a Python
user had to write `browser.click("//button", MouseButton.middle)` while a Robot Framework
user could write `middle`. PythonLibCore keeps every keyword in two independent tables — the
**keyword table** (`self.keywords`), which `run_keyword` reads with arguments already
converted by RF, and the **attribute table** (`self.attributes`), which `__getattr__` reads
for every Python-path call — and `Browser` defines no `@keyword` methods on its own class, so
every Python-path call goes through the attribute table. We therefore rebuild only that table,
immediately after `DynamicCore.__init__`, wrapping each bound method in a proxy that converts
arguments with Robot Framework's own converters (`Browser/python_arguments.py`). The Robot
Framework path is left untouched by construction rather than by care.

## Considered options

- **A decorator on each keyword method.** Works, but touches all 151 keyword definitions
across 17 modules, and sits on the Robot Framework path too — measured: 100 extra
conversions for 100 `run_keyword` calls, re-validating what RF had already converted.
- **Routing Python calls through `run_keyword`.** This would also give Python callers trace
groups and failure screenshots. Rejected: it puts the library's own execution machinery in
the path of a plain function call. The Python-vs-RF behavioural gap stays a known, accepted
property of the library, documented rather than closed.
- **Widening the runtime type hints** to `MouseButton | Literal["left", "middle", "right"]`.
Rejected: it makes RF's automatic conversion harder and pushes a "str or Enum?" branch into
every keyword body. Widening is allowed only in the generated `.pyi`, which RF never reads.
- **An opt-out flag** on the constructor. Rejected: it doubles the paths under test to buy an
escape hatch that already exists — conversion is idempotent, so passing an already-typed
value is the opt-out.

Full comparison, prototypes and measurements: `docs/research/python-api-options.md`.

## Consequences

- **A Python `None` is never converted.** From Python, a caller who means nothing passes the
`None` object. Measured across every convertible parameter, converting `None` the way RF
does would turn 126 of them into the string `'None'` and raise on 122 more. A string
`"None"` is still converted per its hint — `"None"` is not `None`.
- **Conversion covers every type RF can convert**, not just the Enum / `timedelta` /
`AssertionOperator` scope originally agreed. Reusing `get_keyword_types` was what made the
mechanism small; the wider reach is the promise "the same conversion RF gives you".
- **Conversion never rewrites the caller's own object.** RF's TypedDict converter assigns
converted items back into the mapping it is given and returns that same object. That is
invisible when RF converts test data it just built, but on the Python path the mapping
belongs to the caller, so mappings are copied before conversion — including mappings
reached through a list, tuple, set or dict, because those converters build a new container
around the caller's original dicts. A side effect of this is
that Python ≤ 3.13 no longer sees the library rewrite a passed `recordVideo` dict into a
resolved absolute `Path`, which makes the Python path behave the same on every Python
version.
- **`@wraps` is load-bearing.** `rfbrowser translate` checksums `__doc__` off the attribute
table; without `@wraps` every translation checksum in the project changes silently.
- **jsextension keywords are not covered.** They are code-generated without type annotations,
so there is nothing to convert against and they pass through unwrapped.
- **This depends on `self.attributes`, a PythonLibCore internal**, which is why
`robotframework-pythonlibcore` is pinned exactly. The clean long-term home is an opt-in flag
in `HybridCore` upstream — ship here first, prove it over a release, then propose it with
real usage behind it.
Loading