Skip to content

Commit c811bdf

Browse files
authored
Add support for nesting ampersand (#269)
* Add support for nesting ampersand When used outside the context of nesting, ampersand is treated as the scoping root. * Update copyright and document stuff
1 parent dc71495 commit c811bdf

File tree

12 files changed

+138
-14
lines changed

12 files changed

+138
-14
lines changed

LICENSE.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2018 - 2023 Isaac Muse <[email protected]>
3+
Copyright (c) 2018 - 2024 Isaac Muse <[email protected]>
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
[![Donate via PayPal][donate-image]][donate-link]
2-
[![Discord][discord-image]][discord-link]
32
[![Build][github-ci-image]][github-ci-link]
43
[![Coverage Status][codecov-image]][codecov-link]
54
[![PyPI Version][pypi-image]][pypi-link]
@@ -77,8 +76,6 @@ MIT
7776

7877
[github-ci-image]: https://github.com/facelessuser/soupsieve/workflows/build/badge.svg?branch=master&event=push
7978
[github-ci-link]: https://github.com/facelessuser/soupsieve/actions?query=workflow%3Abuild+branch%3Amaster
80-
[discord-image]: https://img.shields.io/discord/678289859768745989?logo=discord&logoColor=aaaaaa&color=mediumpurple&labelColor=333333
81-
[discord-link]:https://discord.gg/XBnPUZF
8279
[codecov-image]: https://img.shields.io/codecov/c/github/facelessuser/soupsieve/master.svg?logo=codecov&logoColor=aaaaaa&labelColor=333333
8380
[codecov-link]: https://codecov.io/github/facelessuser/soupsieve
8481
[pypi-image]: https://img.shields.io/pypi/v/soupsieve.svg?logo=pypi&logoColor=aaaaaa&labelColor=333333

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

mkdocs.yml

+32-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ repo_url: https://github.com/facelessuser/soupsieve
44
edit_uri: tree/main/docs/src/markdown
55
site_description: A modern CSS selector library for Beautiful Soup.
66
copyright: |
7-
Copyright &copy; 2018 - 2023 <a href="https://github.com/facelessuser" target="_blank" rel="noopener">Isaac Muse</a>
7+
Copyright &copy; 2018 - 2024 <a href="https://github.com/facelessuser" target="_blank" rel="noopener">Isaac Muse</a>
88
99
docs_dir: docs/src/markdown
1010
theme:
@@ -81,8 +81,8 @@ markdown_extensions:
8181
- pymdownx.caret:
8282
- pymdownx.smartsymbols:
8383
- pymdownx.emoji:
84-
emoji_index: !!python/name:materialx.emoji.twemoji
85-
emoji_generator: !!python/name:materialx.emoji.to_svg
84+
emoji_index: !!python/name:material.extensions.emoji.twemoji
85+
emoji_generator: !!python/name:material.extensions.emoji.to_svg
8686
- pymdownx.escapeall:
8787
hardbreak: True
8888
nbsp: True
@@ -118,6 +118,35 @@ markdown_extensions:
118118
- example
119119
- quote
120120
- pymdownx.blocks.details:
121+
types:
122+
- name: details-new
123+
class: new
124+
- name: details-settings
125+
class: settings
126+
- name: details-note
127+
class: note
128+
- name: details-abstract
129+
class: abstract
130+
- name: details-info
131+
class: info
132+
- name: details-tip
133+
class: tip
134+
- name: details-success
135+
class: success
136+
- name: details-question
137+
class: question
138+
- name: details-warning
139+
class: warning
140+
- name: details-failure
141+
class: failure
142+
- name: details-danger
143+
class: danger
144+
- name: details-bug
145+
class: bug
146+
- name: details-example
147+
class: example
148+
- name: details-quote
149+
class: quote
121150
- pymdownx.blocks.html:
122151
- pymdownx.blocks.definition:
123152
- pymdownx.blocks.tab:
@@ -127,8 +156,6 @@ extra:
127156
social:
128157
- icon: fontawesome/brands/github
129158
link: https://github.com/facelessuser
130-
- icon: fontawesome/brands/discord
131-
link: https://discord.gg/XBnPUZF
132159

133160
plugins:
134161
- search:

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)