Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-soupsieve for 
openSUSE:Factory checked in at 2026-09-23 14:32:49
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-soupsieve (Old)
 and      /work/SRC/openSUSE:Factory/.python-soupsieve.new.383539 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-soupsieve"

Wed Sep 23 14:32:49 2026 rev:28 rq:1379675 version:2.9.2

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-soupsieve/python-soupsieve.changes        
2026-07-07 21:00:11.676091683 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-soupsieve.new.383539/python-soupsieve.changes
    2026-09-23 14:33:35.333921489 +0200
@@ -1,0 +2,25 @@
+Tue Sep 22 09:30:32 UTC 2026 - Nico Krapp <[email protected]>
+
+- Update to 2.9.2
+  * FIX: Fix issue where :is() and :where() were not accounting for empty
+    selectors in the max selector count as they should.
+  * FIX: Fix issue where :has() was allowing empty selectors in some
+    circumstances even though it is not forgiving.
+  * FIX: Reduce selector object size when :is() and :where() contain empty
+    selectors.
+- Update to 2.9.1
+  * FIX: Correct [attr^=""], [attr$=""], and [attr*=""] to match nothing when
+    the value is empty, per CSS Selectors Level 4 substring matching, which
+    previously matched any element merely having the attribute.
+- Update to 2.9.0 (fixes CVE-2026-85999 (bsc#1281119),
+  CVE-2026-86000 (bsc#1281120))
+  * NEW: Drop Python 3.9 support.
+  * NEW: Lazy compile selector patterns to improve initial import speed.
+  * FIX: Correct :nth-child/:nth-of-type (and -last- variants) for An+B values
+    whose sequence steps onto index 0 or onto the last child
+    (e.g. :nth-child(2n-2), :nth-child(n-1), :nth-child(n+5)), which
+    previously matched the wrong elements or nothing at all.
+  * FIX: More efficient CSS ID matching.
+  * FIX: Fix inefficient trimming of comments and white space.
+
+-------------------------------------------------------------------

Old:
----
  soupsieve-2.8.4.tar.gz

New:
----
  soupsieve-2.9.2.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-soupsieve.spec ++++++
--- /var/tmp/diff_new_pack.SEfQWL/_old  2026-09-23 14:33:36.258960161 +0200
+++ /var/tmp/diff_new_pack.SEfQWL/_new  2026-09-23 14:33:36.260960245 +0200
@@ -26,7 +26,7 @@
 %endif
 %{?sle15_python_module_pythons}
 Name:           python-soupsieve%{psuffix}
-Version:        2.8.4
+Version:        2.9.2
 Release:        0
 Summary:        A modern CSS selector implementation for BeautifulSoup
 License:        MIT

++++++ soupsieve-2.8.4.tar.gz -> soupsieve-2.9.2.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/PKG-INFO new/soupsieve-2.9.2/PKG-INFO
--- old/soupsieve-2.8.4/PKG-INFO        2020-02-02 01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/PKG-INFO        2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: soupsieve
-Version: 2.8.4
+Version: 2.9.2
 Summary: A modern CSS selector implementation for Beautiful Soup.
 Project-URL: Homepage, https://github.com/facelessuser/soupsieve
 Author-email: Isaac Muse <[email protected]>
@@ -13,7 +13,6 @@
 Classifier: License :: OSI Approved :: MIT License
 Classifier: Operating System :: OS Independent
 Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.9
 Classifier: Programming Language :: Python :: 3.10
 Classifier: Programming Language :: Python :: 3.11
 Classifier: Programming Language :: Python :: 3.12
@@ -22,7 +21,7 @@
 Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
 Classifier: Topic :: Software Development :: Libraries :: Python Modules
 Classifier: Typing :: Typed
-Requires-Python: >=3.9
+Requires-Python: >=3.10
 Description-Content-Type: text/markdown
 
 [![Donate via PayPal][donate-image]][donate-link]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/docs/src/markdown/about/changelog.md 
new/soupsieve-2.9.2/docs/src/markdown/about/changelog.md
--- old/soupsieve-2.8.4/docs/src/markdown/about/changelog.md    2020-02-02 
01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/docs/src/markdown/about/changelog.md    2020-02-02 
01:00:00.000000000 +0100
@@ -3,6 +3,30 @@
 ---
 # Changelog
 
+## 2.9.2
+
+-   **FIX**: Fix issue where `:is()` and `:where()` were not accounting for 
empty selectors in the max selector count as
+    they should (@arpitjain099).
+-   **FIX**: Fix issue where `:has()` was allowing empty selectors in some 
circumstances even though it is not
+    forgiving.
+-   **FIX**: Reduce selector object size when `:is()` and `:where()` contain 
empty selectors.
+
+## 2.9.1
+
+-   **FIX**: Correct `[attr^=""]`, `[attr$=""]`, and `[attr*=""]` to match 
nothing when the value is empty, per CSS
+    Selectors Level 4 substring matching, which previously matched any element 
merely having the attribute
+    (@chuenchen309).
+
+## 2.9
+
+-   **NEW**: Drop Python 3.9 support.
+-   **NEW**: Lazy compile selector patterns to improve initial import speed.
+-   **FIX**: Correct `:nth-child`/`:nth-of-type` (and `-last-` variants) for 
`An+B` values whose sequence steps onto
+    index 0 or onto the last child (e.g. `:nth-child(2n-2)`, 
`:nth-child(n-1)`, `:nth-child(n+5)`), which previously
+    matched the wrong elements or nothing at all (@gaoflow).
+-   **FIX**: More efficient CSS ID matching (@kaimandalic).
+-   **FIX**: Fix inefficient trimming of comments and white space 
(@kaimandalic).
+
 ## 2.8.4
 
 -   **FIX**: Fix another inefficient attribute pattern (@mauriceng98).
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/docs/src/markdown/faq.md 
new/soupsieve-2.9.2/docs/src/markdown/faq.md
--- old/soupsieve-2.8.4/docs/src/markdown/faq.md        2020-02-02 
01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/docs/src/markdown/faq.md        2020-02-02 
01:00:00.000000000 +0100
@@ -32,7 +32,7 @@
 pseudo-classes behaved in unexpected ways without awareness to `iframes`, this 
was fixed in 1.9.1. Pseudo-classes such
 as [`:default`](./selectors/pseudo-classes.md#:default), 
[`:indeterminate`](./selectors/pseudo-classes.md#:indeterminate),
 [`:dir()`](./selectors/pseudo-classes.md#:dir), 
[`:lang()`](./selectors/pseudo-classes.md#:lang),
-[`:root`](./selectors/pseudo-classes.md#:root), and 
[`:contains()`](./selectors/pseudo-classes.md#:contains) were
+[`:root`](./selectors/pseudo-classes.md#:root), and 
[`:contains()`](./selectors/pseudo-classes.md#:-soup-contains) were
 given awareness of `iframes` to ensure they behaved properly and returned the 
expected elements. This doesn't mean that
 `select` won't return elements in `iframes`, but it won't allow something like 
`:default` to select a `button` in an
 `iframe` whose parent `form` is outside the `iframe`. Or better put, a default 
`button` will be evaluated in the context
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/docs/src/markdown/selectors/index.md 
new/soupsieve-2.9.2/docs/src/markdown/selectors/index.md
--- old/soupsieve-2.8.4/docs/src/markdown/selectors/index.md    2020-02-02 
01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/docs/src/markdown/selectors/index.md    2020-02-02 
01:00:00.000000000 +0100
@@ -96,20 +96,21 @@
 
 ### Simple Selector
 
-A simple selector represents a single condition on an element. It can be a 
[type selector](#type-selectors),
-[universal selector](#universal-selectors), [ID selector](#id-selectors), 
[class selector](#class-selectors),
-[attribute selector](#attribute-selectors), or [pseudo class 
selector](#pseudo-classes).
+A simple selector represents a single condition on an element. It can be a 
[type selector](./basic.md#type-selectors),
+[universal selector](./basic.md#universal-selectors), [ID 
selector](./basic.md#id-selectors),
+[class selector](./basic.md#class-selectors), [attribute 
selector](./basic.md#attribute-selectors), or
+[pseudo class selector](./pseudo-classes.md).
 
 ### Compound Selector
 
 A [compound](#compound-selector) selector is a sequence of 
[simple](#simple-selector) selectors. They do not contain any
-[combinators](#combinators-and-selector-lists). If a universal or type 
selector is used, they must come first, and only
-one instance of either a universal or type selector can be used, both cannot 
be used at the same time.
+[combinators](./combinators.md). If a universal or type selector is used, they 
must come first, and only one instance of
+either a universal or type selector can be used, both cannot be used at the 
same time.
 
 ### Complex Selector
 
 A complex selector consists of multiple [simple](#simple-selector) or 
[compound](#compound-selector) selectors joined
-with [combinators](#combinators-and-selector-lists).
+with [combinators](./combinators.md).
 
 ### Selector List
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/soupsieve-2.8.4/docs/src/markdown/selectors/pseudo-classes.md 
new/soupsieve-2.9.2/docs/src/markdown/selectors/pseudo-classes.md
--- old/soupsieve-2.8.4/docs/src/markdown/selectors/pseudo-classes.md   
2020-02-02 01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/docs/src/markdown/selectors/pseudo-classes.md   
2020-02-02 01:00:00.000000000 +0100
@@ -4,9 +4,9 @@
 
 These are pseudo classes that are either fully or partially supported. Partial 
support is usually due to limitations of
 not being in a live, browser environment. Pseudo classes that cannot be 
implemented are found under
-[Non-Applicable Pseudo 
Classes](./unsupported.md/#non-applicable-pseudo-classes). Any selectors that 
are not found here or under the
-non-applicable either are under consideration, have not yet been evaluated, or 
are too new and viewed as a risk to
-implement as they might not stick around.
+[Non-Applicable Pseudo Classes](./unsupported.md). Any selectors that are not 
found here or under the non-applicable
+either are under consideration, have not yet been evaluated, or are too new 
and viewed as a risk to implement as they
+might not stick around.
 
 ## `:any-link`:material-language-html5:{: title="HTML" 
data-md-color-primary="orange" .icon} {:#:any-link}
 
@@ -417,8 +417,8 @@
 Selects an element if any of the relative selectors passed as parameters 
(which are relative to the `:scope` of the
 given element), match at least one element.
 
-While the level 4 specifications state that [compound](#compound-selector) 
selectors are supported, complex selectors
-are planned for level 5 CSS selectors. Soup Sieve supports 
[complex](#complex-selector) selectors.
+While the level 4 specifications state that 
[compound](./index.md#compound-selector) selectors are supported, complex
+selectors are planned for level 5 CSS selectors. Soup Sieve supports 
[complex](./index.md#complex-selector) selectors.
 
 In addition to supporting complex selectors, Soup Sieve also supports nested 
`:has()` which has been excluded from the
 level 4 specifications to help encourage browsers to implement `:has()`. This 
exclusion helps to reduces complexity and
@@ -557,9 +557,9 @@
 The alias `:matches()` is also supported as it was the original name for the 
selector, and some browsers support it.
 It is strongly encouraged to use `:is()` instead as support for `:matches()` 
may be dropped in the future.
 
-While the level 4 specifications state that [compound](#compound-selector) 
selectors are supported, some browsers
-(Safari) support complex selectors which are planned for level 5 CSS 
selectors. Soup Sieve also supports
-[complex](#complex-selector) selectors.
+While the level 4 specifications state that 
[compound](./index.md#compound-selector) selectors are supported, some
+browsers (Safari) support complex selectors which are planned for level 5 CSS 
selectors. Soup Sieve also supports
+[complex](./index.md#complex-selector) selectors.
 
 /// tab | Syntax
 ```css
@@ -880,8 +880,9 @@
 Level 4+ CSS:material-flask:{: title="Experimental" 
data-md-color-primary="purple" .icon}
 
 -   Selects all elements that do not match any of the selectors in the 
selector list. While the level 4 specifications
-    state that [compound](#compound-selector) selectors are supported, some 
browsers (Safari) support complex selectors
-    which are planned for level 5 CSS selectors. Soup Sieve also supports 
[complex](#complex-selector) selectors.
+    state that [compound](./index.md#compound-selector) selectors are 
supported, some browsers (Safari) support complex
+    selectors which are planned for level 5 CSS selectors. Soup Sieve also 
supports
+    [complex](./index.md#complex-selector) selectors.
 
     /// tab | Syntax
     ```css
@@ -977,10 +978,11 @@
 
     Essentially, `#!css img:nth-of-type(2)` would be equivalent to `#!css 
:nth-child(2 of img)`. The advantage of using
     `:nth-child(an+b [of S]?)` over `:nth-of-type` is that `:nth-of-type` is 
restricted to types, while
-    `:nth-child(an+b [of S]?)` can use [complex](#complex-selector) selectors.
+    `:nth-child(an+b [of S]?)` can use [complex](./index.md#complex-selector) 
selectors.
 
-    While the level 4 specifications state that [compound](#compound-selector) 
selectors are supported, complex
-    selectors are planned for level 5 CSS selectors. Soup Sieve supports 
[complex](#complex-selector) selectors.
+    While the level 4 specifications state that 
[compound](./index.md#compound-selector) selectors are supported,
+    complex selectors are planned for level 5 CSS selectors. Soup Sieve 
supports [complex](./index.md#complex-selector)
+    selectors.
 
     /// tab | Syntax
     ```css
@@ -1080,10 +1082,11 @@
 
     Essentially, `#!css img:nth-last-of-type(2)` would be equivalent to `#!css 
:nth-last-child(2 of img)`. The advantage
     of using `:nth-last-child(an+b [of S]?)` over `:nth-last-of-type` is that 
`:nth-last-of-type` is restricted to
-    types, while `:nth-last-child(an+b [of S]?)` can use 
[complex](#complex-selector) selectors.
+    types, while `:nth-last-child(an+b [of S]?)` can use 
[complex](./index.md#complex-selector) selectors.
 
-    While the level 4 specifications state that [compound](#compound-selector) 
selectors are supported, complex
-    selectors are planned for level 5 CSS selectors. Soup Sieve supports 
[complex](#complex-selector) selectors.
+    While the level 4 specifications state that 
[compound](./index.md#compound-selector) selectors are supported,
+    complex selectors are planned for level 5 CSS selectors. Soup Sieve 
supports [complex](./index.md#complex-selector)
+    selectors.
 
     /// tab | Syntax
     ```css
@@ -1671,9 +1674,9 @@
 used to see which applies. Beautiful Soup and Soup Sieve don't care about 
specificity so `:where()` is essentially just
 an alias for `:is()`.
 
-While the level 4 specifications state that [compound](#compound-selector) 
selectors are supported, some browsers
-(Safari) support complex selectors which are planned for level 5 CSS 
selectors. Soup Sieve also supports
-[complex](#complex-selector) selectors.
+While the level 4 specifications state that 
[compound](./index.md#compound-selector) selectors are supported, some
+browsers (Safari) support complex selectors which are planned for level 5 CSS 
selectors. Soup Sieve also supports
+[complex](./index.md#complex-selector) selectors.
 
 /// tab | Syntax
 ```css
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/hatch_build.py 
new/soupsieve-2.9.2/hatch_build.py
--- old/soupsieve-2.8.4/hatch_build.py  2020-02-02 01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/hatch_build.py  2020-02-02 01:00:00.000000000 +0100
@@ -29,7 +29,6 @@
             'License :: OSI Approved :: MIT License',
             'Operating System :: OS Independent',
             'Programming Language :: Python :: 3',
-            'Programming Language :: Python :: 3.9',
             'Programming Language :: Python :: 3.10',
             'Programming Language :: Python :: 3.11',
             'Programming Language :: Python :: 3.12',
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/pyproject.toml 
new/soupsieve-2.9.2/pyproject.toml
--- old/soupsieve-2.8.4/pyproject.toml  2020-02-02 01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/pyproject.toml  2020-02-02 01:00:00.000000000 +0100
@@ -9,7 +9,7 @@
 description = "A modern CSS selector implementation for Beautiful Soup."
 readme = "README.md"
 license = "MIT"
-requires-python = ">=3.9"
+requires-python = ">=3.10"
 authors = [
     { name = "Isaac Muse", email = "[email protected]" },
 ]
@@ -109,7 +109,7 @@
 [tox]
 isolated_build = true
 envlist =
-    py{39,310,311,312,313,314},
+    py{310,311,312,313,314},
     lint, nolxml, nohtml5lib
 
 [testenv]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/soupsieve/__meta__.py 
new/soupsieve-2.9.2/soupsieve/__meta__.py
--- old/soupsieve-2.8.4/soupsieve/__meta__.py   2020-02-02 01:00:00.000000000 
+0100
+++ new/soupsieve-2.9.2/soupsieve/__meta__.py   2020-02-02 01:00:00.000000000 
+0100
@@ -193,5 +193,5 @@
     return Version(major, minor, micro, release, pre, post, dev)
 
 
-__version_info__ = Version(2, 8, 4, "final")
+__version_info__ = Version(2, 9, 2, "final")
 __version__ = __version_info__._get_canonical()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/soupsieve/css_match.py 
new/soupsieve-2.9.2/soupsieve/css_match.py
--- old/soupsieve-2.8.4/soupsieve/css_match.py  2020-02-02 01:00:00.000000000 
+0100
+++ new/soupsieve-2.9.2/soupsieve/css_match.py  2020-02-02 01:00:00.000000000 
+0100
@@ -995,44 +995,18 @@
 
             # We can only adjust bounds within a variable index
             if var:
-                # Abort if our nth index is out of bounds and only getting 
further out of bounds as we increment.
-                # Otherwise, increment to try to get in bounds.
-                adjust = None
-                while idx < 1 or idx > last_index:
-                    if idx < 0:
-                        diff_low = 0 - idx
-                        if adjust is not None and adjust == 1:
-                            break
-                        adjust = -1
-                        count += count_incr
-                        idx = last_idx = a * count + b if var else a
-                        diff = 0 - idx
-                        if diff >= diff_low:
-                            break
-                    else:
-                        diff_high = idx - last_index
-                        if adjust is not None and adjust == -1:
-                            break
-                        adjust = 1
-                        count += count_incr
-                        idx = last_idx = a * count + b if var else a
-                        diff = idx - last_index
-                        if diff >= diff_high:
-                            break
-                        diff_high = diff
-
-                # If a < 0, our count is working backwards, so floor the index 
by increasing the count.
-                # Find the count that yields the lowest, in bound value and 
use that.
-                # Lastly reverse count increment so that we'll increase our 
index.
-                lowest = count
-                if a < 0:
-                    while idx >= 1:
-                        lowest = count
-                        count += count_incr
-                        idx = last_idx = a * count + b if var else a
+                # Find the count `n` that yields the smallest in-bounds index
+                # (>= 1), then set the increment direction so that the index
+                # ascends from there as the evaluation loop below walks 
children.
+                if a > 0:
+                    # Ascending sequence: smallest n with a * n + b >= 1.
+                    count = 0 if b >= 1 else -(-(1 - b) // a)
+                elif a < 0:
+                    # Descending sequence: largest n with a * n + b >= 1, then
+                    # walk n back down so the index increases.
+                    count = (b - 1) // -a if b >= 1 else 0
                     count_incr = -1
-                count = lowest
-                idx = last_idx = a * count + b if var else a
+                idx = last_idx = a * count + b
 
             # Evaluate elements while our calculated nth index is still in 
range
             while 1 <= idx <= last_index + 1:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/soupsieve/css_parser.py 
new/soupsieve-2.9.2/soupsieve/css_parser.py
--- old/soupsieve-2.8.4/soupsieve/css_parser.py 2020-02-02 01:00:00.000000000 
+0100
+++ new/soupsieve-2.9.2/soupsieve/css_parser.py 2020-02-02 01:00:00.000000000 
+0100
@@ -8,6 +8,12 @@
 from .util import SelectorSyntaxError
 import warnings
 from typing import Match, Any, Iterator, cast
+from dataclasses import dataclass
+from collections import UserDict
+import threading
+
+RE_LOCK = threading.Lock()
+SEL_LOCK = threading.RLock()
 
 UNICODE_REPLACEMENT_CHAR = 0xFFFD
 
@@ -106,7 +112,7 @@
 NEWLINE = r'(?:\r\n|(?!\r\n)[\n\f\r])'
 WS = fr'(?:[ \t]|{NEWLINE})'
 # Comments
-COMMENTS = r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)'
+COMMENTS = r'(?:/\*(?:[^*]|\*(?!/))*\*/)'
 # Whitespace with comments included
 WSC = fr'(?:{WS}|{COMMENTS})'
 # CSS escapes
@@ -114,7 +120,7 @@
 CSS_STRING_ESCAPES = fr'(?:\\(?:[a-f0-9]{{1,6}}{WS}?|[^\r\n\f]|$|{NEWLINE}))'
 # CSS Identifier
 IDENTIFIER = fr'''
-(?:(?:-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]|{CSS_ESCAPES})+|--)
+(?:(?:--|-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]|{CSS_ESCAPES}))
 (?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]|{CSS_ESCAPES})*)
 '''
 # `nth` content
@@ -177,8 +183,11 @@
 # Whitespace checks
 RE_WS = re.compile(WS)
 RE_WS_BEGIN = re.compile(fr'^{WSC}*')
-RE_WS_END = re.compile(fr'{WSC}*$')
+RE_WS_END = re.compile(fr'^(?:[ \t]|(?:\n\r|(?!\n\r)[\n\f\r])|{COMMENTS})*')
 RE_CUSTOM = re.compile(fr'^{PAT_PSEUDO_CLASS_CUSTOM}$', re.X)
+RE_PSEUDO_CLASS_SPECIAL = re.compile(PAT_PSEUDO_CLASS_SPECIAL, re.I | re.X | 
re.U)
+
+QUOTED = ("'", '"')
 
 # Constants
 # List split token
@@ -247,7 +256,7 @@
     return custom_selectors
 
 
-def css_unescape(content: str, string: bool = False) -> str:
+def css_unescape(content: str) -> str:
     """
     Unescape CSS value.
 
@@ -271,7 +280,11 @@
 
         return value
 
-    return (RE_CSS_ESC if not string else RE_CSS_STR_ESC).sub(replace, content)
+    quoted = content.startswith(QUOTED)
+    return (RE_CSS_STR_ESC if quoted else RE_CSS_ESC).sub(
+        replace,
+        content[1:-1] if quoted else content
+    )
 
 
 def escape(ident: str) -> str:
@@ -309,7 +322,17 @@
         """Initialize."""
 
         self.name = name
-        self.re_pattern = re.compile(pattern, re.I | re.X | re.U)
+        self.pattern = pattern
+        self._re_pattern: re.Pattern[str] | None = None
+
+    @property
+    def re_pattern(self) -> re.Pattern[str]:
+        """Retrieve the compiled regular expression pattern."""
+
+        with RE_LOCK:
+            if self._re_pattern is None:
+                self._re_pattern = re.compile(self.pattern, re.I | re.X | re.U)
+        return self._re_pattern
 
     def get_name(self) -> str:
         """Get name."""
@@ -336,7 +359,6 @@
                 self.patterns[pseudo] = pattern
 
         self.matched_name = None  # type: SelectorPattern | None
-        self.re_pseudo_name = re.compile(PAT_PSEUDO_CLASS_SPECIAL, re.I | re.X 
| re.U)
 
     def get_name(self) -> str:
         """Get name."""
@@ -347,7 +369,7 @@
         """Match the selector."""
 
         pseudo = None
-        m = self.re_pseudo_name.match(selector, index)
+        m = RE_PSEUDO_CLASS_SPECIAL.match(selector, index)
         if m:
             name = util.lower(css_unescape(m.group('name')))
             pattern = self.patterns.get(name)
@@ -427,10 +449,187 @@
     __repr__ = __str__
 
 
+@dataclass
+class CSSPattern:
+    """A CSS pattern that hasn't been processed by `CSSParser` yet."""
+
+    selector: str
+    flags: int
+
+
+class PseudoSelectorMap(UserDict[str, CSSPattern | ct.SelectorList]):
+    """Pseudo selector map."""
+
+    def __setitem__(self, key: str, value: CSSPattern | ct.SelectorList) -> 
None:
+        """Set item."""
+
+        self.data[key] = value
+
+    def __getitem__(self, key: str) -> ct.SelectorList:
+        """Get item."""
+
+        with SEL_LOCK:
+            value = self.data[key]
+            if isinstance(value, CSSPattern):
+                value = 
CSSParser(value.selector).process_selectors(flags=value.flags)
+                self.data[key] = value
+
+        return value
+
+
+# CSS pattern for `:link` and `:any-link`
+CSS_LINK = CSSPattern('html|*:is(a, area)[href]', FLG_PSEUDO | FLG_HTML)
+# CSS pattern for `:checked`
+CSS_CHECKED = CSSPattern(
+    '''
+    html|*:is(input[type=checkbox], input[type=radio])[checked], 
html|option[selected]
+    ''',
+    FLG_PSEUDO | FLG_HTML
+)
+# CSS pattern for `:default` (must compile CSS_CHECKED first)
+CSS_DEFAULT = CSSPattern(
+    '''
+    :checked,
+
+    /*
+    This pattern must be at the end.
+    Special logic is applied to the last selector.
+    */
+    html|form html|*:is(button, input)[type="submit"]
+    ''',
+    FLG_PSEUDO | FLG_HTML | FLG_DEFAULT
+)
+# CSS pattern for `:indeterminate`
+CSS_INDETERMINATE = CSSPattern(
+    '''
+    html|input[type="checkbox"][indeterminate],
+    html|input[type="radio"]:is(:not([name]), [name=""]):not([checked]),
+    html|progress:not([value]),
+
+    /*
+    This pattern must be at the end.
+    Special logic is applied to the last selector.
+    */
+    html|input[type="radio"][name]:not([name='']):not([checked])
+    ''',
+    FLG_PSEUDO | FLG_HTML | FLG_INDETERMINATE
+)
+# CSS pattern for `:disabled`
+CSS_DISABLED = CSSPattern(
+    '''
+    html|*:is(input:not([type=hidden]), button, select, textarea, fieldset, 
optgroup, option, fieldset)[disabled],
+    html|optgroup[disabled] > html|option,
+    html|fieldset[disabled] > html|*:is(input:not([type=hidden]), button, 
select, textarea, fieldset),
+    html|fieldset[disabled] >
+        html|*:not(legend:nth-of-type(1)) html|*:is(input:not([type=hidden]), 
button, select, textarea, fieldset)
+    ''',
+    FLG_PSEUDO | FLG_HTML
+)
+# CSS pattern for `:enabled`
+CSS_ENABLED = CSSPattern(
+    '''
+    html|*:is(input:not([type=hidden]), button, select, textarea, fieldset, 
optgroup, option, fieldset):not(:disabled)
+    ''',
+    FLG_PSEUDO | FLG_HTML
+)
+# CSS pattern for `:required`
+CSS_REQUIRED = CSSPattern('html|*:is(input, textarea, select)[required]', 
FLG_PSEUDO | FLG_HTML)
+# CSS pattern for `:optional`
+CSS_OPTIONAL = CSSPattern('html|*:is(input, textarea, 
select):not([required])', FLG_PSEUDO | FLG_HTML)
+# CSS pattern for `:placeholder-shown`
+CSS_PLACEHOLDER_SHOWN = CSSPattern(
+    '''
+    html|input:is(
+        :not([type]),
+        [type=""],
+        [type=text],
+        [type=search],
+        [type=url],
+        [type=tel],
+        [type=email],
+        [type=password],
+        [type=number]
+    )[placeholder]:not([placeholder='']):is(:not([value]), [value=""]),
+    html|textarea[placeholder]:not([placeholder=''])
+    ''',
+    FLG_PSEUDO | FLG_HTML | FLG_PLACEHOLDER_SHOWN
+)
+# CSS pattern for `:read-write` (CSS_DISABLED must be compiled first)
+CSS_READ_WRITE = CSSPattern(
+    '''
+    html|*:is(
+        textarea,
+        input:is(
+            :not([type]),
+            [type=""],
+            [type=text],
+            [type=search],
+            [type=url],
+            [type=tel],
+            [type=email],
+            [type=number],
+            [type=password],
+            [type=date],
+            [type=datetime-local],
+            [type=month],
+            [type=time],
+            [type=week]
+        )
+    ):not([readonly], :disabled),
+    html|*:is([contenteditable=""], [contenteditable="true" i])
+    ''',
+    FLG_PSEUDO | FLG_HTML
+)
+# CSS pattern for `:read-only`
+CSS_READ_ONLY = CSSPattern('html|*:not(:read-write)', FLG_PSEUDO | FLG_HTML)
+# CSS pattern for `:in-range`
+CSS_IN_RANGE = CSSPattern(
+    '''
+    html|input:is(
+        [type="date"],
+        [type="month"],
+        [type="week"],
+        [type="time"],
+        [type="datetime-local"],
+        [type="number"],
+        [type="range"]
+    ):is(
+        [min],
+        [max]
+    )
+    ''',
+    FLG_PSEUDO | FLG_HTML | FLG_IN_RANGE
+)
+# CSS pattern for `:out-of-range`
+CSS_OUT_OF_RANGE = CSSPattern(
+    '''
+    html|input:is(
+        [type="date"],
+        [type="month"],
+        [type="week"],
+        [type="time"],
+        [type="datetime-local"],
+        [type="number"],
+        [type="range"]
+    ):is(
+        [min],
+        [max]
+    )
+    ''',
+    FLG_PSEUDO | FLG_HTML | FLG_OUT_OF_RANGE
+)
+# CSS pattern for :open
+CSS_OPEN = CSSPattern('html|*:is(details, dialog)[open]', FLG_PSEUDO | 
FLG_HTML)
+# CSS pattern for :muted
+CSS_MUTED = CSSPattern('html|*:is(video, audio)[muted]', FLG_PSEUDO | FLG_HTML)
+# CSS pattern default for `:nth-child` "of S" feature
+CSS_NTH_OF_S_DEFAULT = CSSPattern("*|*", FLG_PSEUDO)
+
+
 class CSSParser:
     """Parse CSS selectors."""
 
-    css_tokens = (
+    CSS_TOKENS = (
         SelectorPattern("pseudo_close", PAT_PSEUDO_CLOSE),
         SpecialPseudoPattern(
             (
@@ -458,6 +657,29 @@
         SelectorPattern("combine", PAT_COMBINE)
     )
 
+    # Pseudos that expand to selectors
+    PSEUDO_SELECTORS = PseudoSelectorMap(
+        {
+            ':link': CSS_LINK,
+            ':any-link': CSS_LINK,
+            ':checked': CSS_CHECKED,
+            ':default': CSS_DEFAULT,
+            ':indeterminate': CSS_INDETERMINATE,
+            ':disabled': CSS_DISABLED,
+            ':enabled': CSS_ENABLED,
+            ':required': CSS_REQUIRED,
+            ':muted': CSS_MUTED,
+            ':open': CSS_OPEN,
+            ':optional': CSS_OPTIONAL,
+            ':read-only': CSS_READ_ONLY,
+            ':read-write': CSS_READ_WRITE,
+            ':in-range': CSS_IN_RANGE,
+            ':out-of-range': CSS_OUT_OF_RANGE,
+            ':placeholder-shown': CSS_PLACEHOLDER_SHOWN,
+            '<nth-of-s>': CSS_NTH_OF_S_DEFAULT
+        }
+    )
+
     def __init__(
         self,
         selector: str,
@@ -472,9 +694,10 @@
         self.custom = {} if custom is None else custom
         self.count = 0
 
-    def check_count(self) -> None:
+    def increment_count(self, increment: int = 1) -> None:
         """Check the current selector count."""
 
+        self.count += increment
         if self.count > SELECTOR_LIMIT:
             raise ValueError(f'Selector exceeds pseudo-class nesting limit of 
{SELECTOR_LIMIT}')
 
@@ -499,28 +722,30 @@
             flags = re.DOTALL
 
         if op:
-            if m.group('value').startswith(('"', "'")):
-                value = css_unescape(m.group('value')[1:-1], True)
-            else:
-                value = css_unescape(m.group('value'))
+            value = css_unescape(m.group('value'))
 
         if not op:
             # Attribute name
             pattern = None
         elif op.startswith('^'):
             # Value start with
-            pattern = re.compile(r'^%s.*' % re.escape(value), flags)
+            # `^=` should match nothing if the value is empty, so use `(?!)` 
which cannot be matched.
+            value = r'(?!)' if not value else re.escape(value)
+            pattern = re.compile(r'^%s.*' % value, flags)
         elif op.startswith('$'):
             # Value ends with
-            pattern = re.compile(r'.*?%s$' % re.escape(value), flags)
+            # `$=` should match nothing if the value is empty, so use `(?!)` 
which cannot be matched.
+            value = r'(?!)' if not value else re.escape(value)
+            pattern = re.compile(r'.*?%s$' % value, flags)
         elif op.startswith('*'):
             # Value contains
-            pattern = re.compile(r'.*?%s.*' % re.escape(value), flags)
+            # `*=` should match nothing if the value is empty, so use `(?!)` 
which cannot be matched.
+            value = r'(?!)' if not value else re.escape(value)
+            pattern = re.compile(r'.*?%s.*' % value, flags)
         elif op.startswith('~'):
             # Value contains word within space separated list
-            # `~=` should match nothing if it is empty or contains whitespace,
-            # so if either of these cases is present, use `[^\s\S]` which 
cannot be matched.
-            value = r'[^\s\S]' if not value or RE_WS.search(value) else 
re.escape(value)
+            # `*~` should match nothing if the value is empty, so use `(?!)` 
which cannot be matched.
+            value = r'(?!)' if not value or RE_WS.search(value) else 
re.escape(value)
             pattern = re.compile(r'.*?(?:(?<=^)|(?<=[ \t\r\n\f]))%s(?=(?:[ 
\t\r\n\f]|$)).*' % value, flags)
         elif op.startswith('|'):
             # Value starts with word in dash separated list
@@ -581,9 +806,7 @@
             ).process_selectors(flags=FLG_PSEUDO)
             self.custom[pseudo] = selector
 
-        self.count += selector.count
-        self.check_count()
-
+        self.increment_count(selector.count)
         sel.selectors.append(selector)
         has_selector = True
         return has_selector
@@ -614,66 +837,10 @@
                 sel.flags |= ct.SEL_SCOPE
             elif pseudo == ':empty':
                 sel.flags |= ct.SEL_EMPTY
-            elif pseudo in (':link', ':any-link'):
-                self.count += CSS_LINK.count
-                self.check_count()
-                sel.selectors.append(CSS_LINK)
-            elif pseudo == ':checked':
-                self.count += CSS_CHECKED.count
-                self.check_count()
-                sel.selectors.append(CSS_CHECKED)
-            elif pseudo == ':default':
-                self.count += CSS_DEFAULT.count
-                self.check_count()
-                sel.selectors.append(CSS_DEFAULT)
-            elif pseudo == ':indeterminate':
-                self.count += CSS_INDETERMINATE.count
-                self.check_count()
-                sel.selectors.append(CSS_INDETERMINATE)
-            elif pseudo == ":disabled":
-                self.count += CSS_DISABLED.count
-                self.check_count()
-                sel.selectors.append(CSS_DISABLED)
-            elif pseudo == ":enabled":
-                self.count += CSS_ENABLED.count
-                self.check_count()
-                sel.selectors.append(CSS_ENABLED)
-            elif pseudo == ":required":
-                self.count += CSS_REQUIRED.count
-                self.check_count()
-                sel.selectors.append(CSS_REQUIRED)
-            elif pseudo == ":muted":
-                self.count += CSS_MUTED.count
-                self.check_count()
-                sel.selectors.append(CSS_MUTED)
-            elif pseudo == ":open":
-                self.count += CSS_OPEN.count
-                self.check_count()
-                sel.selectors.append(CSS_OPEN)
-            elif pseudo == ":optional":
-                self.count += CSS_OPTIONAL.count
-                self.check_count()
-                sel.selectors.append(CSS_OPTIONAL)
-            elif pseudo == ":read-only":
-                self.count += CSS_READ_ONLY.count
-                self.check_count()
-                sel.selectors.append(CSS_READ_ONLY)
-            elif pseudo == ":read-write":
-                self.count += CSS_READ_WRITE.count
-                self.check_count()
-                sel.selectors.append(CSS_READ_WRITE)
-            elif pseudo == ":in-range":
-                self.count += CSS_IN_RANGE.count
-                self.check_count()
-                sel.selectors.append(CSS_IN_RANGE)
-            elif pseudo == ":out-of-range":
-                self.count += CSS_OUT_OF_RANGE.count
-                self.check_count()
-                sel.selectors.append(CSS_OUT_OF_RANGE)
-            elif pseudo == ":placeholder-shown":
-                self.count += CSS_PLACEHOLDER_SHOWN.count
-                self.check_count()
-                sel.selectors.append(CSS_PLACEHOLDER_SHOWN)
+            elif pseudo in self.PSEUDO_SELECTORS:
+                pseudo_selector = self.PSEUDO_SELECTORS[pseudo]
+                self.increment_count(pseudo_selector.count)
+                sel.selectors.append(pseudo_selector)
             elif pseudo == ':first-child':
                 sel.nth.append(ct.SelectorNth(1, False, 0, False, False, 
ct.SelectorList()))
             elif pseudo == ':last-child':
@@ -772,9 +939,8 @@
                 nth_sel = self.parse_selectors(iselector, m.end(0), FLG_PSEUDO 
| FLG_OPEN)
             else:
                 # Use default `*|*` for `of S`.
-                nth_sel = CSS_NTH_OF_S_DEFAULT
-                self.count += nth_sel.count
-                self.check_count()
+                nth_sel = self.PSEUDO_SELECTORS['<nth-of-s>']
+                self.increment_count(nth_sel.count)
             if pseudo_sel == ':nth-child':
                 sel.nth.append(ct.SelectorNth(s1, var, s2, False, False, 
nth_sel))
             elif pseudo_sel == ':nth-last-child':
@@ -825,6 +991,12 @@
         if not combinator:
             combinator = WS_COMBINATOR
         if combinator == COMMA_COMBINATOR:
+            if not has_selector:
+                raise SelectorSyntaxError(
+                    f"The combinator '{combinator}' at position {index}, must 
have a selector before it",
+                    self.pattern,
+                    index
+                )
             sel.rel_type = rel_type
             selectors[-1].relations.append(sel)
             rel_type = ":" + WS_COMBINATOR
@@ -879,9 +1051,8 @@
 
             # If we are in a forgiving pseudo class, just make the selector a 
"no match"
             if combinator == COMMA_COMBINATOR:
-                sel.no_match = True
+                self.increment_count()
                 del relations[:]
-                selectors.append(sel)
         else:
             if combinator == COMMA_COMBINATOR:
                 if not sel.tag and not is_pseudo:
@@ -922,17 +1093,11 @@
                 FutureWarning
             )
         contains_own = pseudo == ":-soup-contains-own"
-        values = css_unescape(m.group('values'))
-        patterns = []
-        for token in RE_VALUES.finditer(values):
-            if token.group('split'):
-                continue
-            value = token.group('value')
-            if value.startswith(("'", '"')):
-                value = css_unescape(value[1:-1], True)
-            else:
-                value = css_unescape(value)
-            patterns.append(value)
+        patterns = [
+            css_unescape(token.group('value'))
+            for token in RE_VALUES.finditer(m.group('values'))
+            if not token.group('split')
+        ]
         sel.contains.append(ct.SelectorContains(patterns, contains_own))
         has_selector = True
         return has_selector
@@ -940,19 +1105,11 @@
     def parse_pseudo_lang(self, sel: _Selector, m: Match[str], has_selector: 
bool) -> bool:
         """Parse pseudo language."""
 
-        values = m.group('values')
-        patterns = []
-        for token in RE_VALUES.finditer(values):
-            if token.group('split'):
-                continue
-            value = token.group('value')
-            if value.startswith(('"', "'")):
-                value = css_unescape(value[1:-1], True)
-            else:
-                value = css_unescape(value)
-
-            patterns.append(value)
-
+        patterns = [
+            css_unescape(token.group('value'))
+            for token in RE_VALUES.finditer(m.group('values'))
+            if not token.group('split')
+        ]
         sel.lang.append(ct.SelectorLang(patterns))
         has_selector = True
 
@@ -1030,8 +1187,7 @@
                 key, m = next(iselector)
 
                 if key not in ('combine', 'pseudo_close'):
-                    self.count += 1
-                    self.check_count()
+                    self.increment_count()
 
                 # Handle parts
                 if key == "at_rule":
@@ -1123,6 +1279,7 @@
         # Forgive empty slots in pseudo-classes that have lists (and are 
forgiving)
         elif is_forgive and (not selectors or not relations):
             # Handle normal pseudo-classes with empty slots like `:is()` etc.
+            self.increment_count()
             sel.no_match = True
             del relations[:]
             selectors.append(sel)
@@ -1160,14 +1317,15 @@
         # Ignore whitespace and comments at start and end of pattern
         m = RE_WS_BEGIN.search(pattern)
         index = m.end(0) if m else 0
-        m = RE_WS_END.search(pattern)
-        end = (m.start(0) - 1) if m else (len(pattern) - 1)
+        m = RE_WS_END.search(pattern[::-1])
+        offset = m.end(0) if m else 0
+        end = len(pattern) - (1 + offset)
 
         if self.debug:  # pragma: no cover
             print(f'## PARSING: {pattern!r}')
         while index <= end:
             m = None
-            for v in self.css_tokens:
+            for v in self.CSS_TOKENS:
                 m = v.match(pattern, index, self.flags)
                 if m:
                     name = v.get_name()
@@ -1199,169 +1357,3 @@
         """Process selectors."""
 
         return self.parse_selectors(self.selector_iter(self.pattern), index, 
flags)
-
-
-# Precompile CSS selector lists for pseudo-classes (additional logic may be 
required beyond the pattern)
-# A few patterns are order dependent as they use patterns previous compiled.
-
-# CSS pattern for `:link` and `:any-link`
-CSS_LINK = CSSParser(
-    'html|*:is(a, area)[href]'
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
-# CSS pattern for `:checked`
-CSS_CHECKED = CSSParser(
-    '''
-    html|*:is(input[type=checkbox], input[type=radio])[checked], 
html|option[selected]
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
-# CSS pattern for `:default` (must compile CSS_CHECKED first)
-CSS_DEFAULT = CSSParser(
-    '''
-    :checked,
-
-    /*
-    This pattern must be at the end.
-    Special logic is applied to the last selector.
-    */
-    html|form html|*:is(button, input)[type="submit"]
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML | FLG_DEFAULT)
-# CSS pattern for `:indeterminate`
-CSS_INDETERMINATE = CSSParser(
-    '''
-    html|input[type="checkbox"][indeterminate],
-    html|input[type="radio"]:is(:not([name]), [name=""]):not([checked]),
-    html|progress:not([value]),
-
-    /*
-    This pattern must be at the end.
-    Special logic is applied to the last selector.
-    */
-    html|input[type="radio"][name]:not([name='']):not([checked])
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML | FLG_INDETERMINATE)
-# CSS pattern for `:disabled`
-CSS_DISABLED = CSSParser(
-    '''
-    html|*:is(input:not([type=hidden]), button, select, textarea, fieldset, 
optgroup, option, fieldset)[disabled],
-    html|optgroup[disabled] > html|option,
-    html|fieldset[disabled] > html|*:is(input:not([type=hidden]), button, 
select, textarea, fieldset),
-    html|fieldset[disabled] >
-        html|*:not(legend:nth-of-type(1)) html|*:is(input:not([type=hidden]), 
button, select, textarea, fieldset)
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
-# CSS pattern for `:enabled`
-CSS_ENABLED = CSSParser(
-    '''
-    html|*:is(input:not([type=hidden]), button, select, textarea, fieldset, 
optgroup, option, fieldset):not(:disabled)
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
-# CSS pattern for `:required`
-CSS_REQUIRED = CSSParser(
-    'html|*:is(input, textarea, select)[required]'
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
-# CSS pattern for `:optional`
-CSS_OPTIONAL = CSSParser(
-    'html|*:is(input, textarea, select):not([required])'
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
-# CSS pattern for `:placeholder-shown`
-CSS_PLACEHOLDER_SHOWN = CSSParser(
-    '''
-    html|input:is(
-        :not([type]),
-        [type=""],
-        [type=text],
-        [type=search],
-        [type=url],
-        [type=tel],
-        [type=email],
-        [type=password],
-        [type=number]
-    )[placeholder]:not([placeholder='']):is(:not([value]), [value=""]),
-    html|textarea[placeholder]:not([placeholder=''])
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML | FLG_PLACEHOLDER_SHOWN)
-# CSS pattern default for `:nth-child` "of S" feature
-CSS_NTH_OF_S_DEFAULT = CSSParser(
-    '*|*'
-).process_selectors(flags=FLG_PSEUDO)
-# CSS pattern for `:read-write` (CSS_DISABLED must be compiled first)
-CSS_READ_WRITE = CSSParser(
-    '''
-    html|*:is(
-        textarea,
-        input:is(
-            :not([type]),
-            [type=""],
-            [type=text],
-            [type=search],
-            [type=url],
-            [type=tel],
-            [type=email],
-            [type=number],
-            [type=password],
-            [type=date],
-            [type=datetime-local],
-            [type=month],
-            [type=time],
-            [type=week]
-        )
-    ):not([readonly], :disabled),
-    html|*:is([contenteditable=""], [contenteditable="true" i])
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
-# CSS pattern for `:read-only`
-CSS_READ_ONLY = CSSParser(
-    '''
-    html|*:not(:read-write)
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
-# CSS pattern for `:in-range`
-CSS_IN_RANGE = CSSParser(
-    '''
-    html|input:is(
-        [type="date"],
-        [type="month"],
-        [type="week"],
-        [type="time"],
-        [type="datetime-local"],
-        [type="number"],
-        [type="range"]
-    ):is(
-        [min],
-        [max]
-    )
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_IN_RANGE | FLG_HTML)
-# CSS pattern for `:out-of-range`
-CSS_OUT_OF_RANGE = CSSParser(
-    '''
-    html|input:is(
-        [type="date"],
-        [type="month"],
-        [type="week"],
-        [type="time"],
-        [type="datetime-local"],
-        [type="number"],
-        [type="range"]
-    ):is(
-        [min],
-        [max]
-    )
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_OUT_OF_RANGE | FLG_HTML)
-
-# CSS pattern for :open
-CSS_OPEN = CSSParser(
-    '''
-    html|*:is(details, dialog)[open]
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
-
-
-# CSS pattern for :muted
-CSS_MUTED = CSSParser(
-    '''
-    html|*:is(video, audio)[muted]
-    '''
-).process_selectors(flags=FLG_PSEUDO | FLG_HTML)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/tests/test_api.py 
new/soupsieve-2.9.2/tests/test_api.py
--- old/soupsieve-2.8.4/tests/test_api.py       2020-02-02 01:00:00.000000000 
+0100
+++ new/soupsieve-2.9.2/tests/test_api.py       2020-02-02 01:00:00.000000000 
+0100
@@ -603,6 +603,22 @@
         with self.assertRaises(ValueError):
             sv.compile(selector)
 
+    def test_excessive_group_selectors(self):
+        """Test excessive selectors in `:is()` and `:where()`."""
+
+        count = 10000
+        selector = f':is({"," * count})'
+
+        # Compile the selector
+        with self.assertRaises(ValueError):
+            sv.compile(selector)
+
+        selector = f':where({"," * count})'
+
+        # Compile the selector
+        with self.assertRaises(ValueError):
+            sv.compile(selector)
+
     def test_excessive_custom_selectors(self):
         """Test excessive custom selectors."""
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/tests/test_level3/test_attribute.py 
new/soupsieve-2.9.2/tests/test_level3/test_attribute.py
--- old/soupsieve-2.8.4/tests/test_level3/test_attribute.py     2020-02-02 
01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/tests/test_level3/test_attribute.py     2020-02-02 
01:00:00.000000000 +0100
@@ -61,6 +61,39 @@
             flags=util.HTML
         )
 
+    def test_attribute_begins_cannot_have_empty(self):
+        """Test attribute `^=` will match nothing when value is empty."""
+
+        # An empty value cannot match anything per the spec.
+        self.assert_selector(
+            self.MARKUP,
+            '[class^=""]',
+            [],
+            flags=util.HTML
+        )
+
+    def test_attribute_end_cannot_have_empty(self):
+        """Test attribute `$=` will match nothing when value is empty."""
+
+        # An empty value cannot match anything per the spec.
+        self.assert_selector(
+            self.MARKUP,
+            '[class$=""]',
+            [],
+            flags=util.HTML
+        )
+
+    def test_attribute_contains_cannot_have_empty(self):
+        """Test attribute `*=` will match nothing when value is empty."""
+
+        # An empty value cannot match anything per the spec.
+        self.assert_selector(
+            self.MARKUP,
+            '[class*=""]',
+            [],
+            flags=util.HTML
+        )
+
     def test_attribute_contains_with_newlines(self):
         """Test attribute `*=` will match with new lines."""
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/tests/test_level3/test_nth_child.py 
new/soupsieve-2.9.2/tests/test_level3/test_nth_child.py
--- old/soupsieve-2.8.4/tests/test_level3/test_nth_child.py     2020-02-02 
01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/tests/test_level3/test_nth_child.py     2020-02-02 
01:00:00.000000000 +0100
@@ -244,3 +244,30 @@
         """Test that pseudo class fails with bad parameters (basically it 
doesn't match)."""
 
         self.assert_raises(':nth-child(a)', SelectorSyntaxError)
+
+    def test_nth_child_an_plus_b_boundaries(self):
+        """Test `An+B` forms whose sequence steps onto index 0 or the last 
child."""
+
+        markup = """
+        <body>
+        <i id="0"></i>
+        <i id="1"></i>
+        <i id="2"></i>
+        <i id="3"></i>
+        <i id="4"></i>
+        </body>
+        """
+
+        # `B < 1` sequences whose first valid index is > 1 or is all indices
+        self.assert_selector(markup, "i:nth-child(2n-2)", ['1', '3'], 
flags=util.HTML)
+        self.assert_selector(
+            markup, "i:nth-child(n-1)", ['0', '1', '2', '3', '4'], 
flags=util.HTML
+        )
+        # `B` equal to the child count (index lands on the last child)
+        self.assert_selector(markup, "i:nth-child(n+5)", ['4'], 
flags=util.HTML)
+        self.assert_selector(markup, "i:nth-child(2n+5)", ['4'], 
flags=util.HTML)
+        # same boundaries, counted from the end
+        self.assert_selector(
+            markup, "i:nth-last-child(2n-2)", ['1', '3'], flags=util.HTML
+        )
+        self.assert_selector(markup, "i:nth-last-child(n+5)", ['0'], 
flags=util.HTML)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/tests/test_level4/test_has.py 
new/soupsieve-2.9.2/tests/test_level4/test_has.py
--- old/soupsieve-2.8.4/tests/test_level4/test_has.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/tests/test_level4/test_has.py   2020-02-02 
01:00:00.000000000 +0100
@@ -140,9 +140,12 @@
         )
 
     def test_has_empty(self):
-        """Test has with empty slot due to multiple commas."""
+        """Test has with empty slot due to no selectors."""
 
         self.assert_raises('div:has()', SelectorSyntaxError)
+        self.assert_raises('div:has(,a)', SelectorSyntaxError)
+        self.assert_raises('div:has(a,)', SelectorSyntaxError)
+        self.assert_raises('div:has(a,,a)', SelectorSyntaxError)
 
     def test_invalid_incomplete_has(self):
         """Test `:has()` fails with just a combinator."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/soupsieve-2.8.4/tests/test_performance.py 
new/soupsieve-2.9.2/tests/test_performance.py
--- old/soupsieve-2.8.4/tests/test_performance.py       1970-01-01 
01:00:00.000000000 +0100
+++ new/soupsieve-2.9.2/tests/test_performance.py       2020-02-02 
01:00:00.000000000 +0100
@@ -0,0 +1,61 @@
+"""Test performance cases."""
+import unittest
+import sys
+import signal
+import time
+import soupsieve as sv
+from soupsieve.util import SelectorSyntaxError
+
+
+class Timeout(Exception):
+    """Timeout exception."""
+
+
[email protected](not sys.platform.startswith('win'), "Unix/Linux test")
+class TestPerformance(unittest.TestCase):
+    """Test performance."""
+
+    def assert_performance(self, selector):
+        """Test performance of of specific cases."""
+
+        LIMIT = 5.0
+
+        signal.signal(signal.SIGALRM, lambda *_: (_ for _ in 
()).throw(Timeout()))
+        signal.setitimer(signal.ITIMER_REAL, LIMIT)
+        dt = None
+        t = 0
+        try:
+            t = time.perf_counter()
+            sv.compile(selector)
+            dt = time.perf_counter() - t
+        except SelectorSyntaxError:
+            dt = time.perf_counter() - t
+        except Timeout:
+            pass
+        finally:
+            signal.setitimer(signal.ITIMER_REAL, 0)
+
+        hung = dt is None
+        success = not hung and dt < 1.0
+
+        if not success:
+            print('Selector:', selector)
+            print(f"{'> %.0f s (HANG)' % LIMIT if hung else '%.3f s' % dt}")
+
+        self.assertTrue(success)
+
+    def test_performance_caase(self):
+        """Test performance cases."""
+
+        for n in (1000, 2000, 4000, 8000):
+            self.assert_performance("[a=" + "a" * n)
+
+        for n in (2000, 4000, 8000, 16000):
+            self.assert_performance("a" * n + "!")
+
+        self.assert_performance("[a=" + "a" * 12000)
+
+        for n in (2000, 4000, 8000, 16000):
+            self.assert_performance("a" + " " * n + "b")
+
+        self.assert_performance("a" + " " * 20000 + "b")

Reply via email to