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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions dash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could also expose css_escape

from . import dependencies # noqa: F401,E402
from . import development # noqa: F401,E402
from . import exceptions # noqa: F401,E402
Expand Down
70 changes: 66 additions & 4 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would maybe rename to escape_css_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):
Expand Down
45 changes: 45 additions & 0 deletions tests/integration/test_css_select_by_id.py
Original file line number Diff line number Diff line change
@@ -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))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an easy possibility of extending

def wait_for_element_by_id(self, element_id, timeout=None):
"""Explicit wait until the element is present, timeout if not set,
equals to the fixture's `wait_timeout` shortcut to `WebDriverWait` with
`EC.presence_of_element_located`."""
return self._wait_for(
EC.presence_of_element_located,
((By.ID, element_id),),
timeout,
"timeout {}s => waiting for element id {}".format(
timeout if timeout else self._wait_timeout, element_id
),
)

to check if input element_id is dict, and then do dash.stringify_id(element_id, escape_css=True) for the user in the wait_for_element_by_id function.

The user's test line here

assert dash_duo.wait_for_element_by_id(dash.stringify_id(id, escape_css=True))

then becomes

assert dash_duo.wait_for_element_by_id(id)

both in the case with string IDs, and dictionary IDs (when you use pattern-matching callbacks).

Not sure if that is too magical 🔮 or a nice and easy to understand convenience.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea with extending the testing utilities, I think that is where most cases would be for escaping.

assert dash_duo.wait_for_element("#" + dash.stringify_id(id, escape_css=True))