From 4d7c7634782613c85e78c4e26cd4cbb3a9a71a3d Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Wed, 12 Aug 2026 22:18:54 +0300 Subject: [PATCH] feat: easier arg conversion from Python usage Fixes: #5145 --- .github/workflows/on-push.yml | 4 +- Browser/CONTEXT.md | 46 + Browser/browser.py | 18 +- Browser/python_arguments.py | 133 +++ Browser/utils/types.py | 2 +- CONTEXT-MAP.md | 16 + CONTEXT.md => atest/CONTEXT.md | 0 .../10_retest/Browser_New_Context_Test.robot | 6 +- ...argument-conversion-via-attribute-table.md | 57 ++ docs/research/python-api-ergonomics.md | 855 ++++++++++++++++++ docs/research/python-api-options.md | 610 +++++++++++++ utest/KeywordArgumentSpy.py | 89 ++ utest/test_data_types.py | 6 +- utest/test_python_arguments.py | 348 +++++++ utest/test_python_usage.py | 18 +- utest/test_translation.py | 66 ++ 16 files changed, 2262 insertions(+), 12 deletions(-) create mode 100644 Browser/CONTEXT.md create mode 100644 Browser/python_arguments.py create mode 100644 CONTEXT-MAP.md rename CONTEXT.md => atest/CONTEXT.md (100%) create mode 100644 docs/adr/0006-python-argument-conversion-via-attribute-table.md create mode 100644 docs/research/python-api-ergonomics.md create mode 100644 docs/research/python-api-options.md create mode 100644 utest/KeywordArgumentSpy.py create mode 100644 utest/test_python_arguments.py diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml index cad99c325..f31733bda 100644 --- a/.github/workflows/on-push.yml +++ b/.github/workflows/on-push.yml @@ -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 diff --git a/Browser/CONTEXT.md b/Browser/CONTEXT.md new file mode 100644 index 000000000..b47295503 --- /dev/null +++ b/Browser/CONTEXT.md @@ -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." diff --git a/Browser/browser.py b/Browser/browser.py index f528e5131..fe718e338 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -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 @@ -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, @@ -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), @@ -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() diff --git a/Browser/python_arguments.py b/Browser/python_arguments.py new file mode 100644 index 000000000..8a59dac27 --- /dev/null +++ b/Browser/python_arguments.py @@ -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] diff --git a/Browser/utils/types.py b/Browser/utils/types.py index 65f7d925e..68c24a730 100644 --- a/Browser/utils/types.py +++ b/Browser/utils/types.py @@ -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. diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md new file mode 100644 index 000000000..71b15a93a --- /dev/null +++ b/CONTEXT-MAP.md @@ -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/). diff --git a/CONTEXT.md b/atest/CONTEXT.md similarity index 100% rename from CONTEXT.md rename to atest/CONTEXT.md diff --git a/atest/test/10_retest/Browser_New_Context_Test.robot b/atest/test/10_retest/Browser_New_Context_Test.robot index 29aec35b7..1b7927ed7 100755 --- a/atest/test/10_retest/Browser_New_Context_Test.robot +++ b/atest/test/10_retest/Browser_New_Context_Test.robot @@ -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} - ELSE - Should Match Regexp ${dir_type} # Python 3.13 need mode wired regex - END + Should Match Regexp ${dir_type} diff --git a/docs/adr/0006-python-argument-conversion-via-attribute-table.md b/docs/adr/0006-python-argument-conversion-via-attribute-table.md new file mode 100644 index 000000000..ff7b1c026 --- /dev/null +++ b/docs/adr/0006-python-argument-conversion-via-attribute-table.md @@ -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. diff --git a/docs/research/python-api-ergonomics.md b/docs/research/python-api-ergonomics.md new file mode 100644 index 000000000..89a74423a --- /dev/null +++ b/docs/research/python-api-ergonomics.md @@ -0,0 +1,855 @@ +# Pure-Python ergonomics for the Browser library + +Research notes: how to make `Browser` pleasant to call from plain Python +(`browser.click("//button", "middle")`) without degrading Robot Framework usage. + +> **Point-in-time record, 2026-08-11. Not maintained.** +> +> Primary-source research into Robot Framework's conversion API and PythonLibCore's +> internals, kept because re-deriving it is the expensive part. It is evidence, not current +> documentation, and nothing here is updated as those libraries move. Measured against the +> versions in the table below. +> +> The decision this fed into is +> [`docs/adr/0006-python-argument-conversion-via-attribute-table.md`](../adr/0006-python-argument-conversion-via-attribute-table.md); +> the options comparison is [`python-api-options.md`](python-api-options.md); the shipped +> implementation is `Browser/python_arguments.py`. + +**Status:** research only. No library code was changed *by this document* — the feature it +led to has since been implemented. + +## Where this file lives and why + +The repo has no existing `notes/` or `docs/research/` convention. `docs/` currently +holds `adr/`, `examples/`, `plugins/`, `releasenotes/`, `versions/`. The closest +existing convention is `docs/adr/` (architecture decision records, e.g. +`docs/adr/0004-rf-listener-as-library-not-standalone.md`), but this document is +investigation, not a decision, so it is **not** an ADR. Per the task instruction it is +filed at `docs/research/python-api-ergonomics.md`, creating that directory. +The decision that followed became +[`docs/adr/0006-python-argument-conversion-via-attribute-table.md`](../adr/0006-python-argument-conversion-via-attribute-table.md) +(0005 was taken in the meantime). + +## Legend + +- **[V]** VERIFIED — I read the source and/or executed it and observed the result. +- **[I]** INFERRED — reasoning on top of verified facts; not directly observed. + +## Versions this was verified against + +| Component | Version | Path | +| --- | --- | --- | +| Robot Framework | 7.4.2 | `.venv/lib/python3.14/site-packages/robot/` | +| PythonLibCore | 4.6.0 | `.venv/lib/python3.14/site-packages/robotlibcore/` | +| Browser (this repo) | branch `python_usage` | `Browser/` | + +Cross-checked against `robotframework/robotframework` git tags `v6.1.1`, `v7.0`, +`v7.1`, `v7.2`, `v7.3`, `v7.4` and `master` via the GitHub contents API. **[V]** + +Repo-local source line numbers refer to the working tree at the time of writing. + +--- + +## 1. Executive summary + +1. **RF's argument conversion is fully reachable outside a Robot Framework run.** The + entire `robot.running.arguments` package has exactly one reference to the execution + context, and it is an optional fallback for *language* configuration only + (`typeinfo.py:422-423`). With no context, conversion works and degrades to the + default English language config. **[V]** — see §4. +2. **`robot.api.TypeInfo` exists and is explicitly public API**, documented as such in + RF's own source and its readthedocs API reference, since **RF 7.0**. **[V]** — §2. +3. **PythonLibCore performs no conversion whatsoever.** `DynamicCore.run_keyword` is a + one-line direct call of the bound method. All conversion happens on the *Robot + Framework* side, before `run_keyword` is invoked, driven by `get_keyword_types`. + A Python caller therefore bypasses 100% of it. **[V]** — §3. +4. **The pattern is already in this repo, twice.** `Browser/keywords/promises.py:128` + and `Browser/entry/__main__.py:463` both do + `get_keyword_types(kw)` → `RobotTypeConverter.converter_for(type)` → `.convert(value)`. + `RobotTypeConverter` (`Browser/utils/data_types.py:25`) is a thin wrapper over + `robot.api.TypeInfo.from_type_hint`. A python-friendly API is a generalisation of + code that already ships. **[V]** — §6.4. +5. **Scale of the problem: 123 Enum-typed parameters across 84 of 151 keywords (55.6%)**, + plus 35 `timedelta` parameters across 30 keywords, plus 4 Enum / 2 `timedelta` / + 1 TypedDict arguments on `Browser.__init__` itself. **[V]** — §6. +6. **Blocker on the current dependency floor:** `pyproject.toml:12` allows + `robotframework >= 6.1.1`, but `TypeInfo` does not exist before RF 7.0. **[V]** — §2.3. +7. **Nothing coerces strings today.** No Browser enum is a `str` mixin, none defines + `_missing_`, and the library registers no `ROBOT_LIBRARY_CONVERTERS`. So + `MouseButton("right")` raises `ValueError` (values are `auto()` ints). **[V]** — §6.3. +8. **No RF library anywhere offers a documented string-accepting Python API.** Browser is + already the furthest along (documented Python example in `README.md:143-159`, + generated `.pyi` stubs, and the converter in §6.4). SeleniumLibrary is Python-callable + only because it never adopted Enums. **[V]** — §7. +9. **Two things conversion alone will not fix:** the `run_keyword` bypass costs + run-on-failure / tracing / pause-on-failure (three closed Browser issues about exactly + this), and AssertionEngine's `validate` / `then` operators call `BuiltIn().evaluate` + and raise `RobotNotRunningError` outside a run. **[V]** — §7. + +--- + +## 2. Robot Framework's public conversion API + +### 2.1 `robot.api.TypeInfo` — it exists + +`robot/api/__init__.py` re-exports it. **[V]** + +```python +from robot.running import ( + TestSuite as TestSuite, + TestSuiteBuilder as TestSuiteBuilder, + TypeInfo as TypeInfo, +) +``` + +- Local: `.venv/lib/python3.14/site-packages/robot/api/__init__.py:104-108` +- Master: — identical lines, fetched and confirmed. **[V]** + +The module docstring lists it as public API (`robot/api/__init__.py:67-68`): **[V]** + +> `TypeInfo` class for parsing type hints and converting values based on them. +> New in Robot Framework 7.0. + +The class docstring states its status directly (`robot/running/arguments/typeinfo.py:107-108`): **[V]** + +> Part of the public API starting from Robot Framework 7.0. In such usage +> should be imported via the `robot.api` package. + +The readthedocs API reference documents `TypeInfo`, `ArgumentSpec`, `TypeConverter`, +`CustomArgumentConverters`, `ArgumentConverter` and `ArgumentResolver`, and repeats the +public-API sentence for `TypeInfo` only: + **[V]** + +**Public-ness ranking [I], based on the above:** + +| Symbol | Import path | Status | +| --- | --- | --- | +| `TypeInfo` | `from robot.api import TypeInfo` | **Public, stability-guaranteed** since 7.0 **[V]** | +| `TypeInfo` | `from robot.running.arguments import TypeInfo` | Works, but docstring says prefer `robot.api` **[V]** | +| `ArgumentSpec`, `TypeConverter`, `CustomArgumentConverters`, `PythonArgumentParser`, `DynamicArgumentParser` | `from robot.running.arguments import ...` | Exported in `__init__.py` and autodoc'd, but **not** in `robot.api` and **not** declared public — treat as semi-internal **[I]** | +| `ArgumentConverter`, `ArgumentResolver` | `from robot.running.arguments.argumentconverter import ArgumentConverter` | Not re-exported from the package `__init__.py` at all — internal **[V]** | + +`robot/running/arguments/__init__.py` exports exactly: `DefaultValue`, +`DynamicArgumentParser`, `PythonArgumentParser`, `UserKeywordArgumentParser`, `ArgInfo`, +`ArgumentSpec`, `CustomArgumentConverters`, `EmbeddedArguments`, `TypeConverter`, +`TypeInfo`. Note `ArgumentConverter` and `ArgumentResolver` are **absent**. **[V]** + +### 2.2 Signatures + +`TypeInfo.convert` — `robot/running/arguments/typeinfo.py:367-394` **[V]** + +```python +def convert( + self, + value: Any, + name: "str|None" = None, + custom_converters: "CustomArgumentConverters|dict|None" = None, + languages: "LanguagesLike" = None, + kind: str = "Argument", + allow_unknown: bool = False, +) -> object: +``` + +Raises `ValueError` if conversion fails, `TypeError` if there is no converter for the +type and `allow_unknown` is false. + +Constructors — all classmethods on `TypeInfo`: **[V]** + +| Method | Line | Accepts | +| --- | --- | --- | +| `from_type_hint(hint, sequence_is_union=False)` | `typeinfo.py:202` | anything: a type, `list[int]`, `int \| float`, the string `'int'`, a TypedDict | +| `from_type(hint: type)` | `typeinfo.py:268` | an actual concrete type only | +| `from_string(hint: str)` | `typeinfo.py:277` | `'int'`, `'list[int]'`, `'int \| float'` | +| `from_sequence(sequence)` | `typeinfo.py:296` | `[int, float]` → a union | +| `from_variable(variable, ...)` | `typeinfo.py:320` | `${x: int}` syntax; new in RF 7.3 | +| `get_converter(...)` | `typeinfo.py:396` | returns the `TypeConverter` for reuse; new in RF 7.2 | + +So the idiom named in the question is correct and supported: **[V]** + +```python +from robot.api import TypeInfo +TypeInfo.from_type(MouseButton).convert("middle") # -> MouseButton.middle +``` + +### 2.3 Version availability — verified per git tag + +Checked by fetching `src/robot/api/__init__.py` and +`src/robot/running/arguments/typeinfo.py` at each tag. **[V]** + +| RF version | `typeinfo.py` | `TypeInfo` in `robot.api` | `.convert` | `.get_converter` | `allow_unknown` | +| --- | --- | --- | --- | --- | --- | +| 6.1.1 | **absent** | no | — | — | — | +| 7.0 | yes | **yes** | yes (`typeinfo.py:259`) | no | no | +| 7.1 | yes | yes | yes | no | no | +| 7.2 | yes | yes | yes | **yes** (`:288`, doc "New in Robot Framework 7.2") | no | +| 7.3 | yes | yes | yes | yes | **yes** | +| 7.4 | yes | yes | yes | yes | yes | + +The RF 7.0 `convert` signature is the 7.4 one minus `allow_unknown`. **[V]** + +**Behaviour change in 7.4** — the only `TypeInfo` mention in any RF 7.x release note +(`doc/releasenotes/rf-7.4.rst:298-301`, and identically in `rf-7.4rc1/rc2`): **[V]** + +> `robot.api.TypeInfo.from_type_hint` does not anymore consider a sequence of types +> [a union]. […] use the `robot.api.TypeInfo.from_sequence` method instead. + +I grepped **all** `doc/releasenotes/rf-7*.rst` files: `TypeInfo` is mentioned only in +`rf-7.4.rst`, `rf-7.4rc1.rst`, `rf-7.4rc2.rst`. The 7.0 "public API" claim is sourced +from the class docstring and `robot/api/__init__.py`, not from the 7.0 release notes. **[V]** + +**Consequence for this repo:** `pyproject.toml:12` declares +`"robotframework >= 6.1.1, < 9.0.0"`. Any `TypeInfo`-based feature must either raise the +floor to `>= 7.0` or guard the import — which is exactly what +`Browser/utils/data_types.py:30-36` already does with a `try/except ImportError`. **[V]** + +### 2.4 What RF converts, observed empirically with **no execution context** + +Script: `scratchpad/verify_rf.py`. `EXECUTION_CONTEXTS.current` printed as `None` +throughout. **[V]** + +| Type hint | Input | Result | +| --- | --- | --- | +| `MouseButton` (Enum) | `"middle"` | `MouseButton.middle` | +| `MouseButton` | `"MIDDLE"` | `MouseButton.middle` (case/`_-` insensitive) | +| `MouseButton` | `MouseButton.left` | passthrough, unchanged | +| `SelectAttribute` (`auto()` values) | `"label"` / `"LABEL"` | `SelectAttribute.label` | +| `SelectAttribute` | `"bogus"` | `ValueError: … does not have member 'bogus'. Available: 'index', 'label', 'text' and 'value'` | +| `timedelta` | `"3s"`, `"1 min 5 s"`, `5`, `1.5` | `0:00:03`, `0:01:05`, `0:00:05`, `0:00:01.5` | +| `Path` | `"foo/bar"` | `PosixPath('foo/bar')` | +| `Optional[MouseButton]` | `None` / `"right"` | `None` / `MouseButton.right` | +| `Union[int, MouseButton]` | `"left"` | `MouseButton.left` | +| `Literal["a","b"]` | `"a"` / `"c"` | `'a'` / `ValueError: … cannot be converted to 'a' or 'b'` | +| `List[MouseButton]` | `"['left','right']"` (string!) | `[MouseButton.left, MouseButton.right]` | +| `list[MouseButton]` | `["left","right"]` | `[MouseButton.left, MouseButton.right]` | +| TypedDict `Coord` | `"{'x': '1', 'y': '2'}"` or `{"x":"1","y":"2"}` | `{'x': 1, 'y': 2}` — **nested values converted too** | +| `bool` | `"true"` | `True` | +| `int` | `"1_000"` | `1000` (separators stripped) | +| unregistered class `Custom` | `"x"` | `TypeError: Unrecognized type 'Custom'`; with `allow_unknown=True` → `"x"` passthrough | +| `Custom` + `custom_converters={Custom: to_custom}` | `"abc"` | `Custom` instance, `.v == "ABC"` | + +Error messages carry the argument name when `name=` is passed: **[V]** + +``` +Argument 'button' got value 'nope' that cannot be converted to MouseButton: +MouseButton does not have member 'nope'. Available: 'left', 'middle' and 'right' +``` + +**Enum matching rules** (`typeconverters.py:204-250`) **[V]**: exact member-name lookup +`enum[value]` first; then normalized comparison ignoring case, `_` and `-` +(`eq(m, value, ignore="_-")`); ambiguity across multiple members is an error; for +`int`-subclass enums, lookup by integer value is also tried. Matching is by **member +name**, not by value — important for `AssertionOperator`, whose member names are +`"=="`, `"equal"`, `"should be"` etc. + +**Custom converters** (`customconverters.py`) **[V]**: `ROBOT_LIBRARY_CONVERTERS` is a +`{type: callable}` mapping; the callable may take `(value)` or `(value, library)`; +the value type it accepts is read from the converter's own annotation +(`ConverterInfo.for_converter`, `customconverters.py:68-100`). `TypeInfo.convert` accepts +the same mapping directly via `custom_converters=` — it calls +`CustomArgumentConverters.from_dict` internally (`typeinfo.py:420-421`). **[V]** + +--- + +## 3. PythonLibCore + +### 3.1 `run_keyword` bypasses everything — confirmed + +`robotlibcore/core/dynamic.py:24-25`, in full: **[V]** + +```python +class DynamicCore(HybridCore): + def run_keyword(self, name, args, kwargs=None): + return self.keywords[name](*args, **(kwargs or {})) +``` + +That is the entire implementation. There is **no conversion, no validation, no +coercion** anywhere in PythonLibCore. `self.keywords[name]` is the bound method captured +in `HybridCore.add_library_components` (`hybrid.py:48-50`). **[V]** + +Conversion in a real RF run happens *before* RF ever calls `run_keyword`: RF asks the +library for `get_keyword_types(name)`, builds an `ArgumentSpec`, and runs +`ArgumentResolver` + `ArgumentConverter` on the test-data strings. So: + +- **RF call path:** test data → RF resolves/converts using `get_keyword_types` → `run_keyword` → bound method with real objects. **[V]** +- **Python call path:** `browser.click(...)` → `HybridCore.__getattr__` (`hybrid.py:108-116`) → the bound method directly. **RF is not in the loop at all.** **[V]** + +Note the Python path does not even go through `run_keyword`; attribute access resolves +straight to the component's bound method via `self.attributes`. That means Browser's own +`run_keyword` override (`Browser/browser.py:1335-1359`, which adds trace groups, failure +screenshots and pause-on-failure) is *also* bypassed from Python. The library's own +`__init__` docstring already documents this consequence, for `run_on_failure` +(`Browser/browser.py:888`): **[V]** + +> Run on failure is not applied when library methods are executed directly from Python. + +### 3.2 How the dynamic API is built + +| Method | File:line | Source of truth | +| --- | --- | --- | +| `get_keyword_names` | `hybrid.py:122-123` | `sorted(self.keywords)` | +| `run_keyword` | `dynamic.py:24-25` | direct bound-method call | +| `get_keyword_arguments` | `dynamic.py:27-32` | `KeywordSpecification.argument_specification` | +| `get_keyword_types` | `dynamic.py:46-50` | `KeywordSpecification.argument_types` | +| `get_keyword_tags` | `dynamic.py:34-35` | `func.robot_tags` | +| `get_keyword_documentation` | `dynamic.py:37-44` | `inspect.getdoc` | + +`get_keyword_types` returns **raw type hints**, not `TypeInfo` objects +(`KeywordBuilder._get_types`, `builder.py:113-132`): it returns `func.robot_types` if the +`@keyword(types=...)` option was used, otherwise `typing.get_type_hints(func)` with +non-argument entries stripped. **[V]** This is precisely what +`Browser/keywords/promises.py:129` and `Browser/entry/__main__.py:466` consume. + +Keyword discovery is `callable(func) and hasattr(func, "robot_name")` +(`hybrid.py:47`). **Note:** bare `@keyword` sets `robot_name = None`, so detection must +use `hasattr`, never truthiness. **[V]** + +### 3.3 Conversion hooks in PythonLibCore: none + +Grepping the whole 650-line package for `convert`, `TypeInfo`, `TypeConverter` finds +nothing. There is no pre-call hook, no argument filter, no `types=` post-processing. +The only extension points are `add_library_components`, the plugin system +(`robotlibcore/plugin/parser.py`) and the translation mechanism +(`robotlibcore/utils/translations.py`). **[V]** + +`robotlibcore.__init__` re-exports RF's decorator verbatim: `from robot.api.deco import +keyword` (`robotlibcore/__init__.py:16`). **[V]** + +### 3.4 `@keyword` decorator options + +`robot/api/deco.py:68-125` — signature `keyword(name=None, tags=(), types=())`, setting +`func.robot_name`, `func.robot_tags`, `func.robot_types`. **[V]** From its docstring: **[V]** + +> Types [may be given] either as a dictionary mapping argument names to types or as a +> list of types mapped to arguments based on position. It is OK to specify types only to +> some arguments, and **setting `types` to `None` disables type conversion altogether**. + +This is the one existing lever that decouples the *declared RF type* from the *Python +annotation*: `types=` overrides annotations for RF only. **[I]** A design that annotates +parameters permissively for Python (e.g. `str | MouseButton`) while declaring the strict +RF type via `@keyword(types={...})` is mechanically possible, but it would degrade IDE +completion and Libdoc-from-annotations, and would have to be applied 151 times. + +### 3.5 Precedent inside PythonLibCore for a python-friendly wrapper + +None found. **[V]** + +--- + +## 4. Is RF conversion usable outside a run? Yes. + +This is the load-bearing question, so it was checked two ways. + +**By source.** Grepping the entire `robot/running/arguments/` package for +`EXECUTION_CONTEXTS`, `robot.running.context`, `LOGGER`, `BuiltIn()`: **[V]** + +``` +embedded.py:24 from ..context import EXECUTION_CONTEXTS +embedded.py:114 context = EXECUTION_CONTEXTS.current +typeinfo.py:55 from ..context import EXECUTION_CONTEXTS +typeinfo.py:422 if not languages and EXECUTION_CONTEXTS.current: +typeinfo.py:423 languages = EXECUTION_CONTEXTS.current.languages +``` + +`embedded.py` is embedded-argument parsing and is not on the conversion path. +The only conversion-path reference is `typeinfo.py:422-423`: **[V]** + +```python +if not languages and EXECUTION_CONTEXTS.current: + languages = EXECUTION_CONTEXTS.current.languages +elif not isinstance(languages, Languages): + languages = Languages(languages) +``` + +With no context this falls to `Languages(None)` — the default English configuration. +**`typeconverters.py` (938 lines, every converter) contains zero context references.** **[V]** + +Language config only affects `BooleanConverter` (`typeconverters.py:313-315`, matching +localized true/false words) and `NoneConverter`. Enum, timedelta, Path, Union, Literal, +TypedDict and custom-converter conversion are entirely language-independent. **[I]** + +**By execution.** Every table row in §2.4 and §5 was produced in a bare Python process +with `EXECUTION_CONTEXTS.current is None`. **[V]** + +**Counter-example for contrast:** `robot.libraries.BuiltIn` is the RF library that *is* +context-dependent — most of its keywords call `self._get_context()` and raise +`RobotNotRunningError` outside a run. Conversion machinery is deliberately not like +this. **[I]** + +--- + +## 5. Full round-trip on a real Browser keyword + +Because `Browser/generated/` (protobuf gencode) is not built in a source checkout, +`import Browser` fails at `Browser/base/librarycomponent.py:29`. Stubbing +`Browser.generated.playwright_pb2` + `grpc` in `sys.modules` makes real introspection +work (`scratchpad/stubgen.py`). **[V]** + +Using only `robot.running.arguments` APIs, outside any RF run, on +`Browser/keywords/interaction.py:358` `click_with_options`: **[V]** + +```python +from robot.running.arguments import PythonArgumentParser +spec = PythonArgumentParser().parse(Interaction.click_with_options, name="Click With Options") +spec.convert([None, "//b", "middle", "Shift"], + [("delay", "200ms"), ("trial", "true"), ("position_x", "10")]) +``` + +Observed output: **[V]** + +``` +converted positional: [None, '//b', , ] +converted named: [('delay', datetime.timedelta(microseconds=200000)), + ('trial', True), ('position_x', 10.0)] +``` + +Notes: **[V]** + +- `*modifiers` varargs are converted element-wise. +- Already-correct values pass through untouched: passing `MouseButton.middle` and + `timedelta(seconds=2)` returns them unchanged. +- A bad value produces the RF error message verbatim: + `Argument 'button' got value 'nope' that cannot be converted to MouseButton: …` +- `spec` is derived from the *live annotations*, so it stays correct automatically as + signatures change — no parallel type table to maintain. **[I]** + +**Two API choices at this level [V]:** + +- `ArgumentSpec.convert(positional, named, converters=None, dry_run=False, languages=None)` + (`argumentspec.py:142-153`) — conversion only. **This is the right one for a Python wrapper.** +- `ArgumentSpec.resolve(args, named_args=None, variables=None, ...)` + (`argumentspec.py:116-140`) — adds RF named-arg splitting on `=`, `${var}` replacement + and arity validation, then calls `convert`. It passes `dry_run=not variables`, and + `ArgumentConverter._convert` (`argumentconverter.py:67-72`) **skips conversion for any + value containing an RF variable** when `dry_run` is true. Verified: with + `resolve(..., variables=None)`, the value `"${sel}"` is left unconverted while + `"middle"` still converts. Harmless for `str` parameters, but a silent no-op if a + typed argument's value happened to look like `${...}`. Prefer `convert`. + +`ArgumentConverter` also carries RF's subtle default-value fallback rules +(`argumentconverter.py:76-121`): `None` is preserved when the default is `None`; a `str` +default suppresses conversion; an `int` default also permits `float`. Reusing +`ArgumentSpec.convert` inherits all of this for free; a hand-rolled converter would not. **[I]** + +--- + +## 6. Grounding in this repo + +Counts produced by runtime introspection with `typing.get_type_hints` over every class in +`Browser/keywords/*.py`, detecting keywords via `hasattr(fn, "robot_name")` and flattening +nested generics (`Optional[...]`, `X | None`, `list[...]`, `dict[...]`). +Independently reproduced by two separate scripts with identical results. **[V]** +Scripts: `scratchpad/recount.py`, `scratchpad/analyze_kw_types.py`. + +### 6.1 Headline numbers **[V]** + +| Metric | Value | +| --- | --- | +| Total keywords | **151** | +| Keywords with ≥1 Enum parameter | **84 (55.6%)** | +| Enum-typed parameters | **123** | +| Distinct Enum classes used in signatures | **43** | +| Keywords with a `timedelta` parameter | **30** | +| `timedelta` parameters | **35** | +| Keywords with an `AssertionOperator` parameter | **31** | +| Keywords with a `SelectAttribute` parameter | **2** | +| Total parameters (excl. `self`) | 585 (577 annotated, 8 unannotated) | + +Keywords per module (top 5): `interaction.py` 32, `getters.py` 28, +`playwright_state.py` 23, `browser_control.py` 16, `webapp_state.py` 8. **[V]** + +Only 10 keywords pass an explicit name to `@keyword`; the other 141 use bare `@keyword` +(`robot_name is None`). **[V]** + +### 6.2 Non-Enum RF-friendly types in signatures **[V]** + +| Type | Keywords | Parameters | +| --- | --- | --- | +| Union / `X \| None` | 93 | ~280 (248 include `None`, i.e. Optional) | +| TypedDict | 12 | 23 | +| `list` / `List` / `Sequence` | 9 | 16 | +| `dict` / `Dict` / `Mapping` | 8 | 13 | +| `Path` | 4 | 6 | +| `timedelta` | 30 | 35 | +| `Literal` | **0** | **0** | + +`Literal` is never used in a keyword signature — only internally at +`Browser/browser.py:1422` and `Browser/utils/logger.py:23`. **[V]** + +TypedDict parameters: `Proxy` ×4; `DownloadInfo`, `NewPageDetails`, `GeoLocation`, +`HttpCredentials`, `RecordHar`, `RecordVideo`, `ViewportDimensions` ×2 each; +`BoundingBox`, `HighLightElement`, `FileUploadBuffer`, `PdfMarging`, `ClientCertificate` +×1 each. **[V]** + +Enum classes by parameter count (top 15): `AssertionOperator` 31, `SelectionType` 16, +`Scope` 8, `SupportedBrowsers` 7, `PageLoadStates` 5, then `Permission`, `SizeFields`, +`MouseButton`, `KeyboardModifier`, `DialogAction`, `ColorScheme`, `ForcedColors` at 3 +each, and `CookieType`, `ElementState`, `SelectAttribute` at 2. 42 of the 43 Enum classes +come from `Browser/utils/data_types.py`; `AssertionOperator` comes from +`assertionengine`. **[V]** + +Return annotations: 1 keyword returns an Enum (`get_element_states` → `ElementState`, +`Browser/keywords/getters.py:1456`); 14 return a `data_types` class. **[V]** + +### 6.3 `Browser/utils/data_types.py` **[V]** + +- 71 top-level `class` statements; 79 classes at runtime, because 8 enums are built with + the **functional** `Enum(...)` API: `FormatterKeywords`, `FormatingRules`, + `CookieSameSite`, `ColorScheme`, `Permission`, `ScrollBehavior`, `InstallableBrowser`, + `InstallationOptions`. +- **47 Enum subclasses**: 46 plain `Enum`, 1 `IntFlag` (`ElementState`, line 1118). + **0 `str`-mixin enums, 0 `IntEnum`, 0 `Flag`.** Roughly 28 use `auto()`. +- **27 TypedDicts**, **0 dataclasses**, 5 plain classes (`RobotTypeConverter` :25, + `Deprecated` :151, `RegExp` :265 (a `str` subclass), `DelayedKeyword` :388, + `LambdaFunction` :701). +- Largest enum: `ElementRole` (line 293, 82 members). + +**`_missing_` is defined nowhere** — `grep -rn "def _missing_" Browser/ atest/ utest/` +returns zero hits, as does the installed `assertionengine`. (A naive `grep _missing_` +returns 3 hits in `Browser/mypy.ini`, but those are `ignore_missing_imports` — false +positives.) **[V]** Combined with 0 `str`-mixin enums and `auto()` +integer values, this means plain-Python `browser.click(button="right")` cannot work +today: `MouseButton("right")` raises `ValueError`. Name-based, case-insensitive lookup +exists **only** inside RF's `EnumConverter`. **[V]** + +`AssertionOperator` (`assertionengine/assertion_engine.py:27-56`, assertionengine 5.0.1) +is a functional `Enum` whose *member names* are the human aliases (`"equal"`, `"equals"`, +`"=="`, `"should be"`, …) and whose *values* are the operator symbols (`"=="`). It has +26 members but only 13 canonical ones — the rest are Python enum aliases. It defines no +`_missing_`. **[V]** The practical consequence, all observed: **[V]** + +| Call | Result | +| --- | --- | +| `AssertionOperator("==")` (by value) | works → `AssertionOperator.equal` | +| `AssertionOperator("equal")` (by value) | `ValueError: 'equal' is not a valid AssertionOperator` | +| `AssertionOperator["should be"]` (by name) | works → `AssertionOperator.equal` | +| `TypeInfo.from_type(AssertionOperator).convert(x)` for `x` in `"=="`, `"equal"`, `"should be"`, `"SHOULD BE"`, `"should_be"`, `"contains"` | **all work** | + +This is the sharpest single illustration of the gap: the plain-Python constructor accepts +one arbitrary subset of the spellings, while RF's converter accepts all of them +case- and separator-insensitively. It also explains why +`utest/test_python_usage.py:131` has to write `AssertionOperator["=="]` with +`__getitem__` rather than the more natural call syntax. **[V]** + +### 6.4 Existing string→typed conversion in this repo — the precedent + +Two shipping call sites already do exactly what a python-friendly API needs. **[V]** + +`Browser/keywords/promises.py:128-137`: + +```python +def convert_keyword_arg(self, kw: str, arg_name: str, arg_value: Any) -> Any: + argument_type = self.library.get_keyword_types(kw).get(arg_name) + if argument_type is not None: + converter = TypeConverter.converter_for(argument_type) + return ( + converter.convert(name=arg_name, value=arg_value) + if converter + else arg_value + ) + return arg_value +``` + +`Browser/entry/__main__.py:463-482` `convert_options_types` does the same for the +`launch_browser_server` CLI, via `browser_lib.get_keyword_types("launch_browser_server")`. + +Both route through `RobotTypeConverter` (`Browser/utils/data_types.py:25-37`): + +```python +class RobotTypeConverter(TypeConverter): + @classmethod + def converter_for(cls, arg_type): + if arg_type is None: + return None + try: + from robot.api import TypeInfo + if not isinstance(arg_type, TypeInfo): + type_hint = TypeInfo.from_type_hint(arg_type) + except ImportError: + type_hint = arg_type + return TypeConverter.converter_for(type_hint) +``` + +So **the library already depends on `robot.api.TypeInfo`**, already guards it for +RF < 7.0 with `try/except ImportError`, and already imports +`robot.running.arguments.typeconverters.TypeConverter` directly +(`Browser/utils/data_types.py:20`). Verified working outside an RF run: **[V]** + +``` +RobotTypeConverter.converter_for(MouseButton).convert('middle') -> MouseButton.middle +RobotTypeConverter.converter_for(timedelta).convert('3s') -> 0:00:03 +RobotTypeConverter.converter_for(Optional[MouseButton]).convert('right') -> MouseButton.right +``` + +> **Latent bug found while verifying (not fixed — research only).** +> `Browser/utils/data_types.py:33-37`: when `arg_type` *is* already a `TypeInfo`, the +> `if not isinstance(...)` branch is skipped and `type_hint` is never assigned, so the +> `return` raises `UnboundLocalError: cannot access local variable 'type_hint'`. +> Reproduced: `RobotTypeConverter.converter_for(TypeInfo.from_type(MouseButton))`. +> It is unreachable today because `get_keyword_types` returns raw hints, never +> `TypeInfo` — but it would fire immediately if anything started passing `TypeInfo` +> objects around. **[V]** + +Also note `Browser/utils/data_types.py:44-148` contains a hand-rolled TypedDict/Union +coercion layer (`convert_typed_dict` and helpers) that predates / duplicates what +`TypedDictConverter` in RF does (`typeconverters.py:607-674`), including nested +conversion. **[V]** (Whether it can be retired is out of scope here. **[I]**) + +Other conversion-ish helpers: `Browser/keywords/getters.py:1519` local `convert_str`; +`Browser/utils/misc.py` `type_converter` (display-only, tested in +`utest/test_type_converter.py`); `Browser/browser.py:1637` and +`Browser/base/librarycomponent.py:230` `convert_timeout`. **[V]** + +### 6.5 `Browser/browser.py` **[V]** + +| What | Line | +| --- | --- | +| `from robotlibcore import DynamicCore, PluginParser` | 36 | +| `class Browser(DynamicCore)` | 156 | +| `ROBOT_LIBRARY_VERSION` / `ROBOT_LIBRARY_LISTENER` / `ROBOT_LIBRARY_SCOPE = "GLOBAL"` | 842-845 | +| `__init__` (keyword-only; `*_` rejects positionals at :896) | 851-871 | +| component list handed to `DynamicCore` (20 components) | 905-925 | +| `DynamicCore.__init__(self, libraries, translation_file)` | 972 | +| `run_keyword` **overridden** (trace groups, failure screenshot, pause-on-failure) | 1335-1359 | +| `get_keyword_tags` overridden (adds `Plugin` tag) | 1380-1384 | +| `get_keyword_documentation` overridden | 1647-1648 | +| `get_keyword_types` — **not** overridden, inherited from PLC | — | + +`ROBOT_LIBRARY_CONVERTERS` appears **exactly once in the whole repo's Python sources**, +and it is test-only: `atest/library/os_wrapper.py:162` (`{datetime: _parse_fi_date}`). +(Other matches are only in `atest/output/**/syslog.txt` execution logs.) +The Browser library itself registers **no** custom converters. **[V]** + +`Browser.__init__` itself has the same ergonomics problem: `auto_closing_level: +AutoClosingLevel`, `enable_playwright_debug: PlaywrightLogTypes | bool`, +`enable_presenter_mode: HighLightElement | bool` (TypedDict), +`external_browser_executable: dict[SupportedBrowsers, str] | None`, +`tracing_group_mode: TracingGroupMode`, `retry_assertions_for: timedelta = 1s`, +`timeout: timedelta = 10s` — i.e. 4 Enum + 2 `timedelta` + 1 TypedDict arguments. **[V]** + +### 6.6 How the library is used from Python today **[V]** + +`utest/test_python_usage.py` is the canonical example: `Browser.Browser()` at lines 52, +60, 70, 85, 193, 203, 261. It **mixes styles**, which is itself evidence of the problem: + +- line 131 `browser.get_text("h1", AssertionOperator["=="], "Login Page")` — Enum object, + via `__getitem__` because `AssertionOperator("==")` would not work +- line 183 `browser.new_browser(browser=SupportedBrowsers.chromium, headless=True, timeout="0")` + — Enum object, but a *string* timeout +- lines 54/62/72/87/184 `browser.close_browser("ALL")` — plain string, works only because + that keyword handles it +- lines 222/231/240/249 `browser.promise_to("Wait For Response", "matcher=", "timeout=1s")` + — all strings, and they work **because** `promises.py` routes them through the RF converter + +Other direct instantiations: `utest/test_shared_playwright_port.py`, +`test_screenshot.py:22`, `test_browser_folder_cleanup.py:13`, `test_docs.py:5`, +`test_output_dir.py:5`, `test_run_on_failure.py:8`, `test_secrets.py:38`, +`test_get_time.py:10,15`, `test_translation.py:12,57`, `test_waiters.py`. + +The untracked scratch file `test.py` in the repo root does **not** use Browser at all; +it sketches three throwaway functions (`foo_1`, `foo_2`, `get_text`) with fully +`str`-annotated, python-friendly signatures — apparently an exploration of "what would a +string-typed `Get Text` look like". **[V]** (It is untracked and unreferenced. **[I]**) + +--- + +## 7. Prior art + +**Headline: no Robot Framework library offers a first-class, documented +"call me from Python with strings" API.** That is a genuine negative result across +SeleniumLibrary, RequestsLibrary, `BuiltIn` and PythonLibCore. Browser is already the +furthest along of any of them. + +| Project | Prior art? | Why | +| --- | --- | --- | +| SeleniumLibrary | **No** | Python-callable only incidentally — it never adopted Enums | +| RequestsLibrary | **No** | Static library, no Enums; problem never arose | +| `robot.libraries.BuiltIn` | **Counter-example** | Deliberately context-*dependent*; raises outside a run | +| PythonLibCore | **No** | No conversion hook exists (§3.3); no issue/PR proposes one | +| AssertionEngine | **No** | Enum with no `_missing_`; `verify_assertion` rejects strings | +| **Browser (this repo)** | **Partial — the most of any** | Documented Python example, generated `.pyi` stubs, and a shipped context-free converter (§6.4) | +| `manykarim/rf-mcp` | **Yes — the one real external instance** | Uses `TypeInfo.convert` outside a run | + +**SeleniumLibrary** () — `SeleniumLibrary(DynamicCore)` +at `src/SeleniumLibrary/__init__.py:62`. It *does* override `run_keyword` +(`src/SeleniumLibrary/__init__.py:676-681`) but **only** to call `failure_occurred()`; +no argument conversion. Its only conversion is for *library import* arguments +(`_convert_timeout` / `_convert_delay`, `src/SeleniumLibrary/__init__.py:633-636`) — +notably the same `__init__`-arguments gap Browser has (§6.5). +Its keyword signatures use plain `str` for choice arguments +(`switch_window(locator=..., browser: str = "CURRENT")`, `src/SeleniumLibrary/keywords/window.py:31-35`); +`keywords/element.py` imports no `Enum` at all. Its README documents no Python API. +**This is the key comparison: SeleniumLibrary "just works" from Python because it never +demanded Enums — which argues the Enum design, not the dynamic API, is the thing needing +a Python-side answer.** + +**RequestsLibrary** () — does not +use PythonLibCore at all; a plain static library using `@keyword` from `robot.api.deco` +(`class RequestsLibrary(RequestsOnSessionKeywords)`, `src/RequestsLibrary/__init__.py:17`). +No `run_keyword` override, no conversion, no Enums. It is Python-callable only +incidentally, and it holds an RF-context dependency at construction +(`self.builtin = BuiltIn()`, `src/RequestsLibrary/RequestsKeywords.py:19`). + +**`robot.libraries.BuiltIn`** — the instructive counter-example. `_get_context` +(`src/robot/libraries/BuiltIn.py:155-159`) raises +`RobotNotRunningError("Cannot access execution context")` when +`EXECUTION_CONTEXTS.current is None`; `RobotNotRunningError(AttributeError)` at line 5540. +Constructing `BuiltIn()` is free, but anything touching variables, the namespace or the RF +log raises. Its `robot_running` property (line 116, new in RF 6.1) is the **sanctioned +idiom for "am I inside a run?"** — directly useful to any Python-friendly design. +So "BuiltIn is usable from Python" is only half true, and RF's *conversion* machinery +(§4) is deliberately unlike it. + +**PythonLibCore issue tracker** — all issues and PRs enumerated; **nothing** proposes +python-friendly calling or argument conversion. The closest is +issue #2 (closed, 2017), a complaint that a `@keyword`-renamed method could not be called +programmatically by its Python name — fixed by `core/hybrid.py:54` +(`self.attributes[name] = self.attributes[kw_name] = kw`), which is exactly the line that +makes `browser.click(...)` resolve at all. Note the double keying: `attributes` carries both +names while `keywords` and `keywords_spec` carry only the robot name, so anything looking up +a keyword spec by `kw.__name__` fails for every `@keyword(name=...)` keyword. See options doc +§3, "The naming trap". + +**Browser's own issue tracker** — no open request for a Python-friendly API or +string-instead-of-enum, but the demand shows up as recurring *bug reports* about the +`run_keyword` bypass: + +- (closed) — run-on-failure + screenshots missing when driving Browser from Python. Maintainer: "Because this is by + design, we are not going to change the functionality. That being said, you are not the + first one that stumbled into this feature and raises an issue about it." Referenced in + `docs/releasenotes/Browser-20.0.0.md:117`. +- (closed) — `Promise To` fails from Python. +- (closed) — `Take Screenshot` fails from Python. + +**Browser already documents Python usage** — `README.md:143-159`, verified verbatim: **[V]** + +```python +import Browser +browser = Browser.Browser() +browser.new_page("https://playwright.dev") +assert 'Playwright' in browser.get_text("h1") +browser.close_browser() +``` + +> But please note that not all features all available from Python. Example automatic +> closing, run on failure and some others features depends with the library interacting +> with Robot Framework. […] Python code must mimic the the required Robot Framework +> interfaces that the library requires. + +Note the example carefully uses `get_text("h1")` with **no** assertion operator — it +sidesteps the Enum problem rather than solving it. **[I]** +The project also already generates `.pyi` stubs for Python users +(`Browser/gen_stub.py`, `tasks.py:298-302`, producing `Browser/browser.pyi`), so +investment in Python-side typing already exists. **[V]** + +**`manykarim/rf-mcp`** () — the one genuine external +precedent for the technique. `src/robotmcp/utils/rf_native_type_converter.py` caches +`TypeInfo` per keyword argument and calls +`type_info.convert(value, name=name, kind="argument")` after `signature.bind_partial`, +to execute arbitrary RF keywords from Python with string arguments, outside a run. It is +LLM tooling rather than a library-authoring pattern, but it validates the approach. **[I]** + +**Related RF issue, filed by the Browser team**: + (closed) — "Argument +conversion with enums should work with normalized names", opened with "We are using a lot +of enums in Browser Library…". That change is what makes `"should not be"` → +`AssertionOperator.inequal` work — **but only through RF's conversion layer, which Python +callers skip.** The ergonomics gap is the direct consequence of a fix this project itself +requested. **[I]** + +### AssertionEngine — no string coercion, and a context landmine + +`verify_assertion` (`assertionengine/assertion_engine.py:188-219`) is typed +`operator: AssertionOperator | None` and dispatches via `handlers.get(operator)` +(line 206), raising if the lookup misses. Verified by execution: **[V]** + +``` +verify_assertion('abc', '==', 'abc') -> RuntimeError: `==` is not a valid assertion operator +verify_assertion('abc', AssertionOperator['=='], 'abc') -> 'abc' +verify_assertion('abc', AssertionOperator['validate'], …) -> RobotNotRunningError: Cannot access execution context +``` + +So (a) it does **not** coerce strings, and (b) the `validate` and `then`/`evaluate` +operators call `BuiltIn().evaluate(...)` (`assertion_engine.py:152, 205, 272, 390`) and +therefore **cannot work outside an RF run at all**, no matter how good the argument +conversion is. **[V]** Any Python-friendly design must treat those two operators as a +separate problem. + +Note also that AssertionEngine's `type_converter.py` is **not** a coercion helper despite +the name — 29 lines containing only `type_converter()` (returns a type name for error +messages) and `is_truthy()`. **[V]** + +--- + +## 8. Implications + +Purely derived from the above; no recommendation is being made here. **[I]** + +1. A wrapper that converts arguments using RF's own machinery is **feasible, cheap and + context-free**. `ArgumentSpec.convert` (or `TypeInfo.convert` per argument) reproduces + RF semantics exactly, including error messages, and is derived from live annotations + so it cannot drift from the signatures. +2. It is **strictly additive**: RF's own path never calls it, `get_keyword_types` is + untouched, annotations stay as they are, so Libdoc, IDE completion and RF conversion + are all unaffected. +3. The **dependency floor is the real constraint**: `robotframework >= 6.1.1` in + `pyproject.toml:12` vs. `TypeInfo` requiring 7.0. The existing + `try/except ImportError` in `RobotTypeConverter` shows the established mitigation. + `allow_unknown=` needs 7.3, `get_converter()` needs 7.2 — avoid both if the floor stays low. +4. Alternatives considered and their costs: + - **`_missing_` on every enum** — 47 classes to touch, only fixes Enums (not the 35 + `timedelta` params, TypedDicts or Unions), and diverges from RF's normalization + rules (`ignore="_-"`, ambiguity detection). Cheap per-class, incomplete overall. + - **`str`-mixin enums** — same partial coverage, and changes wire/serialization values. + - **`@keyword(types=...)` with loosened annotations** — 151 sites, and it degrades + exactly the IDE/Libdoc benefits the current design exists for. + - **Wrapping via RF conversion** — one implementation, covers Enums, `timedelta`, + `Path`, Unions, TypedDicts and nested generics uniformly. +5. Three behaviours differ between the RF path and any direct-Python path **regardless of + conversion**, and each needs its own decision: + - `Browser.run_keyword`'s failure-screenshot / trace-group / pause-on-failure layer is + skipped entirely on attribute access (§3.1) — the subject of Browser issues 4741, + 1685 and 4224, and already acknowledged in `README.md` and the `run_on_failure` + docstring. + - AssertionEngine's `validate` and `then`/`evaluate` operators call + `BuiltIn().evaluate` and raise `RobotNotRunningError` outside a run (§7). Conversion + cannot help these; they need a context shim or explicit non-support. + - `Browser.__init__`'s own 4 Enum / 2 `timedelta` / 1 TypedDict arguments are not + covered by keyword-level conversion at all (§6.5). + `BuiltIn().robot_running` (`BuiltIn.py:116`) is the sanctioned probe for branching on + "am I inside a run?" if a design needs it. +7. There is one external precedent for the exact technique — `manykarim/rf-mcp` converts + arguments with `TypeInfo.convert` outside a run (§7) — so this would not be + unprecedented territory, merely unprecedented *as a library-authoring pattern*. +6. Prefer `ArgumentSpec.convert` over `ArgumentSpec.resolve` — `resolve` implies + `dry_run=True` without `variables`, which silently skips conversion for + `${...}`-looking values (§5). + +--- + +## 9. Reproduction + +Scripts used (all in the session scratchpad, nothing written into the repo): + +| Script | Purpose | +| --- | --- | +| `stubgen.py` | stubs `Browser.generated.*` + `grpc` so `import Browser` works in a source checkout | +| `verify_rf.py` | §2.4 conversion matrix with no execution context | +| `verify_pipeline.py` | `PythonArgumentParser` over real Browser keywords | +| `verify_roundtrip.py` | §5 full convert round-trip on `click_with_options` | +| `verify_dryrun.py` | `resolve` vs `convert` `dry_run` behaviour | +| `recount.py`, `analyze_kw_types.py` | §6 counts (two independent implementations, identical results) | + +## 10. Primary sources + +- RF source (local 7.4.2): `robot/api/__init__.py`, `robot/api/deco.py`, + `robot/running/arguments/{__init__,typeinfo,typeconverters,customconverters,argumentconverter,argumentresolver,argumentspec,argumentparser}.py` +- RF master: +- RF tags v6.1.1 / v7.0 / v7.1 / v7.2 / v7.3 / v7.4 via the GitHub contents API +- RF release notes: `doc/releasenotes/rf-7.4.rst` (lines 298-301) +- RF API reference: +- PythonLibCore source (local 4.6.0): `robotlibcore/{__init__,core/hybrid,core/dynamic,keywords/builder,keywords/specification}.py` +- PythonLibCore repo: +- This repo: `Browser/browser.py`, `Browser/keywords/*.py`, `Browser/utils/data_types.py`, + `Browser/keywords/promises.py`, `Browser/entry/__main__.py`, `utest/test_python_usage.py`, + `pyproject.toml` diff --git a/docs/research/python-api-options.md b/docs/research/python-api-options.md new file mode 100644 index 000000000..24c2c77d1 --- /dev/null +++ b/docs/research/python-api-options.md @@ -0,0 +1,610 @@ +# Python-friendly keyword arguments — implementation options + +> **Point-in-time record, 2026-08-11. Not maintained.** +> +> This is the investigation that led to a decision, kept for its evidence rather than as +> current documentation. The decision itself is +> [`docs/adr/0006-python-argument-conversion-via-attribute-table.md`](../adr/0006-python-argument-conversion-via-attribute-table.md), +> which is the file to read first and the one that is kept true. +> +> Measured against Browser 20.3.0, PythonLibCore 4.5.0, Python 3.14, RF 7.4.1. PythonLibCore +> has since moved to 4.6.0. These claims were re-verified on 2026-08-11 against 4.6.0 while +> Option B was implemented: the two-table split, the attribute/keyword entry counts, the +> conversion error text on RF 7.1.1 and 7.4.2, and that `rfbrowser translate` checksums are +> unchanged by wrapping. Everything else is as it was measured and has not been rechecked. +> +> Two things this document got wrong, corrected during implementation: +> **(1)** the `converting_proxy` in §3 binds arguments without guarding `signature.bind`, so a +> wrong-arity call reports bind's message instead of Python's — the shipped version falls back +> to calling through. **(2)** jsextension keywords are *not* covered: they are code-generated +> without type annotations, so there is nothing to convert against and they stay unwrapped. + +Goal: make `browser.click("//button", "middle")` work from plain Python, without changing +anything about how the library behaves under Robot Framework. + +Scope agreed with the maintainer: **Enum**, **timedelta**, **AssertionOperator**. +Priority order: **runtime behaviour first, IDE experience second, type checkers third.** +Cost on the RF execution path is not a constraint ("correctness first"). + +Further decisions, 2026-08-09: + +- **Widening the runtime type hints is 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` (§9). +- **The Python-vs-RF behavioural difference stays.** Python callers not getting trace groups + and failure screenshots is a known, accepted property; explaining it better in the docs is + a separate task (§3.1). +- **RF floor raised.** RF 6.x is dropped; 7.2 was rejected as too recent for enterprise + users. Evidence points to `>= 7.1.1`, the lowest version CI actually tests (§5). + +Companion document: [`python-api-ergonomics.md`](python-api-ergonomics.md) — the primary-source +research this builds on. This file is the options comparison and recommendation. + +Everything marked **[V]** was verified by running code against this repo at +`Browser` 20.3.0, PythonLibCore 4.5.0, Python 3.14. The default RF is 7.4.1; claims about +version behaviour were checked against RF 6.1.1, 7.0.1, 7.1.1, 7.2.2, 7.3 and 7.4.1 in +isolated environments. Reproduction scripts are listed in §11. + +--- + +## 1. The two facts that decide the design + +**Fact 1 — RF and Python reach a keyword through two independent dicts.** **[V]** + +`HybridCore.add_library_components` stores each bound keyword method in *two* places +(`robotlibcore/core/hybrid.py:50,54`): + +```python +self.keywords[kw_name] = kw # line 50 — used by run_keyword +self.attributes[name] = self.attributes[kw_name] = kw # line 54 — used by __getattr__ +``` + +- **Robot Framework** calls `DynamicCore.run_keyword`, which is one line — + `return self.keywords[name](*args, **(kwargs or {}))` (`core/dynamic.py:24-25`). + RF has already converted the arguments by then, using `get_keyword_types`. +- **Python** calls `browser.click(...)`, which misses on the class and falls through to + `HybridCore.__getattr__` → `self.attributes[name]` (`core/hybrid.py:108-116`). + No conversion happens anywhere. + +`Browser` defines no `@keyword` methods on the class itself — all 151 come from the +component instances passed at `Browser/browser.py:972` — so **every** Python keyword call +goes through `__getattr__`. **[V]** + +That means the two paths can be given different behaviour by touching only one dict. + +**Fact 2 — the conversion machinery already ships in this repo.** **[V]** + +`RobotTypeConverter` (`Browser/utils/data_types.py:25-37`) wraps `robot.api.TypeInfo` and +already guards RF < 7.0 with `try/except ImportError`. Two shipping call sites already do +exactly the `get_keyword_types` → `converter_for` → `convert` dance: +`Browser/keywords/promises.py:128-137` and `Browser/entry/__main__.py:463-482`. + +So this feature is a *generalisation of code that already runs in production*, not new +machinery. The RF version floor is a separate question, treated in §5. + +--- + +## 2. Option A — decorator on each keyword method + +A `@converting_proxy` decorator applied to the keyword function, under `@keyword`. + +```python +@keyword +@converting_proxy +def click(self, selector: str, button: MouseButton = MouseButton.left): + ... +``` + +The wrapper binds the incoming arguments to the signature and converts each one whose +declared type has a converter. + +**Verified working** for Enum, `timedelta` from `"1.5s"`, `AssertionOperator` from `"=="`, +`*varargs: KeyboardModifier`, `bool` from `"true"`, and RF's error message on a bad value. +`get_keyword_arguments` and `get_keyword_types` are unaffected because `KeywordBuilder` +calls `inspect.unwrap` (`robotlibcore/keywords/builder.py:45,124`). **[V]** + +| | | +| --- | --- | +| ✅ | Explicit and greppable — you can see at each keyword that it opts in. | +| ✅ | Per-keyword opt-out is trivial: omit the decorator. | +| ❌ | **Touches all 151 keyword definitions** across 17 modules. | +| ❌ | **Sits on the RF path too.** Measured: `convert_args` ran **100 times for 100 `run_keyword` calls** — RF converts, then the decorator re-validates every argument. **[V]** | +| ❌ | Every new keyword must remember the decorator; nothing enforces it. | +| ❌ | Two decorators per keyword, order-sensitive. | + +## 3. Option B — wrap the `attributes` dict (recommended) + +Keyword bodies and signatures are untouched. After `DynamicCore.__init__`, rebuild +`self.attributes` with conversion-wrapping proxies and **leave `self.keywords` alone**: + +```python +class Browser(DynamicCore): + def __init__(self, ...): + ... + DynamicCore.__init__(self, libraries, translation_file) + # RF reaches keywords via self.keywords; Python reaches them via self.attributes. + # Wrapping only the latter gives Python callers argument conversion and leaves + # the Robot Framework execution path byte-for-byte unchanged. + # + # Resolve types through self.keywords and cache by the bound method: keywords_spec + # is keyed by ROBOT name only, while attributes is keyed by BOTH the method name + # and the robot name (§1, `core/hybrid.py:54`). See the naming trap below. + types_by_method = { + kw: self.get_keyword_types(kw_name) + for kw_name, kw in self.keywords.items() + } + wrapped: dict = {} + for name, kw in list(self.attributes.items()): + if kw not in wrapped: + wrapped[kw] = converting_proxy(kw, types_by_method.get(kw, {})) + self.attributes[name] = wrapped[kw] +``` + +with + +```python +def converting_proxy(bound_method, types): + """Return a proxy that converts plain Python values to the declared keyword types.""" + if not types: + return bound_method + sig = inspect.signature(bound_method) + plan = {} + for name, hint in types.items(): + if name in sig.parameters and (conv := RobotTypeConverter.converter_for(hint)): + plan[name] = (conv, sig.parameters[name].kind) + if not plan: + return bound_method + + # @wraps is REQUIRED, not cosmetic: `rfbrowser translate` + # (`Browser/entry/translation.py:40`) reads __name__ and __doc__ off these entries and + # checksums the doc. Dropping it changes every translation checksum silently. **[V]** + @wraps(bound_method) + def wrapper(*args, **kwargs): + bound = sig.bind(*args, **kwargs) + for name, (conv, kind) in plan.items(): + if name not in bound.arguments: + continue + value = bound.arguments[name] + # A Python caller who means "nothing" passes the None object. Never convert it: + # on a `str` hint RF turns None into the string "None". See the None rule below. + if value is None: + continue + if kind is inspect.Parameter.VAR_POSITIONAL: + bound.arguments[name] = tuple( + v if v is None else conv.convert(name=name, value=v) for v in value + ) + elif kind is inspect.Parameter.VAR_KEYWORD: + bound.arguments[name] = { + k: (v if v is None else conv.convert(name=name, value=v)) + for k, v in value.items() + } + else: + bound.arguments[name] = conv.convert(name=name, value=value) + return bound_method(*bound.args, **bound.kwargs) + + return wrapper +``` + +### The naming trap — why types are resolved through `self.keywords` + +An earlier draft of this section looked types up as `self.get_keyword_types(kw.__name__)`. +That **cannot construct `Browser()`**: `keywords_spec` is keyed by the *robot* name only +(`core/hybrid.py:50`) and `get_keyword_types` raises `ValueError` on a miss +(`core/dynamic.py:46-50`), so all 10 `@keyword(name=...)` keywords blow up — +`Evaluate JavaScript`, `Get BoundingBox`, and the `LocalStorage`/`SessionStorage` families, +i.e. 20 of the 161 attribute entries. Every keyword misses when a translation file is in +use. Resolving through `self.keywords`, which is keyed the same way `keywords_spec` is, +fixes both. **[V]** + +Caching by bound method matters for the same reason: `attributes` holds 161 entries for 151 +distinct methods, so a per-entry comprehension builds two wrappers for each aliased keyword. + +### The None rule + +A Python `None` is never converted. Measured across every convertible parameter, +`convert(None)` gives **[V]**: + +| result | parameters | +| --- | --- | +| `None` stays `None` | 328 | +| `None` becomes the string `'None'` | 126 | +| raises `ValueError` | 122 | + +The 126 are hints containing `str` but not `None` (`close_browser_server(wsEndpoint: str)`); +the 122 are Enums and bare `timedelta`. Skipping `None` is never worse than today, because +the Python path converts nothing at all today. A *string* `"None"` is still converted per its +hint — on `str | None` it stays `"None"`, on `int | None` RF makes it `None` — which is RF's +documented behaviour and is left alone. + +`str | None` needs no special handling: RF's union converter already returns `None` for a +real `None` and `"None"` for the string. **[V]** + +Applied to the **real** Browser library, unmodified: **[V]** + +``` +attributes entries : 161 +distinct keyword methods : 151 +wrapped (>=1 convertible): 139 +convertible parameters : 576 +failures : 0 +wrap cost : 3.7 ms at construction + +real click() type hints: {'selector': , 'button': } +w("//button", "middle") -> received: {'selector': '//button', 'button': } +bad value -> ValueError: Argument 'button' got value 'nope' that cannot be converted to + MouseButton: MouseButton does not have member 'nope'. + Available: 'left', 'middle' and 'right' +``` + +| | | +| --- | --- | +| ✅ | **One place.** ~25 lines in `browser.py` plus a helper. Zero keyword files edited. | +| ✅ | **RF path provably untouched:** `convert_args` ran **0 times for 100 `run_keyword` calls**. **[V]** | +| ✅ | Applies automatically to new keywords, including the ones generated at runtime (`Browser/browser.py:1116`) as long as wrapping happens after they are registered. | +| ✅ | Reuses `get_keyword_types`, so `@keyword(types=...)` overrides are respected for free. | +| ✅ | Same object — `browser.click(...)` keeps working; no new import or namespace for users to learn. | +| ⚠️ | Wrapping is implicit; a reader of `interaction.py` sees no sign of it. Mitigate with a comment at the wrap site and a section in the docs. | +| ⚠️ | `browser.attributes[...]` is PythonLibCore's internal structure. It is stable across PLC 4.x, but the project takes a dependency on it. See §6 for the upstream fix. | + +### 3.1 B2 — also route through `run_keyword` (considered, out of scope) + +> **Decision (maintainer, 2026-08-09): not doing this.** The behavioural difference between +> the Python and Robot Framework paths is a known, accepted property of the library. It +> should be explained better in the docs, but changing it is a separate concern from this +> feature. Recorded here because the option was evaluated and works; it is not part of the +> plan. **Option B ships without it.** + +`Browser.run_keyword` (`Browser/browser.py:1335-1360`) is not a pass-through. It opens and +closes **Playwright trace groups**, takes a **failure screenshot** via `keyword_error()`, +rewrites error messages through `_alter_keyword_error`, and implements `pause_on_failure`. + +Python callers reach the bound method through `__getattr__` and **never enter +`run_keyword`**, so they get none of that — today, and under plain Option A, B or C. **[V]** +This is a pre-existing gap, but it is worth closing in the same change: a Python user whose +`click` fails currently gets no failure screenshot and no trace group. + +The fix is one line in the proxy — convert, then delegate to `run_keyword` instead of to the +bound method: + +```python + return lib.run_keyword(kw_name, list(bound.args), bound.kwargs) +``` + +**This cannot recurse**, and the two-dict split is exactly why: the proxy lives in +`self.attributes`, while `run_keyword` resolves through `self.keywords`, which is never +wrapped. Verified: **[V]** + +``` +python lib.click("//b", "middle") -> ('click', '//b', MouseButton.middle) + trace: [open_trace_group, close_trace_group] +python failing call raised: element not found + trace: [open_trace_group, failure_screenshot, close_trace_group] +RF run_keyword("click", ["//b", MouseButton.right]) -> ok + trace: [open_trace_group, close_trace_group] +``` + +Note `bound.args` includes `self` only if the signature is unbound — the proxy wraps the +*bound* method, so it does not. Trace-group code is additionally guarded by +`self.keyword_call_stack`, which is empty outside an RF run, so it degrades quietly rather +than misbehaving. + +B2 costs one line over B and would give Python callers parity with RF. Not pursued — see +the decision note above. If the accepted difference is ever revisited, this is the +mechanism, and it is already verified. + +### Overhead + +Measured on a keyword call with no I/O, 100k iterations: **[V]** + +| | µs/call | +| --- | --- | +| undecorated method | 0.04 | +| wrapper, already-typed value passed | 1.60 | +| wrapper, string passed (real conversion) | 20.96 | + +Both numbers are irrelevant next to a gRPC round-trip to the Playwright node process +(milliseconds). Caching `inspect.signature` and the converter at wrap time — as the code +above does — is what keeps the already-typed case at 1.6 µs; resolving them per call costs +**47 µs**. **[V]** + +## 4. Option C — the separate facade + +A second namespace, e.g. `browser.py.click("//button", "middle")` or a `PyBrowser` class, +generated from the keyword list. + +| | | +| --- | --- | +| ✅ | Zero ambiguity: the RF surface and the Python surface are different objects. | +| ✅ | Free rein to diverge — rename to snake_case, drop RF-only arguments, return plain types. | +| ❌ | **Two public APIs to document, version, and deprecate.** For 151 keywords that is a real ongoing cost. | +| ❌ | Users must learn which one to use; every Stack Overflow answer becomes ambiguous. | +| ❌ | Delivers nothing Option B doesn't, for the agreed scope. The facade only pays off if you also want a *different* API shape, which is not the stated goal. | + +Worth keeping in reserve if the goal later expands to "a genuinely Pythonic API" +(snake_case, no `AssertionOperator`, native return types). It is not the cheaper path to +`browser.click("//button", "middle")`. + +## 5. Robot Framework version floor + +`pyproject.toml:12` currently allows `robotframework >= 6.1.1`. The maintainer has confirmed +RF 6.x can be dropped, and ruled out 7.2 as too recent. That leaves 7.0.1 or 7.1.1 — this +section works out which, and what the choice actually buys. + +Verified across every relevant release: **[V]** + +| | 6.1.1 | 7.0 | 7.1 | 7.2 | 7.3+ | +| --- | --- | --- | --- | --- | --- | +| `robot.api.TypeInfo` | ❌ | ✅ | ✅ | ✅ | ✅ | +| `TypeInfo.from_type_hint` / `.convert` | ❌ | ✅ | ✅ | ✅ | ✅ | +| **`TypeInfo.get_converter`** | ❌ | ❌ | ❌ | ✅ | ✅ | +| `typeconverters.TypeConverter.converter_for` (internal) | ✅ | ✅ | ✅ | ✅ | ✅ | + +### Why `get_converter` is the one that matters + +Option B builds a **conversion plan once per keyword at wrap time**: for each parameter, +decide whether a converter exists and, if not, leave that parameter alone. That check is +what needs a public API. + +- `TypeConverter.converter_for(hint)` returns `None` for unconvertible types — clean, but + it lives in `robot.running.arguments.typeconverters`, i.e. **RF internals**. +- `TypeInfo.get_converter()` is the public equivalent — but it arrives in **7.2**, not 7.0. + +On 7.0/7.1 there is no public way to ask "is this convertible?" ahead of time. `convert()` +raises rather than reporting, and the exception *type* is the only stable signal — the +message changed across releases: **[V]** + +``` +RF 7.0 TypeError: No converter found for 'Weird'. +RF 7.2 TypeError: Cannot convert type 'W'. +RF 7.3+ TypeError: Unrecognized type 'W'. +``` + +(`ValueError` means "value is wrong"; `TypeError` means "type has no converter" — but that +contract is not documented as public API.) + +### What the library actually requires today **[V]** + +`pyproject.toml:12` declares `>= 6.1.1`. Measured rather than assumed: + +- **RF 6.1.1 is not known to be broken.** `import Browser` + `Browser()` succeeds + (151 keywords), and the non-browser unit suite passes: 72 passed / 2 skipped on both + 6.1.1 and 7.0.1, 74 passed on 7.4.1. (One `test_secrets` failure is environmental and + identical on all three.) **Weigh this lightly** — the maintainer notes unit coverage is + thin and the acceptance suite carries the real coverage, and the acceptance suite has + never run on anything below 7.1.1. Every RF 7 API the code touches is deliberately + guarded: + `TypeInfo` (`data_types.py:30-37`), `LOGLEVEL` (`logger.py:20-23`), `Secret` + (`types.py:15-20`), each with a working fallback. +- **But CI tests only `7.1.1` and `7.4.2`** — 6 matrix entries each, across all workflows + (`.github/workflows/on-push.yml`). Nothing below 7.1.1 is exercised anywhere, including + the acceptance tests. + +So the declared floor is not demonstrably false about *functionality*, but it is false about +*verification*: 6.1.1 and 7.0.x are supported only in the sense that nobody has broken them +yet by accident — and the tests that would notice are the ones that never run there. + +Release dates, from PyPI: **[V]** + +| 6.1.1 | 7.0 | 7.0.1 | 7.1.1 | 7.2.2 | 7.3 | 7.4 | 7.4.2 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 2023-07-28 | 2024-01-11 | 2024-06-10 | **2024-10-19** | 2025-02-07 | 2025-05-30 | 2025-12-12 | 2026-03-03 | + +### Decision: floor should be `robotframework >= 7.1.1` + +**7.1.1 is the lowest version CI actually verifies**, and at 2024-10-19 it is *older* than +the 7.2.2 that was rejected as too recent for enterprise users. Declaring 7.1.1 costs +nothing that 7.0.1 doesn't already cost, and makes the declared floor an honest statement +about what is tested. + +For this feature specifically, **7.0.1 and 7.1.1 are technically identical** — `get_converter` +still arrives in 7.2, so either way the implementation uses `RobotTypeConverter`. The choice +between them is about honesty of the dependency declaration, not capability. + +Consequences of any 7.x floor: + +- `TypeInfo.get_converter` is unavailable below 7.2, so the plan-time "is this convertible?" + check keeps using `RobotTypeConverter` → `TypeConverter.converter_for`, i.e. **RF + internals**. This is not new debt: two shipping call sites already do exactly this + (§1, Fact 2). +- Two guards become dead code and should be deleted: + - `Browser/utils/data_types.py:30-37` — the `TypeInfo` `try/except ImportError`. Deleting + it removes the `UnboundLocalError` rather than fixing it. + - `Browser/utils/logger.py:20-23` — the `LOGLEVEL` guard, which already carries the + comment `TODO: Remove when Robot Framework 7 is minimum version`. +- Two things must **stay**, because they guard against RF < **7.4**, not RF < 7: + - `Browser/utils/types.py:15-20` — the `Secret` fallback class. + - The two `test_data_types.py` tests that skip on RF older than 7.4.0. +- No change to the architecture. Option B is unaffected. + +### Internal API stability across 7.0.1 – 7.4.1 **[V]** + +Since the implementation depends on internals, this was verified on every release in range: + +- `TypeConverter.convert` signature is **identical** on 7.0.1, 7.1.1, 7.2.2, 7.3 and 7.4.1: + `(self, value, name=None, kind='Argument')`. Calling it as `convert(name=..., value=...)` + — as the existing call sites do — is safe throughout. +- Enum, `timedelta` and `Optional[Enum]` convert identically on all five. + +One behaviour change to be aware of: for an **unconvertible** type, `converter_for` returns +`None` on 7.0.1–7.2.2 but an `UnknownConverter` instance on 7.3+. `UnknownConverter.convert` +is a pass-through (`'hello' -> 'hello'`, `42 -> 42`, `None -> None`), so correctness is +unaffected — but the conversion plan will contain a few extra no-op entries on 7.3+. +**Tests must not assert on plan size or on `converter_for(...) is None`.** **[V]** + +> **Note on 7.4.** `from_type_hint` changed its sequence handling in 7.4. Enums, `timedelta` +> and `AssertionOperator` — the agreed scope — are unaffected, but the acceptance tests +> should run against both the floor and the latest RF. **[V]** + +> **Pre-existing bug.** `Browser/utils/data_types.py:33-37`: when `arg_type` is already a +> `TypeInfo`, `type_hint` is never assigned and the function raises `UnboundLocalError`. +> Unreachable today because `get_keyword_types` returns raw hints. If the floor moves to +> 7.0+, the surrounding `try/except ImportError` is dead and the whole branch should go, +> taking the bug with it. **[V]** + +## 6. Where this should eventually live + +Option B works by reaching into `self.attributes`, a PythonLibCore internal. The clean +long-term home is **PythonLibCore itself** — `HybridCore` owns both dicts and is the only +place that knows the split is intentional. An opt-in flag would let every RF library get +this: + +```python +class Browser(DynamicCore): + ROBOT_PYTHON_FRIENDLY_ARGS = True +``` + +Recommended sequencing: **ship Option B in Browser first**, prove it over a release, then +propose it upstream with a working implementation and real usage behind it. Blocking on an +upstream release would stall the feature, and PLC has no conversion hooks today +(`python-api-ergonomics.md` §3.3). + +## 7. Interaction with the repo's own Python-calling code **[V]** + +Two places inside this repo already call keywords from Python. Option B affects exactly one +of them, and the difference is which dict they use: + +| Call site | How it dispatches | Wrapped by Option B? | +| --- | --- | --- | +| `Browser/keywords/promises.py:68` (`promise_to`) | `self.library.keywords[known_keyword](...)` | **No** — uses the RF dict | +| `Browser/entry/__main__.py:444` (`rfbrowser launch-browser-server`) | `browser_lib.launch_browser_server(browser=..., **params)` | **Yes** — attribute access | + +`promise_to` already converts its own arguments (`convert_keyword_arg`, line 128) and then +calls the unwrapped method, so it is unaffected in both behaviour and cost. + +The entry point is different: `convert_options_types` converts at line 443, then line 444 +calls through `__getattr__` — so under Option B those arguments would be converted **twice**. + +**This matters because the `rfbrowser` entry point is the least-tested code in the project** +— acceptance coverage was dropped as too flaky on GitHub runners, and it is validated by +manual testing on each change. CI will not catch a regression here. + +### Double conversion is safe — conversion is idempotent **[V]** + +Verified on the entry point's own parameter list — every one converts to itself, including +the `Proxy` TypedDict and both `timedelta` fields: + +``` +browser ok SupportedBrowsers.chromium -> SupportedBrowsers.chromium +proxy ok {'server': 'http://p:8080'} -> {'server': 'http://p:8080'} +timeout ok timedelta(seconds=10) -> timedelta(seconds=10) +non-idempotent: none +``` + +And library-wide, by converting **every parameter default of every keyword** — defaults are +already-typed values by construction, so this models a Python caller passing +`AssertionOperator["=="]` or `SupportedBrowsers.chromium`, as `utest/test_python_usage.py` +does today: + +``` +params with defaults + a converter : 416 +converted cleanly to themselves : 415 +no converter (skipped) : 0 +problems : 1 + save_page_as_pdf.scale hint=float default=1 -> 1.0 +``` + +The single exception is `int 1` → `float 1.0` on a `float`-hinted parameter: same value, +widened type, harmless. + +**Consequences for the plan:** + +- Option B is safe for the entry point, but `rfbrowser launch-browser-server` must go on the + manual test checklist for this change. +- Deleting the `TypeInfo` guard in `RobotTypeConverter` touches code the entry point depends + on. **Land it as a separate commit from the Option B feature**, so a manual-test failure + has an unambiguous cause. + +## 8. Conversion is necessary but not sufficient — AssertionOperator + +Converting `"=="` to `AssertionOperator.equal` works fine outside an RF run. *Executing* +some operators does not. AssertionEngine's `validate` and `then` call `BuiltIn().evaluate`, +which needs an execution context: **[V]** + +``` +== -> 'abc' +contains -> 'abc' +validate !! RobotNotRunningError: Cannot access execution context +then !! RobotNotRunningError: Cannot access execution context +``` + +So of the 31 keywords taking an `AssertionOperator`, all convert, and the ordinary +comparison operators work from Python — but `validate` and `then` cannot, for reasons +outside this library's control. This is pre-existing and unrelated to which option is +chosen. Worth a documented note, and ideally a clearer error than +`RobotNotRunningError` when a Python caller reaches for them. + +## 9. IDE and type-checker tiers + +Runtime (tier 1) is what Options A–C address. The other two tiers are **independent work** +that rides on the existing stub pipeline, and can land later without revisiting the runtime +choice. + +`Browser/browser.pyi` is generated at build time by `Browser/gen_stub.py` from +`mypy_stub/Browser/keywords/*.pyi`, and is not checked in. That generated file is what IDEs +and mypy actually read for the public class. **[V]** So the annotations that Python users +*see* can be widened without touching the runtime annotations that RF reads: + +```python +# mypy_stub source (runtime, RF sees this): +def click(self, selector: str, button: MouseButton = ...): ... + +# generated Browser/browser.pyi (IDE and mypy see this): +def click(self, selector: str, + button: MouseButton | Literal["left", "middle", "right"] = ...): ... +``` + +This gives autocomplete of the valid strings and mypy/ruff understanding of them, with no +effect on libdoc, on RF's conversion, or on the keyword bodies — which is precisely the +objection that ruled out widening the real type hints. `Literal` appears in **zero** keyword +signatures today, so there is no precedent to conflict with. **[V]** + +Note this only works cleanly for Enums whose members map to strings. `timedelta` would +widen to `timedelta | str | int | float` and `AssertionOperator` to a 30-odd member +`Literal`, which is accurate but noisy in tooltips. Worth deciding per type. + +## 10. Recommendation + +1. Raise the RF floor in `pyproject.toml:12` to **`>= 7.1.1`** (§5) — the lowest version CI + verifies, and older than the rejected 7.2.2. Dropping RF 6 is a breaking change for + users, so it belongs in a major release. +2. Delete the two now-dead RF < 7 guards: the `TypeInfo` branch in `RobotTypeConverter` + (`Browser/utils/data_types.py:30-37`), which removes the `UnboundLocalError`, and the + `LOGLEVEL` guard (`Browser/utils/logger.py:20-23`). Keep the `Secret` guard — it is a + 7.4 guard, not a 7.0 one (§5). +3. Implement **Option B** — wrap `self.attributes` after `DynamicCore.__init__` with cached + signatures and converters resolved once per keyword. One place, RF path provably + untouched, works on all 150 wrappable keywords today. Do **not** route through + `run_keyword` (§3.1). +4. Add acceptance tests calling keywords from Python with plain values, plus a test + asserting the RF path does not enter the conversion wrapper. Run them against both the + RF floor and the latest RF (§5). +5. Document that `validate` and `then` assertion operators are unavailable outside an RF + run (§8). +6. Later, independently: widen Enum annotations in generated `.pyi` for tiers 2 and 3. +7. Later still: propose the mechanism upstream to PythonLibCore. + +Option A is the same feature at 151× the diff and with the RF path dragged in. +Option C is a bigger product decision that this goal does not require. + +## 11. Reproduction + +The scripts below were written in the session scratchpad and **were never committed** — they +do not exist anywhere in this repo, so do not go looking for them. They are listed to record +what was run for each claim; the code that matters is quoted inline above, and the shipped +implementation is `Browser/python_arguments.py` with its tests in +`utest/test_python_arguments.py`. + +- `proto.py` — Options A and B side by side on a synthetic PLC library; the + `convert_args` call-counting that proves the RF-path bypass; the naive-overhead numbers. +- `proto2.py` — the cached-signature variant and its timings. +- `real.py` — Option B applied to the real `Browser()` instance; the 150/161 and 609 + parameter counts, and the intercepted `click` call. +- `b2.py` — the `run_keyword`-routing variant, showing trace-group and failure-screenshot + parity for Python callers and the absence of recursion. + +RF version matrix reproduced with +`uv run --isolated --no-project --with "robotframework==" python -c ...`. + +Note: `robot.api.TypeInfo` has **no** `is_valid` method in RF 7.4.1 — the fast path must +use `isinstance` against `info.type` (guarding `info.is_union`), or the converter's own +result. An `is_valid` call inside a `try/except` silently disables the fast path. **[V]** diff --git a/utest/KeywordArgumentSpy.py b/utest/KeywordArgumentSpy.py new file mode 100644 index 000000000..95f07f2c0 --- /dev/null +++ b/utest/KeywordArgumentSpy.py @@ -0,0 +1,89 @@ +from datetime import timedelta +from os import PathLike + +from assertionengine import AssertionOperator +from robot.api.deco import keyword + +from Browser import Browser, KeyboardModifier, MouseButton +from Browser.base.librarycomponent import LibraryComponent +from Browser.utils.data_types import ClientCertificate, Proxy, RecordVideo +from Browser.utils.types import Secret + + +class KeywordArgumentSpy(LibraryComponent): + def __init__(self, library: Browser): + super().__init__(library) + library.spy_calls = [] + + def _record(self, name, *args, **kwargs): + self.library.spy_calls.append((name, args, kwargs)) + + @keyword + def spy_enum(self, button: MouseButton = MouseButton.left): + self._record("spy_enum", button) + + @keyword + def spy_text(self, text: str): + self._record("spy_text", text) + + @keyword + def spy_optional_text(self, text: str | None = None): + self._record("spy_optional_text", text) + + @keyword + def spy_operator(self, operator: AssertionOperator | None = None): + self._record("spy_operator", operator) + + @keyword + def spy_secret(self, secret: str | Secret): + self._record("spy_secret", secret) + + @keyword + def spy_proxy(self, proxy: Proxy | None = None): + self._record("spy_proxy", proxy) + + @keyword + def spy_record_video(self, recordVideo: RecordVideo | None = None): + self._record("spy_record_video", recordVideo) + + @keyword + def spy_certificates(self, certificates: list[ClientCertificate] | None = None): + self._record("spy_certificates", certificates) + + @keyword + def spy_delay(self, delay: timedelta | None = None): + self._record("spy_delay", delay) + + # Shaped like `click_with_options`: a defaulted positional before the varargs and a + # keyword-only argument after them, which is what drives the bound.args/bound.kwargs split. + @keyword + def spy_modifiers( + self, + selector: str, + button: MouseButton = MouseButton.left, + *modifiers: KeyboardModifier, + delay: timedelta | None = None, + ): + self._record("spy_modifiers", selector, button, *modifiers, delay=delay) + + # Shaped like `upload_file_by_selector`: required positionals and nothing after the + # varargs, the only other varargs shape in the library that carries a type hint. + @keyword + def spy_paths(self, selector: str, path: PathLike, *extra_paths: PathLike): + self._record("spy_paths", selector, path, *extra_paths) + + @keyword + def spy_counts(self, **counts: int): + self._record("spy_counts", **counts) + + @keyword + def spy_untyped(self, value): + self._record("spy_untyped", value) + + @keyword(types={"button": MouseButton}) + def spy_declared_type(self, button="left"): + self._record("spy_declared_type", button) + + @keyword(name="Spy Renamed") + def spy_renamed(self, button: MouseButton = MouseButton.left): + self._record("spy_renamed", button) diff --git a/utest/test_data_types.py b/utest/test_data_types.py index 4da6f1402..0b3294209 100644 --- a/utest/test_data_types.py +++ b/utest/test_data_types.py @@ -99,7 +99,8 @@ def test_convert_typed_dict_with_secret(): @pytest.mark.skipif( hasattr(Secret, "robot_framework_browser_secret"), - reason="This test is only relevant for Robot Framework older than 7.4.0.", + reason="This test needs Robot Framework's own Secret, added in Robot Framework 7.4.0. " + "Below that the library's fallback Secret is used and the test does not apply.", ) def test_convert_typed_dict_with_secret_legacy_secret_object(): annotations = {"credential": Credential} @@ -123,7 +124,8 @@ def test_convert_typed_dict_with_secret_and_optional_key(): @pytest.mark.skipif( hasattr(Secret, "robot_framework_browser_secret"), - reason="This test is only relevant for Robot Framework older than 7.4.0.", + reason="This test needs Robot Framework's own Secret, added in Robot Framework 7.4.0. " + "Below that the library's fallback Secret is used and the test does not apply.", ) def test_convert_typed_dict_with_secret_and_optional_key_legacy_secret_object(): annotations = {"credential": CredentialOptional} diff --git a/utest/test_python_arguments.py b/utest/test_python_arguments.py new file mode 100644 index 000000000..ce38b871a --- /dev/null +++ b/utest/test_python_arguments.py @@ -0,0 +1,348 @@ +import inspect +import sys +from collections import defaultdict +from datetime import timedelta +from pathlib import Path + +import pytest +from assertionengine import AssertionOperator + +from Browser import Browser, KeyboardModifier, MouseButton +from Browser.entry.translation import get_library_translation + +SPY_PLUGIN = str(Path(__file__).parent / "KeywordArgumentSpy.py") + + +@pytest.fixture +def browser(tmpdir): + Browser._output_dir = tmpdir + return Browser() + + +@pytest.fixture +def spy(tmpdir): + Browser._output_dir = tmpdir + return Browser(plugins=SPY_PLUGIN) + + +def last_call(spy): + return spy.spy_calls[-1] + + +def test_invalid_enum_value_raises_robot_frameworks_own_error(browser): + with pytest.raises( + ValueError, match="cannot be converted to MouseButton" + ) as exc_info: + browser.click("//button", "nope") + assert str(exc_info.value) == ( + "Argument 'button' got value 'nope' that cannot be converted to MouseButton: " + "MouseButton does not have member 'nope'. Available: 'left', 'middle' and 'right'" + ) + + +def test_enum_reaches_the_keyword_body_converted(spy): + spy.spy_enum("middle") + assert last_call(spy) == ("spy_enum", (MouseButton.middle,), {}) + + +def test_enum_converts_when_passed_by_name(spy): + spy.spy_enum(button="right") + assert last_call(spy) == ("spy_enum", (MouseButton.right,), {}) + + +def test_none_reaches_the_body_as_none_on_a_bare_str_hint(spy): + spy.spy_text(None) + assert last_call(spy) == ("spy_text", (None,), {}) + + +def test_none_reaches_the_body_on_an_enum_parameter(spy): + spy.spy_enum(None) + assert last_call(spy) == ("spy_enum", (None,), {}) + + +def test_the_string_none_is_still_converted_by_its_hint(spy): + spy.spy_text("None") + assert last_call(spy) == ("spy_text", ("None",), {}) + + +def test_var_positional_arguments_convert_per_element(spy): + spy.spy_modifiers("//button", "left", "Alt", "Shift") + assert last_call(spy) == ( + "spy_modifiers", + ("//button", MouseButton.left, KeyboardModifier.Alt, KeyboardModifier.Shift), + {"delay": None}, + ) + + +def test_var_positional_converts_between_a_defaulted_positional_and_a_keyword_only(spy): + spy.spy_modifiers("//button", "middle", "Alt", delay="1.5s") + assert last_call(spy) == ( + "spy_modifiers", + ("//button", MouseButton.middle, KeyboardModifier.Alt), + {"delay": timedelta(seconds=1.5)}, + ) + + +def test_var_positional_converts_without_a_defaulted_positional_or_keyword_only(spy): + spy.spy_paths("//input", "a.txt", "b.txt", "c.txt") + assert last_call(spy) == ( + "spy_paths", + ("//input", Path("a.txt"), Path("b.txt"), Path("c.txt")), + {}, + ) + + +def test_var_positional_converts_when_it_is_the_first_parameter(browser): + with pytest.raises( + ValueError, match="cannot be converted to Permission" + ) as exc_info: + browser.grant_permissions("nope") + assert str(exc_info.value).startswith( + "Argument 'permissions' got value 'nope' that cannot be converted to Permission:" + ) + + +def test_var_positional_converts_on_the_real_click_with_options(browser): + with pytest.raises( + ValueError, match="cannot be converted to KeyboardModifier" + ) as exc_info: + browser.click_with_options("//button", "left", "nope") + assert str(exc_info.value).startswith( + "Argument 'modifiers' got value 'nope' that cannot be converted to " + "KeyboardModifier: KeyboardModifier does not have member 'nope'." + ) + + +def test_none_inside_var_positional_stays_none(spy): + spy.spy_modifiers("//button", "left", "Alt", None) + assert last_call(spy) == ( + "spy_modifiers", + ("//button", MouseButton.left, KeyboardModifier.Alt, None), + {"delay": None}, + ) + + +def test_var_keyword_arguments_convert_per_element(spy): + spy.spy_counts(clicks="2", taps="3") + assert last_call(spy) == ("spy_counts", (), {"clicks": 2, "taps": 3}) + + +def test_none_inside_var_keyword_stays_none(spy): + spy.spy_counts(clicks="2", taps=None) + assert last_call(spy) == ("spy_counts", (), {"clicks": 2, "taps": None}) + + +def test_keyword_with_a_renamed_robot_name_converts(spy): + spy.spy_renamed("middle") + assert last_call(spy) == ("spy_renamed", (MouseButton.middle,), {}) + + +def test_a_renamed_keyword_is_wrapped_once_not_once_per_alias(spy): + assert spy.attributes["spy_renamed"].__wrapped__ is spy.keywords["Spy Renamed"] + assert spy.attributes["spy_renamed"] is spy.attributes["Spy Renamed"] + + +def test_every_keyword_is_wrapped_once_not_once_per_alias(browser): + distinct_entries = {id(entry) for entry in browser.attributes.values()} + assert len(browser.attributes) > len(browser.keywords), "no aliases to test against" + assert len(distinct_entries) == len(browser.keywords) + assert any( + getattr(browser.attributes[name], "__wrapped__", None) is keyword + for name, keyword in browser.keywords.items() + ) + + +def test_a_type_declared_on_the_keyword_decorator_converts(spy): + # get_keyword_types is the source of truth, so `@keyword(types=...)` works for free. + spy.spy_declared_type("middle") + assert last_call(spy) == ("spy_declared_type", (MouseButton.middle,), {}) + + +def test_keywords_without_convertible_parameters_are_left_unwrapped(spy): + assert spy.attributes["spy_untyped"] is spy.keywords["spy_untyped"] + + +def test_jsextension_keywords_are_callable_and_unwrapped(tmpdir): + Browser._output_dir = tmpdir + extension = Path(__file__).parent / "custom_locator_handler.js" + library = Browser(jsextension=str(extension)) + # jsextension keywords are generated without type annotations, so there is nothing to + # convert against and they must pass through untouched. + assert library.get_keyword_types("customLocatorHandler") == {} + assert ( + library.attributes["customLocatorHandler"] + is library.keywords["customLocatorHandler"] + ) + + +def test_a_wrong_number_of_arguments_raises_pythons_own_message(browser): + with pytest.raises(TypeError) as exc_info: + browser.click() + assert "click() missing 1 required positional argument: 'selector'" in str( + exc_info.value + ) + + +def test_the_robot_framework_path_does_not_convert(spy): + spy.run_keyword("spy_enum", ["middle"]) + assert last_call(spy) == ("spy_enum", ("middle",), {}) + + +def test_the_keyword_table_holds_unwrapped_methods(browser): + assert not hasattr(browser.keywords["click"], "__wrapped__") + assert browser.attributes["click"].__wrapped__ is browser.keywords["click"] + for name, keyword in browser.keywords.items(): + entry = browser.attributes[name] + assert entry is keyword or entry.__wrapped__ is keyword + + +def test_signature_and_converter_are_resolved_at_wrap_time_not_per_call( + spy, monkeypatch +): + def explode(*args, **kwargs): + raise AssertionError("resolved per call instead of once at wrap time") + + monkeypatch.setattr(inspect, "signature", explode) + monkeypatch.setattr( + "Browser.utils.data_types.RobotTypeConverter.converter_for", explode + ) + spy.spy_enum("middle") + assert last_call(spy) == ("spy_enum", (MouseButton.middle,), {}) + + +def test_wrapping_preserves_what_introspection_reads(browser): + for attribute_name, keyword_name in [ + ("click", "click"), + ("evaluate_javascript", "Evaluate JavaScript"), + ]: + wrapper = browser.attributes[attribute_name] + original = browser.keywords[keyword_name] + assert wrapper.__name__ == original.__name__ + assert wrapper.__doc__ == original.__doc__ + assert wrapper.robot_name == original.robot_name + assert inspect.signature(wrapper) == inspect.signature(original) + + +def test_wrapping_survives_a_translated_keyword_table(tmpdir): + Browser._output_dir = tmpdir + sys.path.append(str(Path(__file__).parent.absolute())) + library = Browser(language="ENG") + assert "1_click" in library.keywords, "keyword names were not translated" + assert library.attributes["1_click"].__wrapped__ is library.keywords["1_click"] + assert len(library.attributes) > len(library.keywords) + distinct_entries = {id(entry) for entry in library.attributes.values()} + assert len(distinct_entries) == len(library.keywords) + for name, keyword in library.keywords.items(): + entry = library.attributes[name] + assert entry is keyword or entry.__wrapped__ is keyword + + +def test_timedelta_converts_from_a_time_string(spy): + spy.spy_delay("1.5s") + assert last_call(spy) == ("spy_delay", (timedelta(seconds=1.5),), {}) + + +def test_timedelta_converts_from_a_number(spy): + spy.spy_delay(2) + assert last_call(spy) == ("spy_delay", (timedelta(seconds=2),), {}) + + +@pytest.mark.parametrize("value", ["==", "contains"]) +def test_assertion_operator_converts_from_a_string(spy, value): + spy.spy_operator(value) + assert last_call(spy) == ("spy_operator", (AssertionOperator[value],), {}) + + +def test_an_already_typed_value_passes_through_unchanged(spy): + spy.spy_enum(MouseButton.middle) + spy.spy_delay(timedelta(seconds=3)) + assert spy.spy_calls == [ + ("spy_enum", (MouseButton.middle,), {}), + ("spy_delay", (timedelta(seconds=3),), {}), + ] + + +def test_none_reaches_the_body_as_none_on_an_optional_str_hint(spy): + spy.spy_optional_text(None) + assert last_call(spy) == ("spy_optional_text", (None,), {}) + + +def test_the_string_none_stays_a_string_on_an_optional_str_hint(spy): + spy.spy_optional_text("None") + assert last_call(spy) == ("spy_optional_text", ("None",), {}) + + +def test_a_str_parameter_converts_an_int(spy): + spy.spy_text(5) + assert last_call(spy) == ("spy_text", ("5",), {}) + + +def test_a_typed_dict_parameter_converts(spy): + spy.spy_proxy({"server": "http://localhost:8080", "bypass": "localhost"}) + assert last_call(spy) == ( + "spy_proxy", + ({"server": "http://localhost:8080", "bypass": "localhost"},), + {}, + ) + + +def test_a_dict_argument_is_not_rewritten_under_the_caller(spy): + options = {"dir": Path("videos")} + spy.spy_record_video(options) + assert last_call(spy) == ("spy_record_video", ({"dir": "videos"},), {}) + assert options == {"dir": Path("videos")} + + +def test_a_nested_dict_argument_is_not_rewritten_under_the_caller(spy): + options = {"dir": "videos", "size": {"width": "400", "height": "200"}} + spy.spy_record_video(options) + assert last_call(spy) == ( + "spy_record_video", + ({"dir": "videos", "size": {"width": 400, "height": 200}},), + {}, + ) + assert options == {"dir": "videos", "size": {"width": "400", "height": "200"}} + + +def test_a_dict_inside_a_list_is_not_rewritten_under_the_caller(spy): + certificates = [{"origin": "https://example.com", "certPath": Path("cert.pem")}] + spy.spy_certificates(certificates) + assert last_call(spy) == ( + "spy_certificates", + ([{"origin": "https://example.com", "certPath": "cert.pem"}],), + {}, + ) + assert certificates == [ + {"origin": "https://example.com", "certPath": Path("cert.pem")} + ] + + +def test_a_mapping_that_cannot_be_rebuilt_is_not_rewritten_under_the_caller(spy): + # defaultdict(items) raises TypeError -- the first argument is the factory, not a mapping. + options = defaultdict(str, {"dir": Path("videos")}) + spy.spy_record_video(options) + assert last_call(spy) == ("spy_record_video", ({"dir": "videos"},), {}) + assert options == {"dir": Path("videos")} + + +def test_a_str_or_secret_parameter_accepts_a_plain_string(spy): + spy.spy_secret("plain string") + assert last_call(spy) == ("spy_secret", ("plain string",), {}) + + +def test_fill_secret_accepts_a_plain_string(browser): + with pytest.raises(Exception, match="Could not find active page"): + browser.fill_secret("//input", "plain string") + + +def test_rfbrowser_translate_reads_the_documentation_of_unwrapped_methods(tmpdir): + Browser._output_dir = tmpdir + translation = get_library_translation() + library = Browser() + documentation = { + keyword.__name__: keyword.__doc__ for keyword in library.keywords.values() + } + for name, entry in translation.items(): + if name in ["__init__", "__intro__"]: + continue + assert entry["doc"] == documentation[name], name diff --git a/utest/test_python_usage.py b/utest/test_python_usage.py index 38c3cc6d8..e4ea9662b 100644 --- a/utest/test_python_usage.py +++ b/utest/test_python_usage.py @@ -132,6 +132,19 @@ def test_open_page_get_text(application_server, browser): assert text == "Login Page" +def test_click_with_a_plain_string_button(application_server, browser): + browser.new_page("localhost:7272/dist/") + browser.click_with_options("#clickWithOptions", "middle") + assert browser.get_text("#mouse_button") == "middle" + + +def test_get_text_with_a_plain_string_assertion_operator(application_server, browser): + browser.new_page("localhost:7272/dist/") + assert browser.get_text("h1", "==", "Login Page") == "Login Page" + with pytest.raises(AssertionError): + browser.get_text("h1", "==", "Wrong Page") + + def test_readme_example(browser): browser.new_page("https://playwright.dev") assert "Playwright" in browser.get_text("h1") @@ -209,9 +222,10 @@ def test_playwright_double_close(): def test_promise_handling(browser, application_server): file = Path(__file__) browser.new_page("localhost:7272/dist/") - browser.promise_to_upload_file(file.resolve()) + promise = browser.promise_to_upload_file(file.resolve()) browser.click("#file_chooser") - assert browser.get_text("#upload_result") == "test_python_usage.py" + browser.wait_for(promise) + browser.get_text("#upload_result", AssertionOperator["=="], "test_python_usage.py") def test_promise_to_wait_for_response_with_name_arguments(browser): diff --git a/utest/test_translation.py b/utest/test_translation.py index 9c7f4d8b9..b347ceaab 100644 --- a/utest/test_translation.py +++ b/utest/test_translation.py @@ -1,4 +1,6 @@ +import pkgutil import sys +import zipfile from pathlib import Path import pytest @@ -12,6 +14,39 @@ def browser() -> Browser: return Browser(language="ENG") +class UnlistableImporter: + """Importer that cannot enumerate its modules, like a stale zip importer.""" + + def __init__(self, path: str): + self.path = path + + def find_spec(self, fullname, target=None): + return None + + def iter_modules(self, prefix=""): + raise KeyError(self.path) + + +@pytest.fixture +def unlistable_path_entry(tmp_path): + """Put an importer that raises when listed first on ``sys.path``.""" + entry = str(tmp_path / "unlistable") + + def hook(path): + if path == entry: + return UnlistableImporter(path) + raise ImportError(path) + + sys.path_hooks.insert(0, hook) + sys.path.insert(0, entry) + try: + yield entry + finally: + sys.path.remove(entry) + sys.path_hooks.remove(hook) + sys.path_importer_cache.pop(entry, None) + + def test_no_translation(browser: Browser): assert browser._get_translation(None) is None assert browser._get_translation(False) is None @@ -53,6 +88,37 @@ def test_translated_kw_and_docs(browser: Browser): assert doc.startswith("1 Cancels an active download.") +def test_an_importer_that_cannot_be_listed_does_not_hide_translations( + browser: Browser, unlistable_path_entry +): + assert pkgutil.get_importer(unlistable_path_entry) is not None + with pytest.raises(KeyError): + list(pkgutil.iter_modules()) + lang_plugin = "robotframework_browser_translation_as_list" + file_path = Path(__file__).parent / lang_plugin / "translate_2.json" + assert browser._get_translation("swe") == file_path + + +def test_a_stale_zip_importer_does_not_hide_translations(browser: Browser, tmp_path): + """A Windows console script such as ``robot.exe`` is a zip archive on sys.path. + + ``pkgutil`` reads ``zipimport``'s private directory cache, so on Python 3.13 and + newer an invalidated entry makes ``pkgutil.iter_modules()`` raise ``KeyError``. + """ + archive = tmp_path / "console_script.exe" + with zipfile.ZipFile(archive, "w") as zip_file: + zip_file.writestr("__main__.py", "") + sys.path.insert(0, str(archive)) + try: + pkgutil.get_importer(str(archive)).invalidate_caches() + lang_plugin = "robotframework_browser_translation_as_list" + file_path = Path(__file__).parent / lang_plugin / "translate_2.json" + assert browser._get_translation("swe") == file_path + finally: + sys.path.remove(str(archive)) + sys.path_importer_cache.pop(str(archive), None) + + def test_no_translation(): browser = Browser(language=None) spec = browser.keywords_spec["cancel_download"]