Skip to content

Commit

Permalink
Add support for nesting ampersand (#269)
Browse files Browse the repository at this point in the history
* Add support for nesting ampersand

When used outside the context of nesting, ampersand is treated as the
scoping root.

* Update copyright and document stuff
  • Loading branch information
facelessuser committed Jul 9, 2024
1 parent dc71495 commit c811bdf
Show file tree
Hide file tree
Showing 12 changed files with 138 additions and 14 deletions.
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2018 - 2023 Isaac Muse <isaacmuse@gmail.com>
Copyright (c) 2018 - 2024 Isaac Muse <isaacmuse@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
[![Donate via PayPal][donate-image]][donate-link]
[![Discord][discord-image]][discord-link]
[![Build][github-ci-image]][github-ci-link]
[![Coverage Status][codecov-image]][codecov-link]
[![PyPI Version][pypi-image]][pypi-link]
Expand Down Expand Up @@ -77,8 +76,6 @@ MIT

[github-ci-image]: https://github.com/facelessuser/soupsieve/workflows/build/badge.svg?branch=master&event=push
[github-ci-link]: https://github.com/facelessuser/soupsieve/actions?query=workflow%3Abuild+branch%3Amaster
[discord-image]: https://img.shields.io/discord/678289859768745989?logo=discord&logoColor=aaaaaa&color=mediumpurple&labelColor=333333
[discord-link]:https://discord.gg/XBnPUZF
[codecov-image]: https://img.shields.io/codecov/c/github/facelessuser/soupsieve/master.svg?logo=codecov&logoColor=aaaaaa&labelColor=333333
[codecov-link]: https://codecov.io/github/facelessuser/soupsieve
[pypi-image]: https://img.shields.io/pypi/v/soupsieve.svg?logo=pypi&logoColor=aaaaaa&labelColor=333333
Expand Down
5 changes: 5 additions & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 2.6

- **NEW** Add support for `&` as scoping root per the CSS Nesting Module, Level 1. When `&` is used outside the
context of nesting, it is treated as the scoping root (equivalent to `:scope`).

## 2.5

- **NEW**: Update to support Python 3.12.
Expand Down
8 changes: 8 additions & 0 deletions docs/src/markdown/selectors/pseudo-classes.md
Original file line number Diff line number Diff line change
Expand Up @@ -1579,6 +1579,14 @@ https://developer.mozilla.org/en-US/docs/Web/CSS/:root

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

/// new | New 2.6
`&`, which was introduced in [CSS Nesting Level 1](https://www.w3.org/TR/css-nesting-1/#nest-selector) can be used as
an alternative to `:scope` and is essentially equivalent. Soup Sieve does not support nesting selectors, but `&`, when
not used in the context of nesting is treated as the scoping root per the specification.

`#!py3 sv.select('& > p', soup.div)` is equivalent to `#!py3 sv.select(':scope > p', soup.div)`.
///

`:scope` represents the the element a `match`, `select`, or `filter` is being called on. If we were, for instance,
using `:scope` on a div (`#!py3 sv.select(':scope > p', soup.div)`) `:scope` would represent **that** div element, and
no others. If called on the Beautiful Soup object which represents the entire document, it would simply select
Expand Down
37 changes: 32 additions & 5 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ repo_url: https://github.com/facelessuser/soupsieve
edit_uri: tree/main/docs/src/markdown
site_description: A modern CSS selector library for Beautiful Soup.
copyright: |
Copyright &copy; 2018 - 2023 <a href="https://github.com/facelessuser" target="_blank" rel="noopener">Isaac Muse</a>
Copyright &copy; 2018 - 2024 <a href="https://github.com/facelessuser" target="_blank" rel="noopener">Isaac Muse</a>
docs_dir: docs/src/markdown
theme:
Expand Down Expand Up @@ -81,8 +81,8 @@ markdown_extensions:
- pymdownx.caret:
- pymdownx.smartsymbols:
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.escapeall:
hardbreak: True
nbsp: True
Expand Down Expand Up @@ -118,6 +118,35 @@ markdown_extensions:
- example
- quote
- pymdownx.blocks.details:
types:
- name: details-new
class: new
- name: details-settings
class: settings
- name: details-note
class: note
- name: details-abstract
class: abstract
- name: details-info
class: info
- name: details-tip
class: tip
- name: details-success
class: success
- name: details-question
class: question
- name: details-warning
class: warning
- name: details-failure
class: failure
- name: details-danger
class: danger
- name: details-bug
class: bug
- name: details-example
class: example
- name: details-quote
class: quote
- pymdownx.blocks.html:
- pymdownx.blocks.definition:
- pymdownx.blocks.tab:
Expand All @@ -127,8 +156,6 @@ extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/facelessuser
- icon: fontawesome/brands/discord
link: https://discord.gg/XBnPUZF

plugins:
- search:
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ show_error_codes = true
[tool.ruff]
line-length = 120

select = [
lint.select = [
"A", # flake8-builtins
"B", # flake8-bugbear
"D", # pydocstyle
Expand All @@ -85,7 +85,7 @@ select = [
"PERF" # Perflint
]

ignore = [
lint.ignore = [
"E741",
"D202",
"D401",
Expand Down
2 changes: 1 addition & 1 deletion soupsieve/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from . import css_match as cm
from . import css_types as ct
from .util import DEBUG, SelectorSyntaxError # noqa: F401
import bs4 # type: ignore[import]
import bs4 # type: ignore[import-untyped]
from typing import Any, Iterator, Iterable

__all__ = (
Expand Down
2 changes: 1 addition & 1 deletion soupsieve/__meta__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
return Version(major, minor, micro, release, pre, post, dev)


__version_info__ = Version(2, 5, 0, "final")
__version_info__ = Version(2, 6, 0, "final")
__version__ = __version_info__._get_canonical()
2 changes: 1 addition & 1 deletion soupsieve/css_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import re
from . import css_types as ct
import unicodedata
import bs4 # type: ignore[import]
import bs4 # type: ignore[import-untyped]
from typing import Iterator, Iterable, Any, Callable, Sequence, cast # noqa: F401

# Empty tag pattern (whitespace okay)
Expand Down
6 changes: 6 additions & 0 deletions soupsieve/css_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@
PAT_PSEUDO_CLASS_SPECIAL = fr'(?P<name>:{IDENTIFIER})(?P<open>\({WSC}*)'
# Custom pseudo class (`:--custom-pseudo`)
PAT_PSEUDO_CLASS_CUSTOM = fr'(?P<name>:(?=--){IDENTIFIER})'
# Nesting ampersand selector. Matches `&`
PAT_AMP = r'&'
# Closing pseudo group (`)`)
PAT_PSEUDO_CLOSE = fr'{WSC}*\)'
# Pseudo element (`::pseudo-element`)
Expand Down Expand Up @@ -435,6 +437,7 @@ class CSSParser:
SelectorPattern("pseudo_class_custom", PAT_PSEUDO_CLASS_CUSTOM),
SelectorPattern("pseudo_class", PAT_PSEUDO_CLASS),
SelectorPattern("pseudo_element", PAT_PSEUDO_ELEMENT),
SelectorPattern("amp", PAT_AMP),
SelectorPattern("at_rule", PAT_AT_RULE),
SelectorPattern("id", PAT_ID),
SelectorPattern("class", PAT_CLASS),
Expand Down Expand Up @@ -967,6 +970,9 @@ def parse_selectors(
# Handle parts
if key == "at_rule":
raise NotImplementedError(f"At-rules found at position {m.start(0)}")
elif key == "amp":
sel.flags |= ct.SEL_SCOPE
has_selector = True
elif key == 'pseudo_class_custom':
has_selector = self.parse_pseudo_class_custom(sel, m, has_selector)
elif key == 'pseudo_class':
Expand Down
1 change: 1 addition & 0 deletions tests/test_nesting_1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Test CSS introduced by Nesting level 1."""
80 changes: 80 additions & 0 deletions tests/test_nesting_1/test_amp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Test ampersand selectors."""
from .. import util
import soupsieve as sv


class TestAmp(util.TestCase):
"""Test scope selectors."""

MARKUP = """
<html id="root">
<head>
</head>
<body>
<div id="div">
<p id="0" class="somewordshere">Some text <span id="1"> in a paragraph</span>.</p>
<a id="2" href="http://google.com">Link</a>
<span id="3" class="herewords">Direct child</span>
<pre id="pre" class="wordshere">
<span id="4">Child 1</span>
<span id="5">Child 2</span>
<span id="6">Child 3</span>
</pre>
</div>
</body>
</html>
"""

def test_amp_is_root(self):
"""Test ampersand is the root when the a specific element is not the target of the select call."""

# Scope is root when applied to a document node
self.assert_selector(
self.MARKUP,
"&",
["root"],
flags=util.HTML
)

self.assert_selector(
self.MARKUP,
"& > body > div",
["div"],
flags=util.HTML
)

def test_amp_cannot_select_target(self):
"""Test that ampersand, the element which scope is called on, cannot be selected."""

for parser in util.available_parsers(
'html.parser', 'lxml', 'html5lib', 'xml'):
soup = self.soup(self.MARKUP, parser)
el = soup.html

# Scope is the element we are applying the select to, and that element is never returned
self.assertTrue(len(sv.select('&', el, flags=sv.DEBUG)) == 0)

def test_amp_is_select_target(self):
"""Test that ampersand is the element which scope is called on."""

for parser in util.available_parsers(
'html.parser', 'lxml', 'html5lib', 'xml'):
soup = self.soup(self.MARKUP, parser)
el = soup.html

# Scope here means the current element under select
ids = [el.attrs['id'] for el in sv.select('& div', el, flags=sv.DEBUG)]
self.assertEqual(sorted(ids), sorted(['div']))

el = soup.body
ids = [el.attrs['id'] for el in sv.select('& div', el, flags=sv.DEBUG)]
self.assertEqual(sorted(ids), sorted(['div']))

# `div` is the current element under select, and it has no `div` elements.
el = soup.div
ids = [el.attrs['id'] for el in sv.select('& div', el, flags=sv.DEBUG)]
self.assertEqual(sorted(ids), sorted([]))

# `div` does have an element with the class `.wordshere`
ids = [el.attrs['id'] for el in sv.select('& .wordshere', el, flags=sv.DEBUG)]
self.assertEqual(sorted(ids), sorted(['pre']))

0 comments on commit c811bdf

Please sign in to comment.