From b97c84b0675567745e93c24526bc6539010a7b87 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 8 Jul 2024 19:24:51 -0600 Subject: [PATCH 1/2] Add support for nesting ampersand When used outside the context of nesting, ampersand is treated as the scoping root. --- docs/src/markdown/about/changelog.md | 5 ++ docs/src/markdown/selectors/pseudo-classes.md | 8 ++ pyproject.toml | 4 +- soupsieve/__init__.py | 2 +- soupsieve/__meta__.py | 2 +- soupsieve/css_match.py | 2 +- soupsieve/css_parser.py | 6 ++ tests/test_nesting_1/__init__.py | 1 + tests/test_nesting_1/test_amp.py | 80 +++++++++++++++++++ 9 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 tests/test_nesting_1/__init__.py create mode 100644 tests/test_nesting_1/test_amp.py diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md index 46a9076..8dd142c 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -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. diff --git a/docs/src/markdown/selectors/pseudo-classes.md b/docs/src/markdown/selectors/pseudo-classes.md index a62d55b..66fe628 100644 --- a/docs/src/markdown/selectors/pseudo-classes.md +++ b/docs/src/markdown/selectors/pseudo-classes.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ce9e1bc..996c115 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ show_error_codes = true [tool.ruff] line-length = 120 -select = [ +lint.select = [ "A", # flake8-builtins "B", # flake8-bugbear "D", # pydocstyle @@ -85,7 +85,7 @@ select = [ "PERF" # Perflint ] -ignore = [ +lint.ignore = [ "E741", "D202", "D401", diff --git a/soupsieve/__init__.py b/soupsieve/__init__.py index 8173fd4..45730df 100644 --- a/soupsieve/__init__.py +++ b/soupsieve/__init__.py @@ -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__ = ( diff --git a/soupsieve/__meta__.py b/soupsieve/__meta__.py index 4dcec73..0fbf71b 100644 --- a/soupsieve/__meta__.py +++ b/soupsieve/__meta__.py @@ -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() diff --git a/soupsieve/css_match.py b/soupsieve/css_match.py index 0b0ecfa..e52e42d 100644 --- a/soupsieve/css_match.py +++ b/soupsieve/css_match.py @@ -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) diff --git a/soupsieve/css_parser.py b/soupsieve/css_parser.py index 14cf54a..85854b3 100644 --- a/soupsieve/css_parser.py +++ b/soupsieve/css_parser.py @@ -127,6 +127,8 @@ PAT_PSEUDO_CLASS_SPECIAL = fr'(?P:{IDENTIFIER})(?P\({WSC}*)' # Custom pseudo class (`:--custom-pseudo`) PAT_PSEUDO_CLASS_CUSTOM = fr'(?P:(?=--){IDENTIFIER})' +# Nesting ampersand selector. Matches `&` +PAT_AMP = r'&' # Closing pseudo group (`)`) PAT_PSEUDO_CLOSE = fr'{WSC}*\)' # Pseudo element (`::pseudo-element`) @@ -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), @@ -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': diff --git a/tests/test_nesting_1/__init__.py b/tests/test_nesting_1/__init__.py new file mode 100644 index 0000000..0b61953 --- /dev/null +++ b/tests/test_nesting_1/__init__.py @@ -0,0 +1 @@ +"""Test CSS introduced by Nesting level 1.""" diff --git a/tests/test_nesting_1/test_amp.py b/tests/test_nesting_1/test_amp.py new file mode 100644 index 0000000..27ebc2e --- /dev/null +++ b/tests/test_nesting_1/test_amp.py @@ -0,0 +1,80 @@ +"""Test ampersand selectors.""" +from .. import util +import soupsieve as sv + + +class TestAmp(util.TestCase): + """Test scope selectors.""" + + MARKUP = """ + + + + +
+

Some text in a paragraph.

+ Link + Direct child +
+    Child 1
+    Child 2
+    Child 3
+    
+
+ + + """ + + 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'])) From 51a5ed0ca317faebf12f99ec861dc8299644664e Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 8 Jul 2024 19:30:15 -0600 Subject: [PATCH 2/2] Update copyright and document stuff --- LICENSE.md | 2 +- README.md | 3 --- mkdocs.yml | 37 ++++++++++++++++++++++++++++++++----- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index ae5fec5..d29cfcd 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 - 2023 Isaac Muse +Copyright (c) 2018 - 2024 Isaac Muse Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 81fa762..a1062dc 100644 --- a/README.md +++ b/README.md @@ -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] @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index c944dfd..c3cc048 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 © 2018 - 2023 Isaac Muse + Copyright © 2018 - 2024 Isaac Muse docs_dir: docs/src/markdown theme: @@ -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 @@ -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: @@ -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: