Skip to content
Open
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
81 changes: 53 additions & 28 deletions cssselect/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,9 @@ def parse_selector(stream: TokenStream) -> tuple[Tree, PseudoElement | None]:


def parse_simple_selector(
stream: TokenStream, inside_negation: bool = False
stream: TokenStream,
inside_negation: bool = False,
inside_selector_list: bool = False,
) -> tuple[Tree, PseudoElement | None]:
stream.skip_whitespace()
selector_start = len(stream.used)
Expand Down Expand Up @@ -613,8 +615,12 @@ def parse_simple_selector(
stream.next()
result = Class(result, stream.next_ident())
elif peek == ("DELIM", "|"):
# The explicit "no namespace" syntax, e.g. |div: only valid at
# the very start of a simple selector.
if len(stream.used) != selector_start:
raise SelectorSyntaxError(f"Expected selector, got {peek}")
stream.next()
result = Element(None, stream.next_ident())
result = Element(None, stream.next_ident_or_star())
elif peek == ("DELIM", "["):
stream.next()
result = parse_attrib(result, stream)
Expand All @@ -637,24 +643,30 @@ def parse_simple_selector(
continue
if stream.peek() != ("DELIM", "("):
result = Pseudo(result, ident)
if repr(result) == "Pseudo[Element[*]:scope]" and not (
len(stream.used) == 2
or (len(stream.used) == 3 and stream.used[0].type == "S")
or (len(stream.used) >= 3 and stream.used[-3].is_delim(","))
or (
len(stream.used) >= 4
and stream.used[-3].type == "S"
and stream.used[-4].is_delim(",")
)
):
raise SelectorSyntaxError(
'Got immediate child pseudo-element ":scope" '
"not at the start of a selector"
)
if result.ident == "scope":
# :scope is only supported at the start of a selector,
# i.e. never in :is()/:where()/:matches() arguments
# (where a preceding comma separates arguments, not
# selectors), and otherwise only when the tokens
# preceding its compound selector are the start of the
# input or a comma.
preceding = stream.used[:selector_start]
while preceding and preceding[-1].type == "S":
preceding = preceding[:-1]
if inside_selector_list or (
preceding and not preceding[-1].is_delim(",")
):
raise SelectorSyntaxError(
'Got pseudo-class ":scope" not at the start of a selector'
)
continue
stream.next()
stream.skip_whitespace()
if ident.lower() == "not":
if inside_selector_list:
raise SelectorSyntaxError(
":not() is not supported inside :is(), :where() and :matches()"
)
if inside_negation:
raise SelectorSyntaxError("Got nested :not()")
argument, argument_pseudo_element = parse_simple_selector(
Expand Down Expand Up @@ -703,36 +715,48 @@ def parse_arguments(stream: TokenStream) -> list[Token]: # noqa: RET503
raise SelectorSyntaxError(f"Expected an argument, got {next_}")


def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: # noqa: RET503
def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]:
stream.skip_whitespace()
subselector = ""
subselector_tokens: list[Token] = []
next_ = stream.next()

if next_ in [("DELIM", "+"), ("DELIM", "-"), ("DELIM", ">"), ("DELIM", "~")]:
if next_ in [("DELIM", "+"), ("DELIM", ">"), ("DELIM", "~")]:
combinator = next_
stream.skip_whitespace()
next_ = stream.next()
else:
combinator = Token("DELIM", " ", pos=0)

seen_whitespace = False
while 1:
if next_.type in ("IDENT", "STRING", "NUMBER") or next_ in [
("DELIM", "."),
("DELIM", "*"),
]:
subselector += cast("str", next_.value)
if next_.type == "S":
# Whitespace is valid before the closing parenthesis; anywhere
# else it would be a descendant combinator, which is not
# supported in :has() arguments.
seen_whitespace = True
elif next_.type == "IDENT" or next_ in [("DELIM", "."), ("DELIM", "*")]:
if seen_whitespace:
raise SelectorSyntaxError(f"Expected an argument, got {next_}")
subselector_tokens.append(next_)
elif next_ == ("DELIM", ")"):
result = parse(subselector)
return combinator, result[0]
break
else:
raise SelectorSyntaxError(f"Expected an argument, got {next_}")
next_ = stream.next()

# Reparse the collected tokens instead of their concatenated source
# text, so that escaped identifiers are preserved.
subselector_tokens.append(EOFToken(next_.pos))
result, _ = parse_simple_selector(TokenStream(subselector_tokens))
return combinator, Selector(result)


def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]:
arguments = []
while 1:
result, pseudo_element = parse_simple_selector(stream, True)
result, pseudo_element = parse_simple_selector(
stream, inside_negation=True, inside_selector_list=True
)
if pseudo_element:
raise SelectorSyntaxError(
f"Got pseudo-element ::{pseudo_element} inside function"
Expand Down Expand Up @@ -806,7 +830,8 @@ def parse_series(tokens: Iterable[Token]) -> tuple[int, int]:
for token in tokens:
if token.type == "STRING":
raise ValueError("String tokens not allowed in series.")
s = "".join(cast("str", token.value) for token in tokens).strip()
# The An+B microsyntax is ASCII-case-insensitive: 2N+1, EVEN, Odd...
s = ascii_lower("".join(cast("str", token.value) for token in tokens).strip())
if s == "odd":
return 2, 1
if s == "even":
Expand Down
5 changes: 3 additions & 2 deletions cssselect/xpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ def xpath_nth_last_of_type_function(
self, xpath: XPathExpr, function: Function
) -> XPathExpr:
if xpath.element == "*":
raise ExpressionError("*:nth-of-type() is not implemented")
raise ExpressionError("*:nth-last-of-type() is not implemented")
return self.xpath_nth_child_function(
xpath, function, last=True, add_name_test=False
)
Expand Down Expand Up @@ -669,7 +669,8 @@ def xpath_root_pseudo(self, xpath: XPathExpr) -> XPathExpr:
# for product in response.css('.product'):
# description = product.css(':scope > div::text').get()
def xpath_scope_pseudo(self, xpath: XPathExpr) -> XPathExpr:
return xpath.add_condition("1")
xpath.add_name_test()
return xpath.add_condition("position() = 1")

def xpath_first_child_pseudo(self, xpath: XPathExpr) -> XPathExpr:
return xpath.add_condition("count(preceding-sibling::*) = 0")
Expand Down
91 changes: 84 additions & 7 deletions tests/test_cssselect.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def parse_many(first: str, *others: str) -> list[str]:
assert parse_many("*|*") == ["Element[*]"]
assert parse_many("*|foo") == ["Element[foo]"]
assert parse_many("|foo") == ["Element[foo]"]
assert parse_many("|*") == ["Element[*]"]
assert parse_many("foo|*") == ["Element[foo|*]"]
assert parse_many("foo|bar") == ["Element[foo|bar]"]
# This will never match, but it is valid:
Expand Down Expand Up @@ -185,6 +186,9 @@ def parse_many(first: str, *others: str) -> list[str]:
"CombinedSelector[CombinedSelector[Pseudo[Element[*]:scope] > "
"Hash[Element[*]#foo]] <followed> Hash[Element[*]#bar]]"
]
assert parse_many("*:scope") == ["Pseudo[Element[*]:scope]"]
assert parse_many("div:scope") == ["Pseudo[Element[div]:scope]"]
assert parse_many(".foo:scope") == ["Pseudo[Class[Element[*].foo]:scope]"]

def test_pseudo_elements(self) -> None:
def parse_pseudo(css: str) -> list[tuple[str, str | None]]:
Expand Down Expand Up @@ -384,6 +388,9 @@ def get_error(css: str) -> str | None:
assert get_error("div > ") == ("Expected selector, got <EOF at 6>")
assert get_error(" > div") == ("Expected selector, got <DELIM '>' at 2>")
assert get_error("foo|#bar") == ("Expected ident or '*', got <HASH 'bar' at 4>")
assert get_error(".foo|bar") == ("Expected selector, got <DELIM '|' at 4>")
assert get_error("#bar|foo") == ("Expected selector, got <DELIM '|' at 4>")
assert get_error("[baz]|foo") == ("Expected selector, got <DELIM '|' at 5>")
assert get_error("#.foo") == ("Expected selector, got <DELIM '#' at 0>")
assert get_error(".#foo") == ("Expected ident, got <HASH 'foo' at 1>")
assert get_error(":#foo") == ("Expected ident, got <HASH 'foo' at 1>")
Expand Down Expand Up @@ -415,6 +422,17 @@ def get_error(css: str) -> str | None:
"Got pseudo-element ::before inside :not() at 12"
)
assert get_error(":not(:not(a))") == ("Got nested :not()")
# A :not() inside :is()/:where()/:matches() is not a nested :not()
# and gets its own message
assert get_error(":is(:not(a))") == (
":not() is not supported inside :is(), :where() and :matches()"
)
assert get_error(":where(:not(a))") == (
":not() is not supported inside :is(), :where() and :matches()"
)
assert get_error(":matches(:not(a))") == (
":not() is not supported inside :is(), :where() and :matches()"
)
assert get_error(":is(:before)") == (
"Got pseudo-element ::before inside function"
)
Expand All @@ -426,16 +444,53 @@ def get_error(css: str) -> str | None:
"Expected an argument, got <IDENT 'b' at 9>"
)
assert get_error(":scope > div :scope header") == (
'Got immediate child pseudo-element ":scope" not at the start of a selector'
'Got pseudo-class ":scope" not at the start of a selector'
)
assert get_error("div :scope header") == (
'Got immediate child pseudo-element ":scope" not at the start of a selector'
'Got pseudo-class ":scope" not at the start of a selector'
)
assert get_error("a div:scope") == (
'Got pseudo-class ":scope" not at the start of a selector'
)
assert get_error("a > .foo:scope") == (
'Got pseudo-class ":scope" not at the start of a selector'
)
assert get_error("*:scope") is None
assert get_error("div:scope") is None
assert get_error("foo, *:scope") is None
# :scope is rejected in :is()/:where()/:matches() arguments too;
# a comma there separates arguments, not selectors.
assert get_error(":is(:scope)") == (
'Got pseudo-class ":scope" not at the start of a selector'
)
assert get_error(":is(a, :scope)") == (
'Got pseudo-class ":scope" not at the start of a selector'
)
assert get_error(":where(a, :scope)") == (
'Got pseudo-class ":scope" not at the start of a selector'
)
assert get_error(":matches(a, :scope)") == (
'Got pseudo-class ":scope" not at the start of a selector'
)
assert get_error("foo, :is(a, :scope)") == (
'Got pseudo-class ":scope" not at the start of a selector'
)
assert get_error("> div p") == ("Expected selector, got <DELIM '>' at 0>")

# Unsupported :has() with several arguments
assert get_error(":has(a, b)") == ("Expected an argument, got <DELIM ',' at 6>")
assert get_error(":has()") == ("Expected selector, got <EOF at 0>")
assert get_error(":has()") == ("Expected selector, got <EOF at 5>")
assert get_error(":has(a b)") == ("Expected an argument, got <IDENT 'b' at 7>")
assert get_error(":has(a .b)") == ("Expected an argument, got <DELIM '.' at 7>")
# '-' is not a valid relative combinator
assert get_error(":has(- p)") == ("Expected an argument, got <DELIM '-' at 5>")
# Strings and numbers are not selectors
assert get_error(':has("a")') == ("Expected an argument, got <STRING 'a' at 5>")
assert get_error(":has(1)") == ("Expected an argument, got <NUMBER '1' at 5>")
# Whitespace around a :has() argument is not a combinator
assert get_error("e:has(f )") is None
assert get_error("e:has( > f )") is None
assert get_error(":has(.a )") is None

def test_translation(self) -> None:
def xpath(css: str) -> str:
Expand Down Expand Up @@ -563,6 +618,18 @@ def xpath(css: str) -> str:
assert xpath(r"di\[v") == ("*[name() = 'di[v']")
assert xpath(r"[h\a0 ref]") == ("*[attribute::*[name() = 'h ref']]") # h\xa0ref
assert xpath(r"[h\]ref]") == ("*[attribute::*[name() = 'h]ref']]")
# Escaped identifiers survive inside :has() arguments
assert xpath(r"e:has(di\[v)") == "e[descendant::*[name() = 'di[v']]"

# :scope, alone and in a compound selector
assert xpath(":scope") == "*[position() = 1]"
assert xpath("*:scope") == "*[position() = 1]"
assert xpath("div:scope") == "*[(name() = 'div') and (position() = 1)]"
assert xpath(".foo:scope") == (
"*[(@class and contains("
"concat(' ', normalize-space(@class), ' '), ' foo ')) "
"and (position() = 1)]"
)

with pytest.raises(ExpressionError):
xpath(":fİrst-child")
Expand All @@ -572,9 +639,9 @@ def xpath(css: str) -> str:
xpath(":only-of-type")
with pytest.raises(ExpressionError):
xpath(":last-of-type")
with pytest.raises(ExpressionError):
with pytest.raises(ExpressionError, match=r"\*:nth-of-type\(\)"):
xpath(":nth-of-type(1)")
with pytest.raises(ExpressionError):
with pytest.raises(ExpressionError, match=r"\*:nth-last-of-type\(\)"):
xpath(":nth-last-of-type(1)")
with pytest.raises(ExpressionError):
xpath(":nth-child(n-)")
Expand Down Expand Up @@ -616,7 +683,7 @@ def test_quoting(self) -> None:
'''descendant-or-self::*[@aval = '"""']'''
)
assert css_to_xpath(':scope > div[dataimg="<testmessage>"]') == (
"descendant-or-self::*[1]/div[@dataimg = '<testmessage>']"
"descendant-or-self::*[position() = 1]/div[@dataimg = '<testmessage>']"
)

def test_unicode_escapes(self) -> None:
Expand Down Expand Up @@ -726,7 +793,7 @@ def xpath(css: str) -> str:
assert xpath("p img::attr(src)") == (
"descendant-or-self::p/descendant-or-self::*/img/@src"
)
assert xpath(":scope") == "descendant-or-self::*[1]"
assert xpath(":scope") == "descendant-or-self::*[position() = 1]"
assert xpath(":first-or-second[href]") == (
"descendant-or-self::*[(@id = 'first' or @id = 'second') and (@href)]"
)
Expand Down Expand Up @@ -775,6 +842,12 @@ def series(css: str) -> tuple[int, int] | None:
assert series("5") == (0, 5)
assert series("foo") is None
assert series("n+") is None
# ASCII-case-insensitive
assert series("2N+1") == (2, 1)
assert series("EVEN") == (2, 0)
assert series("Odd") == (2, 1)
assert series("N") == (1, 0)
assert series("-N+3") == (-1, 3)

def test_lang(self) -> None:
document = etree.fromstring(XMLLANG_IDS)
Expand Down Expand Up @@ -903,6 +976,10 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]:
assert pcss(":scope body > div") == ["outer-div", "foobar-div"]
assert pcss(":scope head") == ["nil"]
assert pcss(":scope html") == []
# Compound :scope matches the scope root only if the rest of the
# compound selector matches it too
assert pcss("html:scope") == ["html"]
assert pcss("div:scope") == []

# --- nth-* and nth-last-* -------------------------------------

Expand Down
Loading