Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for nesting ampersand #269

Merged
merged 2 commits into from
Jul 9, 2024
Merged
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
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 <[email protected]>
Copyright (c) 2018 - 2024 Isaac Muse <[email protected]>

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']))
Loading