Skip to content

Commit b97c84b

Browse files
committed
Add support for nesting ampersand
When used outside the context of nesting, ampersand is treated as the scoping root.
1 parent 51ec317 commit b97c84b

File tree

9 files changed

+105
-5
lines changed

9 files changed

+105
-5
lines changed

docs/src/markdown/about/changelog.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 2.6
4+
5+
- **NEW** Add support for `&` as scoping root per the CSS Nesting Module, Level 1. When `&` is used outside the
6+
context of nesting, it is treated as the scoping root (equivalent to `:scope`).
7+
38
## 2.5
49

510
- **NEW**: Update to support Python 3.12.

docs/src/markdown/selectors/pseudo-classes.md

+8
Original file line numberDiff line numberDiff line change
@@ -1579,6 +1579,14 @@ https://developer.mozilla.org/en-US/docs/Web/CSS/:root
15791579

15801580
## `:scope`:material-flask:{: title="Experimental" data-md-color-primary="purple" .icon} {:#:scope}
15811581

1582+
/// new | New 2.6
1583+
`&`, which was introduced in [CSS Nesting Level 1](https://www.w3.org/TR/css-nesting-1/#nest-selector) can be used as
1584+
an alternative to `:scope` and is essentially equivalent. Soup Sieve does not support nesting selectors, but `&`, when
1585+
not used in the context of nesting is treated as the scoping root per the specification.
1586+
1587+
`#!py3 sv.select('& > p', soup.div)` is equivalent to `#!py3 sv.select(':scope > p', soup.div)`.
1588+
///
1589+
15821590
`:scope` represents the the element a `match`, `select`, or `filter` is being called on. If we were, for instance,
15831591
using `:scope` on a div (`#!py3 sv.select(':scope > p', soup.div)`) `:scope` would represent **that** div element, and
15841592
no others. If called on the Beautiful Soup object which represents the entire document, it would simply select

pyproject.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ show_error_codes = true
6969
[tool.ruff]
7070
line-length = 120
7171

72-
select = [
72+
lint.select = [
7373
"A", # flake8-builtins
7474
"B", # flake8-bugbear
7575
"D", # pydocstyle
@@ -85,7 +85,7 @@ select = [
8585
"PERF" # Perflint
8686
]
8787

88-
ignore = [
88+
lint.ignore = [
8989
"E741",
9090
"D202",
9191
"D401",

soupsieve/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from . import css_match as cm
3232
from . import css_types as ct
3333
from .util import DEBUG, SelectorSyntaxError # noqa: F401
34-
import bs4 # type: ignore[import]
34+
import bs4 # type: ignore[import-untyped]
3535
from typing import Any, Iterator, Iterable
3636

3737
__all__ = (

soupsieve/__meta__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
193193
return Version(major, minor, micro, release, pre, post, dev)
194194

195195

196-
__version_info__ = Version(2, 5, 0, "final")
196+
__version_info__ = Version(2, 6, 0, "final")
197197
__version__ = __version_info__._get_canonical()

soupsieve/css_match.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import re
66
from . import css_types as ct
77
import unicodedata
8-
import bs4 # type: ignore[import]
8+
import bs4 # type: ignore[import-untyped]
99
from typing import Iterator, Iterable, Any, Callable, Sequence, cast # noqa: F401
1010

1111
# Empty tag pattern (whitespace okay)

soupsieve/css_parser.py

+6
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@
127127
PAT_PSEUDO_CLASS_SPECIAL = fr'(?P<name>:{IDENTIFIER})(?P<open>\({WSC}*)'
128128
# Custom pseudo class (`:--custom-pseudo`)
129129
PAT_PSEUDO_CLASS_CUSTOM = fr'(?P<name>:(?=--){IDENTIFIER})'
130+
# Nesting ampersand selector. Matches `&`
131+
PAT_AMP = r'&'
130132
# Closing pseudo group (`)`)
131133
PAT_PSEUDO_CLOSE = fr'{WSC}*\)'
132134
# Pseudo element (`::pseudo-element`)
@@ -435,6 +437,7 @@ class CSSParser:
435437
SelectorPattern("pseudo_class_custom", PAT_PSEUDO_CLASS_CUSTOM),
436438
SelectorPattern("pseudo_class", PAT_PSEUDO_CLASS),
437439
SelectorPattern("pseudo_element", PAT_PSEUDO_ELEMENT),
440+
SelectorPattern("amp", PAT_AMP),
438441
SelectorPattern("at_rule", PAT_AT_RULE),
439442
SelectorPattern("id", PAT_ID),
440443
SelectorPattern("class", PAT_CLASS),
@@ -967,6 +970,9 @@ def parse_selectors(
967970
# Handle parts
968971
if key == "at_rule":
969972
raise NotImplementedError(f"At-rules found at position {m.start(0)}")
973+
elif key == "amp":
974+
sel.flags |= ct.SEL_SCOPE
975+
has_selector = True
970976
elif key == 'pseudo_class_custom':
971977
has_selector = self.parse_pseudo_class_custom(sel, m, has_selector)
972978
elif key == 'pseudo_class':

tests/test_nesting_1/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Test CSS introduced by Nesting level 1."""

tests/test_nesting_1/test_amp.py

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Test ampersand selectors."""
2+
from .. import util
3+
import soupsieve as sv
4+
5+
6+
class TestAmp(util.TestCase):
7+
"""Test scope selectors."""
8+
9+
MARKUP = """
10+
<html id="root">
11+
<head>
12+
</head>
13+
<body>
14+
<div id="div">
15+
<p id="0" class="somewordshere">Some text <span id="1"> in a paragraph</span>.</p>
16+
<a id="2" href="http://google.com">Link</a>
17+
<span id="3" class="herewords">Direct child</span>
18+
<pre id="pre" class="wordshere">
19+
<span id="4">Child 1</span>
20+
<span id="5">Child 2</span>
21+
<span id="6">Child 3</span>
22+
</pre>
23+
</div>
24+
</body>
25+
</html>
26+
"""
27+
28+
def test_amp_is_root(self):
29+
"""Test ampersand is the root when the a specific element is not the target of the select call."""
30+
31+
# Scope is root when applied to a document node
32+
self.assert_selector(
33+
self.MARKUP,
34+
"&",
35+
["root"],
36+
flags=util.HTML
37+
)
38+
39+
self.assert_selector(
40+
self.MARKUP,
41+
"& > body > div",
42+
["div"],
43+
flags=util.HTML
44+
)
45+
46+
def test_amp_cannot_select_target(self):
47+
"""Test that ampersand, the element which scope is called on, cannot be selected."""
48+
49+
for parser in util.available_parsers(
50+
'html.parser', 'lxml', 'html5lib', 'xml'):
51+
soup = self.soup(self.MARKUP, parser)
52+
el = soup.html
53+
54+
# Scope is the element we are applying the select to, and that element is never returned
55+
self.assertTrue(len(sv.select('&', el, flags=sv.DEBUG)) == 0)
56+
57+
def test_amp_is_select_target(self):
58+
"""Test that ampersand is the element which scope is called on."""
59+
60+
for parser in util.available_parsers(
61+
'html.parser', 'lxml', 'html5lib', 'xml'):
62+
soup = self.soup(self.MARKUP, parser)
63+
el = soup.html
64+
65+
# Scope here means the current element under select
66+
ids = [el.attrs['id'] for el in sv.select('& div', el, flags=sv.DEBUG)]
67+
self.assertEqual(sorted(ids), sorted(['div']))
68+
69+
el = soup.body
70+
ids = [el.attrs['id'] for el in sv.select('& div', el, flags=sv.DEBUG)]
71+
self.assertEqual(sorted(ids), sorted(['div']))
72+
73+
# `div` is the current element under select, and it has no `div` elements.
74+
el = soup.div
75+
ids = [el.attrs['id'] for el in sv.select('& div', el, flags=sv.DEBUG)]
76+
self.assertEqual(sorted(ids), sorted([]))
77+
78+
# `div` does have an element with the class `.wordshere`
79+
ids = [el.attrs['id'] for el in sv.select('& .wordshere', el, flags=sv.DEBUG)]
80+
self.assertEqual(sorted(ids), sorted(['pre']))

0 commit comments

Comments
 (0)