From a02ff691d2fe804451da63dc83820d5c827e2014 Mon Sep 17 00:00:00 2001 From: Nicholas Tollervey Date: Thu, 11 Dec 2025 17:19:24 +0000 Subject: [PATCH] `pyscript` API/docstring refactor with comprehensive tests (#2414) * Revise display module. TODO: more comprehensive tests. Especially around mimebundles. * Markdown corrections in example code in display.py docstrings. * Minor adjustments and a much more comprehensive test-suite for the display module. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Updated docstring in __init__.py. * Remove unused imports and black-ify the source. * Refactor, docs and tests for the Event class in events.py. * Refactored, simplified and documented @when decorator. * Extensive test suite for @when decorator. * Documentation and minor refactoring of the fetch.py module. TODO: Check tests. * Refactored and more comprehensive tests for the fetch module. * Add/clarify Event related interactions. Thanks @Neon22 for the suggestion. * Refactor, document ffi.py module. * More complete passing tests for ffi.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add docstrings to flatted.py. Since this is actually an external(ish) module, tests for it should be in the external repository from which this code is derived. * Minor docstring cleanup in ffi.py. * Added docstrings and clarifications to fs.py. * Add very limited test suite for fs.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rename magic_js.py to context.py, add comprehensive docstrings, and rename certain internal things for readability and comprehension. * Fix dict check in ffi.py. * Rename test_js_modules to test_context. * Fix test configuration aftert rename. * Docs and refactor of media.py. * Comprehensive tests for media.py. * Refactor and docstrings for storage.py * Appease the ruff gods. * Further storage.py changes and a more complete test suite for storage. * Refactor and docstrings for the util.py module. Fixed a problem with is_awaitable not handling async bound methods. * More comprehensive test suite for util.py. Updated to latest upytest. * A major refactoring, documenting and simplification of the web.py module substantially reducing it in size and complexity with only a few minor (edge) behavioural changes. Softly breaking changes include: - An element's classes are just a set. - An element's styles are just a dict. - Explicitly use `update_all` with ElementCollections (simpler and greater flexibility). - Extract a child element by id with `my_container["#an-id"]` * Updates and additions for a more comprehensive test suite for the web.py module. All code paths are exercised and checked. * Black tidy-ups in test suite. * Refactor and documentation for websocket.py module. * Tests for websocket.py. Disabled due to playwright flakiness, but they all pass in a local browser. * Refactor and documentation of workers.py module. * Added tests for workers.py module. Updated related test suite to account for the new named worker in the test HTML. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refactor away remaining "is not None" not caught before. * Remove check-docstring-first because it interferes with the auto-generated documentation (where triple quoted strings are used to document module attributes). * Careful Markdown changes so the docstrings render properly in the PyScript docs. * Typo correction. * More typo corrections and clarifications. * Add clarification about SVG handling to _render_image docstring. * Add DOM event options to the @when decorator (with new tests to exercise this functionality). * Fixes default value for options if no options passed into @when. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 1 - core/src/stdlib/pyscript.js | 26 +- core/src/stdlib/pyscript/__init__.py | 131 +- core/src/stdlib/pyscript/context.py | 198 ++ core/src/stdlib/pyscript/display.py | 336 +-- core/src/stdlib/pyscript/events.py | 289 ++- core/src/stdlib/pyscript/fetch.py | 181 +- core/src/stdlib/pyscript/ffi.py | 148 +- core/src/stdlib/pyscript/flatted.py | 79 +- core/src/stdlib/pyscript/fs.py | 264 ++- core/src/stdlib/pyscript/magic_js.py | 87 - core/src/stdlib/pyscript/media.py | 254 ++- core/src/stdlib/pyscript/storage.py | 241 +- core/src/stdlib/pyscript/util.py | 35 +- core/src/stdlib/pyscript/web.py | 2002 +++++++++-------- core/src/stdlib/pyscript/websocket.py | 303 ++- core/src/stdlib/pyscript/workers.py | 203 +- core/tests/py_tests.main.spec.js | 2 +- core/tests/python/index.html | 3 + core/tests/python/settings_mpy.json | 19 +- core/tests/python/settings_py.json | 21 +- .../{test_js_modules.py => test_context.py} | 0 .../tests/python/tests/test_current_target.py | 4 +- core/tests/python/tests/test_display.py | 420 +++- core/tests/python/tests/test_events.py | 547 ++++- core/tests/python/tests/test_fetch.py | 250 +- core/tests/python/tests/test_ffi.py | 85 + core/tests/python/tests/test_fs.py | 56 + core/tests/python/tests/test_media.py | 230 +- core/tests/python/tests/test_storage.py | 246 +- core/tests/python/tests/test_util.py | 188 +- core/tests/python/tests/test_web.py | 502 ++++- core/tests/python/tests/test_websocket.py | 181 +- core/tests/python/tests/test_workers.py | 105 + core/tests/python/worker_functions.py | 18 + core/types/stdlib/pyscript.d.ts | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 38 files changed, 5808 insertions(+), 1853 deletions(-) create mode 100644 core/src/stdlib/pyscript/context.py delete mode 100644 core/src/stdlib/pyscript/magic_js.py rename core/tests/python/tests/{test_js_modules.py => test_context.py} (100%) create mode 100644 core/tests/python/tests/test_fs.py create mode 100644 core/tests/python/tests/test_workers.py create mode 100644 core/tests/python/worker_functions.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70fe6fb5..43d9ea56 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,6 @@ repos: hooks: - id: check-builtin-literals - id: check-case-conflict - - id: check-docstring-first - id: check-executables-have-shebangs - id: check-json exclude: tsconfig\.json diff --git a/core/src/stdlib/pyscript.js b/core/src/stdlib/pyscript.js index fcac6257..840212eb 100644 --- a/core/src/stdlib/pyscript.js +++ b/core/src/stdlib/pyscript.js @@ -1,19 +1,19 @@ // ⚠️ This file is an artifact: DO NOT MODIFY export default { "pyscript": { - "__init__.py": "from polyscript import lazy_py_modules as py_import\nfrom pyscript.magic_js import RUNNING_IN_WORKER,PyWorker,config,current_target,document,js_import,js_modules,sync,window\nfrom pyscript.display import HTML,display\nfrom pyscript.fetch import fetch\nfrom pyscript.storage import Storage,storage\nfrom pyscript.websocket import WebSocket\nfrom pyscript.events import when,Event\nif not RUNNING_IN_WORKER:from pyscript.workers import create_named_worker,workers", - "display.py": "_K='_repr_mimebundle_'\n_J='image/svg+xml'\n_I='application/json'\n_H='__repr__'\n_G='savefig'\n_F='text/html'\n_E='image/jpeg'\n_D='application/javascript'\n_C='utf-8'\n_B='text/plain'\n_A='image/png'\nimport base64,html,io,re\nfrom pyscript.magic_js import current_target,document,window\nfrom pyscript.ffi import is_none\n_MIME_METHODS={_G:_A,'_repr_javascript_':_D,'_repr_json_':_I,'_repr_latex':'text/latex','_repr_png_':_A,'_repr_jpeg_':_E,'_repr_pdf_':'application/pdf','_repr_svg_':_J,'_repr_markdown_':'text/markdown','_repr_html_':_F,_H:_B}\ndef _render_image(mime,value,meta):\n\tA=value\n\tif isinstance(A,bytes):A=base64.b64encode(A).decode(_C)\n\tB=re.compile('^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$')\n\tif len(A)>0 and not B.match(A):A=base64.b64encode(A.encode(_C)).decode(_C)\n\tC=f\"data:{mime};charset=utf-8;base64,{A}\";D=' '.join(['{k}=\"{v}\"'for(A,B)in meta.items()]);return f''\ndef _identity(value,meta):return value\n_MIME_RENDERERS={_B:html.escape,_F:_identity,_A:lambda value,meta:_render_image(_A,value,meta),_E:lambda value,meta:_render_image(_E,value,meta),_J:_identity,_I:_identity,_D:lambda value,meta:f\" + + + +``` + +Dynamically creating named workers: + +```python +from pyscript import create_named_worker -# this solves an inconsistency between Pyodide and MicroPython -# @see https://github.com/pyscript/pyscript/issues/2106 -class _ReadOnlyProxy: +# Create a worker from a Python file. +worker = await create_named_worker( + src="./background_tasks.py", + name="task-processor" +) + +# Use the worker's exported functions. +result = await worker.process_data([1, 2, 3, 4, 5]) +print(result) +``` + +Key features: +- Access (`await`) named workers via dictionary-like syntax. +- Dynamically create workers from Python. +- Cross-interpreter support (Pyodide and MicroPython). + +Worker access is asynchronous - you must `await workers[name]` to get +a reference to the worker. This is because workers may not be ready +immediately at startup. +""" + +import js +import json +from polyscript import workers as _polyscript_workers + + +class _ReadOnlyWorkersProxy: + """ + A read-only proxy for accessing named web workers. Use + `create_named_worker()` to create new workers found in this proxy. + + This provides dictionary-like access to named workers defined in + the page. It handles differences between Pyodide and MicroPython + implementations transparently. + + (See: https://github.com/pyscript/pyscript/issues/2106 for context.) + + The proxy is read-only to prevent accidental modification of the + underlying workers registry. Both item access and attribute access are + supported for convenience (especially since HTML attribute names may + not be valid Python identifiers). + + ```python + from pyscript import workers + + # Access a named worker. + my_worker = await workers["worker-name"] + result = await my_worker.some_function() + + # Alternatively, if the name works, access via attribute notation. + my_worker = await workers.worker_name + result = await my_worker.some_function() + ``` + + **This is a proxy object, not a dict**. You cannot iterate over it or + get a list of worker names. This is intentional because worker + startup timing is non-deterministic. + """ + def __getitem__(self, name): - return _get(_workers, name) + """ + Get a named worker by `name`. It returns a promise that resolves to + the worker reference when ready. + + This is useful if the underlying worker name is not a valid Python + identifier. + + ```python + worker = await workers["my-worker"] + ``` + """ + return js.Reflect.get(_polyscript_workers, name) def __getattr__(self, name): - return _get(_workers, name) + """ + Get a named worker as an attribute. It returns a promise that resolves + to the worker reference when ready. + + This allows accessing workers via dot notation as an alternative + to bracket notation. + + ```python + worker = await workers.my_worker + ``` + """ + return js.Reflect.get(_polyscript_workers, name) -workers = _ReadOnlyProxy() +# Global workers proxy for accessing named workers. +workers = _ReadOnlyWorkersProxy() +"""Global proxy for accessing named web workers.""" -async def create_named_worker(src="", name="", config=None, type="py"): - from json import dumps +async def create_named_worker(src, name, config=None, type="py"): + """ + Dynamically create a web worker with a `src` Python file, a unique + `name` and optional `config` (dict or JSON string) and `type` (`py` + for Pyodide or `mpy` for MicroPython, the default is `py`). - if not src: - msg = "Named workers require src" - raise ValueError(msg) + This function creates a new web worker by injecting a ` +

Test Read and Write

Content test_rr_div
@@ -63,6 +65,7 @@ +

diff --git a/core/tests/python/settings_mpy.json b/core/tests/python/settings_mpy.json index fb46cf3b..255f6f9c 100644 --- a/core/tests/python/settings_mpy.json +++ b/core/tests/python/settings_mpy.json @@ -1,27 +1,26 @@ { "files": { - "https://raw.githubusercontent.com/ntoll/upytest/1.0.9/upytest.py": "", + "https://raw.githubusercontent.com/ntoll/upytest/1.0.11/upytest.py": "", "./tests/test_config.py": "tests/test_config.py", + "./tests/test_context.py": "tests/test_context.py", "./tests/test_current_target.py": "tests/test_current_target.py", "./tests/test_display.py": "tests/test_display.py", "./tests/test_document.py": "tests/test_document.py", + "./tests/test_events.py": "tests/test_events.py", "./tests/test_fetch.py": "tests/test_fetch.py", "./tests/test_ffi.py": "tests/test_ffi.py", - "./tests/test_js_modules.py": "tests/test_js_modules.py", + "./tests/test_fs.py": "tests/test_fs.py", "./tests/test_media.py": "tests/test_media.py", "./tests/test_storage.py": "tests/test_storage.py", + "./tests/test_util.py": "tests/test_util.py", "./tests/test_running_in_worker.py": "tests/test_running_in_worker.py", "./tests/test_web.py": "tests/test_web.py", "./tests/test_websocket.py": "tests/test_websocket.py", - "./tests/test_events.py": "tests/test_events.py", - "./tests/test_window.py": "tests/test_window.py" + "./tests/test_window.py": "tests/test_window.py", + "./tests/test_workers.py": "tests/test_workers.py" }, "js_modules": { - "main": { - "./example_js_module.js": "greeting" - }, - "worker": { - "./example_js_worker_module.js": "greeting_worker" - } + "main": {"./example_js_module.js": "greeting"}, + "worker": {"./example_js_worker_module.js": "greeting_worker"} } } diff --git a/core/tests/python/settings_py.json b/core/tests/python/settings_py.json index 50086a8c..af3e03c6 100644 --- a/core/tests/python/settings_py.json +++ b/core/tests/python/settings_py.json @@ -1,29 +1,28 @@ { "files": { - "https://raw.githubusercontent.com/ntoll/upytest/1.0.9/upytest.py": "", + "https://raw.githubusercontent.com/ntoll/upytest/1.0.11/upytest.py": "", "./tests/test_config.py": "tests/test_config.py", + "./tests/test_context.py": "tests/test_context.py", "./tests/test_current_target.py": "tests/test_current_target.py", "./tests/test_display.py": "tests/test_display.py", "./tests/test_document.py": "tests/test_document.py", + "./tests/test_events.py": "tests/test_events.py", "./tests/test_fetch.py": "tests/test_fetch.py", "./tests/test_ffi.py": "tests/test_ffi.py", + "./tests/test_fs.py": "tests/test_fs.py", "./tests/test_media.py": "tests/test_media.py", - "./tests/test_js_modules.py": "tests/test_js_modules.py", "./tests/test_storage.py": "tests/test_storage.py", + "./tests/test_util.py": "tests/test_util.py", "./tests/test_running_in_worker.py": "tests/test_running_in_worker.py", "./tests/test_web.py": "tests/test_web.py", "./tests/test_websocket.py": "tests/test_websocket.py", - "./tests/test_events.py": "tests/test_events.py", - "./tests/test_window.py": "tests/test_window.py" + "./tests/test_window.py": "tests/test_window.py", + "./tests/test_workers.py": "tests/test_workers.py" }, "js_modules": { - "main": { - "./example_js_module.js": "greeting" - }, - "worker": { - "./example_js_worker_module.js": "greeting_worker" - } + "main": {"./example_js_module.js": "greeting"}, + "worker": {"./example_js_worker_module.js": "greeting_worker"} }, - "packages": ["Pillow" ], + "packages": ["Pillow"], "experimental_ffi_timeout": 0 } diff --git a/core/tests/python/tests/test_js_modules.py b/core/tests/python/tests/test_context.py similarity index 100% rename from core/tests/python/tests/test_js_modules.py rename to core/tests/python/tests/test_context.py diff --git a/core/tests/python/tests/test_current_target.py b/core/tests/python/tests/test_current_target.py index cc4e24d2..fa2daf61 100644 --- a/core/tests/python/tests/test_current_target.py +++ b/core/tests/python/tests/test_current_target.py @@ -13,7 +13,7 @@ def test_current_target(): """ expected = "py-0" if is_micropython: - expected = "mpy-w0-target" if RUNNING_IN_WORKER else "mpy-0" + expected = "mpy-w1-target" if RUNNING_IN_WORKER else "mpy-0" elif RUNNING_IN_WORKER: - expected = "py-w0-target" + expected = "py-w1-target" assert current_target() == expected, f"Expected {expected} got {current_target()}" diff --git a/core/tests/python/tests/test_display.py b/core/tests/python/tests/test_display.py index d891bf7c..ada84582 100644 --- a/core/tests/python/tests/test_display.py +++ b/core/tests/python/tests/test_display.py @@ -3,6 +3,7 @@ Tests for the display function in PyScript. """ import asyncio +import json import upytest from pyscript import HTML, RUNNING_IN_WORKER, display, py_import, web @@ -107,20 +108,7 @@ def test_empty_string_target_raises_value_error(): """ with upytest.raises(ValueError) as exc: display("hello world", target="") - assert str(exc.exception) == "Cannot have an empty target" - - -def test_non_string_target_values_raise_typerror(): - """ - The target parameter must be a string. - """ - with upytest.raises(TypeError) as exc: - display("hello world", target=True) - assert str(exc.exception) == "target must be str or None, not bool" - - with upytest.raises(TypeError) as exc: - display("hello world", target=123) - assert str(exc.exception) == "target must be str or None, not int" + assert str(exc.exception) == "Cannot find element with id='' in the page." async def test_tag_target_attribute(): @@ -286,4 +274,406 @@ async def test_image_renders_correctly(): display(img, target="test-element-container", append=False) target = web.page.find("#test-element-container")[0] img = target.find("img")[0] - assert img.src.startswith("data:image/png;charset=utf-8;base64") + assert img.src.startswith("data:image/png;base64"), img.src + + +async def test_mimebundle_simple(): + """ + An object with _repr_mimebundle_ should use the mimebundle formats. + """ + + class MimebundleObj: + def _repr_mimebundle_(self): + return { + "text/html": "Bold HTML", + "text/plain": "Plain text fallback", + } + + display(MimebundleObj()) + container = await get_display_container() + # Should prefer HTML from mimebundle. + assert container[0].innerHTML == "Bold HTML" + + +async def test_mimebundle_with_metadata(): + """ + Mimebundle can include metadata for specific MIME types. + """ + + class ImageWithMeta: + def _repr_mimebundle_(self): + return ( + { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + }, + {"image/png": {"width": "100", "height": "50"}}, + ) + + display(ImageWithMeta(), target="test-element-container", append=False) + target = web.page.find("#test-element-container")[0] + img = target.find("img")[0] + assert img.getAttribute("width") == "100" + assert img.getAttribute("height") == "50" + + +async def test_mimebundle_with_tuple_output(): + """ + Mimebundle format values can be tuples with (data, metadata). + """ + + class TupleOutput: + def _repr_mimebundle_(self): + return {"text/html": ("Italic", {"custom": "meta"})} + + display(TupleOutput()) + container = await get_display_container() + assert container[0].innerHTML == "Italic" + + +async def test_mimebundle_metadata_merge(): + """ + Format-specific metadata should merge with global metadata. + """ + + class MetaMerge: + def _repr_mimebundle_(self): + return ( + { + "image/png": ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + {"height": "75"}, + ) + }, + {"image/png": {"width": "100"}}, + ) + + display(MetaMerge(), target="test-element-container", append=False) + target = web.page.find("#test-element-container")[0] + img = target.find("img")[0] + # Both global and format-specific metadata should be present. + assert img.getAttribute("width") == "100" + assert img.getAttribute("height") == "75" + + +async def test_mimebundle_unsupported_mime(): + """ + If mimebundle contains only unsupported MIME types, fall back to regular methods. + """ + + class UnsupportedMime: + def _repr_mimebundle_(self): + return {"application/pdf": "PDF data", "text/latex": "LaTeX data"} + + def _repr_html_(self): + return "

HTML fallback

" + + display(UnsupportedMime()) + container = await get_display_container() + # Should fall back to _repr_html_. + assert container[0].innerHTML == "

HTML fallback

" + + +async def test_mimebundle_no_dict(): + """ + Mimebundle that returns just a dict (no tuple) should work. + """ + + class SimpleMimebundle: + def _repr_mimebundle_(self): + return {"text/html": "Code"} + + display(SimpleMimebundle()) + container = await get_display_container() + assert container[0].innerHTML == "Code" + + +async def test_repr_html(): + """ + Objects with _repr_html_ should render as HTML. + """ + + class HTMLRepr: + def _repr_html_(self): + return "

HTML Header

" + + display(HTMLRepr()) + container = await get_display_container() + assert container[0].innerHTML == "

HTML Header

" + + +async def test_repr_html_with_metadata(): + """ + _repr_html_ can return (html, metadata) tuple. + """ + + class HTMLWithMeta: + def _repr_html_(self): + return ("

Paragraph

", {"data-custom": "value"}) + + display(HTMLWithMeta()) + container = await get_display_container() + # Metadata is not used in _repr_html_ rendering, but ensure HTML is + # correct. + assert container[0].innerHTML == "

Paragraph

" + + +async def test_repr_svg(): + """ + Objects with _repr_svg_ should render as SVG. + """ + + class SVGRepr: + def _repr_svg_(self): + return '' + + display(SVGRepr(), target="test-element-container", append=False) + target = web.page.find("#test-element-container")[0] + assert "svg" in target.innerHTML.lower() + assert "circle" in target.innerHTML.lower() + + +async def test_repr_json(): + """ + Objects with _repr_json_ should render as JSON. + """ + + class JSONRepr: + def _repr_json_(self): + return '{"key": "value", "number": 42}' + + display(JSONRepr(), target="test-element-container", append=False) + target = web.page.find("#test-element-container")[0] + assert '"key": "value"' in target.innerHTML + value = json.loads(target.innerText) + assert value["key"] == "value" + assert value["number"] == 42 + + +async def test_repr_png_bytes(): + """ + _repr_png_ can render raw bytes. + """ + + class PNGBytes: + def _repr_png_(self): + # Valid 1x1 transparent PNG as bytes. + return b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" + + display(PNGBytes(), target="test-element-container", append=False) + target = web.page.find("#test-element-container")[0] + img = target.find("img")[0] + assert img.src.startswith("data:image/png;base64,") + + +async def test_repr_png_base64(): + """ + _repr_png_ can render a base64-encoded string. + """ + + class PNGBase64: + def _repr_png_(self): + return "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + + display(PNGBase64(), target="test-element-container", append=False) + target = web.page.find("#test-element-container")[0] + img = target.find("img")[0] + assert img.src.startswith("data:image/png;base64,") + + +async def test_repr_jpeg(): + """ + Objects with _repr_jpeg_ should render as JPEG images. + """ + + class JPEGRepr: + def _repr_jpeg_(self): + # Minimal valid JPEG header (won't display but tests the path). + return b"\xff\xd8\xff\xe0\x00\x10JFIF" + + display(JPEGRepr(), target="test-element-container", append=False) + target = web.page.find("#test-element-container")[0] + img = target.find("img")[0] + assert img.src.startswith("data:image/jpeg;base64,") + + +async def test_repr_jpeg_base64(): + """ + _repr_jpeg_ can render a base64-encoded string. + """ + + class JPEGBase64: + def _repr_jpeg_(self): + return "ZCBqcGVnIG1pbmltdW0==" + + display(JPEGBase64(), target="test-element-container", append=False) + target = web.page.find("#test-element-container")[0] + img = target.find("img")[0] + assert img.src.startswith("data:image/jpeg;base64,") + + +async def test_object_with_no_repr_methods(): + """ + Objects with no representation methods should fall back to __repr__ with warning. + """ + + class NoReprMethods: + pass + + obj = NoReprMethods() + display(obj) + container = await get_display_container() + # Should contain the default repr output - the class name. :-) + assert "NoReprMethods" in container.innerText + + +async def test_repr_method_returns_none(): + """ + If a repr method exists but returns None, try next method. + """ + + class NoneReturner: + def _repr_html_(self): + return None + + def __repr__(self): + return "Fallback repr" + + display(NoneReturner()) + container = await get_display_container() + assert container.innerText == "Fallback repr" + + +async def test_multiple_repr_methods_priority(): + """ + When multiple repr methods exist, should use first available in priority order. + """ + + class MultipleReprs: + def _repr_html_(self): + # Highest priority. + return "

HTML version

" + + def __repr__(self): + # Lower priority. + return "Text version" + + display(MultipleReprs()) + container = await get_display_container() + # Should use HTML, not repr. + assert container[0].innerHTML == "

HTML version

" + + +async def test_empty_string_display(): + """ + Empty strings are ignored. + """ + display("") + container = await get_display_container() + assert len(container.children) == 0 + + +async def test_newline_string_skipped(): + """ + Single newline strings are skipped (legacy behavior). + """ + display("\n") + container = await get_display_container() + # Should be empty because newlines are skipped. + assert len(container.children) == 0 + + +async def test_string_with_special_html_chars(): + """ + Strings with HTML special characters should be escaped. + """ + display("") + container = await get_display_container() + assert "<script>" in container[0].innerHTML + assert "