From e4df8cef030f40cd3a8492b0d3d4d47b811ae4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20Fredrik=20Ki=C3=A6r?= <31612826+anders-kiaer@users.noreply.github.com> Date: Sun, 9 May 2021 12:49:41 +0200 Subject: [PATCH] Expose at top level and add optional parameter --- CHANGELOG.md | 9 +++ dash/__init__.py | 1 + dash/_utils.py | 70 ++++++++++++++++++++-- tests/integration/test_css_select_by_id.py | 45 ++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/integration/test_css_select_by_id.py diff --git a/CHANGELOG.md b/CHANGELOG.md index de411802d5..aadac4a4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [UNRELEASED] - YYYY-MM-DD + +### Added +- [#1637](https://github.com/plotly/dash/pull/1637) Exposed the `stringify_id` function at top level, i.e. `dash.stringify_id(id)`. +This function is especially useful in settings where `id` is a dictionary (i.e. used in pattern-matching callbaks), +and you need the stringified ID that dash internally use in the DOM. The function now also takes an optional boolean parameter +`escape_css` (default `False`) which on `True` escapes the returned string for CSS special characters such that it can be used +directly in CSS selectors like `dash_duo.wait_for_element_by_id(dash.stringify_id(id, escape_css=True))`. + ## [1.20.0] - 2021-04-08 ## Dash and Dash Renderer diff --git a/dash/__init__.py b/dash/__init__.py index 0449329627..3d2dd58599 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -3,6 +3,7 @@ # must come before any other imports. __plotly_dash = True from .dash import Dash, no_update # noqa: F401,E402 +from ._utils import stringify_id # noqa: F401,E402 from . import dependencies # noqa: F401,E402 from . import development # noqa: F401,E402 from . import exceptions # noqa: F401,E402 diff --git a/dash/_utils.py b/dash/_utils.py index d073bf6e6d..c23c760a7e 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -192,10 +192,72 @@ def split_callback_id(callback_id): return {"id": id_, "property": prop} -def stringify_id(id_): - if isinstance(id_, dict): - return json.dumps(id_, sort_keys=True, separators=(",", ":")) - return id_ +def css_escape(stringified_id): + """Escapes an ID string such that it can be used in CSS selectors. + + The comments below in the different if-branches are taken from + https://drafts.csswg.org/cssom/#serialize-an-identifier. + """ + + def code_point_range(from_unicode, to_unicode): + return list(range(ord(from_unicode), ord(to_unicode) + 1)) + + CONTROL_CHARACTER_RANGE = code_point_range("\u0001", "\u001f") + [ord("\u007f")] + ALLOWED_FINITE_RANGES = ( + code_point_range("\u0030", "\u0039") + + code_point_range("\u0041", "\u005a") + + code_point_range("\u0061", "\u007a") + + [ord("\u002d"), ord("\u005f")] + ) + + escaped_chars = [] + + for i, char in enumerate(stringified_id): + if char == "\u0000": + # If the character is NULL (U+0000), then the REPLACEMENT CHARACTER (U+FFFD). + escaped_chars.append("\ufffd") + elif ord(char) in CONTROL_CHARACTER_RANGE: + # If the character is in the range U+0001 to U+001F or is U+007F, + # then the character escaped as code point. + escaped_chars.append("\\" + hex(ord(char)).replace("0x", "") + " ") + elif i == 0 and char.isdigit(): + # If the character is the first character and is in the range [0-9], + # then the character escaped as code point. + escaped_chars.append(r"\3" + char + " ") + elif i == 1 and char.isdigit() and stringified_id[0] == "-": + # If the character is the second character and is in the range [0-9] + # and the first character is a "-", then the character escaped as code point. + escaped_chars.append(r"\3" + char + " ") + elif i == 0 and char == "-" and len(stringified_id) == 1: + # If the character is the first character and is a "-", + # and there is no second character, then the escaped character. + escaped_chars.append("\\" + char) + elif ord(char) >= ord("\u0080") or ord(char) in ALLOWED_FINITE_RANGES: + # If the character is not handled by one of the above rules and is greater + # than or equal to U+0080, is "-" (U+002D) or "_" (U+005F), or is in one of + # the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to U+005A), + # or \[a-z] (U+0061 to U+007A), then the character itself. + escaped_chars.append(char) + else: + # Otherwise, the escaped character. + escaped_chars.append("\\" + char) + + return "".join(escaped_chars) + + +def stringify_id(id_, escape_css=False): + """Converts a dictionary ID (used in pattern-matching callbacks) to the + corresponding stringified ID used in the DOM. Use escape_css=True if the + returned string is to be used in CSS selectors. See this link for details: + https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape + """ + + stringified_id = ( + json.dumps(id_, sort_keys=True, separators=(",", ":")) + if isinstance(id_, dict) + else id_ + ) + return css_escape(stringified_id) if escape_css else stringified_id def inputs_to_dict(inputs_list): diff --git a/tests/integration/test_css_select_by_id.py b/tests/integration/test_css_select_by_id.py new file mode 100644 index 0000000000..6bcda54632 --- /dev/null +++ b/tests/integration/test_css_select_by_id.py @@ -0,0 +1,45 @@ +from __future__ import unicode_literals +import pytest + +import dash +import dash_html_components as html +from dash._utils import css_escape + + +TEST_STRING_IDS = [ + "standard-sane-id", + "!sane~ID:at>all?" "-", + "-1a", + "0abc", + "{'a': 1, 'b': 2, 'c': [3, 4, 5]}", + '"string-in-string-with-escape-characters"', + "\u001fabc\u007f", +] + +TEST_DICT_IDS = [ + {"a": 1, "b": "some-string", "c": False}, + {"type": "some-type", "index": 42}, +] + + +@pytest.mark.parametrize("id", TEST_STRING_IDS) +def test_css_escape(dash_duo, id): + assert dash_duo.driver.execute_script( + "return CSS.escape({!r})".format(id) + ) == css_escape(id) + + +@pytest.mark.parametrize( + "id", TEST_STRING_IDS + TEST_DICT_IDS, +) +def test_found_by_css_selector(dash_duo, id): + # This test also indirectly checks that output from the JavaScript stringify_id + # function equals output from the corresponding Python function. + app = dash.Dash(__name__) + + app.layout = html.Div(id=id) + + dash_duo.start_server(app) + + assert dash_duo.wait_for_element_by_id(dash.stringify_id(id, escape_css=True)) + assert dash_duo.wait_for_element("#" + dash.stringify_id(id, escape_css=True))