Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/cssselect/xpath.py: 77%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2cssselect.xpath
3===============
5Translation of parsed CSS selectors to XPath expressions.
8:copyright: (c) 2007-2012 Ian Bicking and contributors.
9See AUTHORS for more details.
10:license: BSD, see LICENSE for more details.
12"""
14from __future__ import annotations
16import re
17from typing import TYPE_CHECKING, cast
19from cssselect.parser import (
20 Attrib,
21 Class,
22 CombinedSelector,
23 Element,
24 Function,
25 Hash,
26 Matching,
27 Negation,
28 Pseudo,
29 PseudoElement,
30 Relation,
31 Selector,
32 SelectorError,
33 SpecificityAdjustment,
34 Tree,
35 parse,
36 parse_series,
37)
39if TYPE_CHECKING:
40 from collections.abc import Callable, Iterable
42 # typing.Self requires Python 3.11
43 from typing_extensions import Self
46class ExpressionError(SelectorError, RuntimeError):
47 """Unknown or unsupported selector (eg. pseudo-class)."""
50#### XPath Helpers
53class XPathExpr:
54 def __init__(
55 self,
56 path: str = "",
57 element: str = "*",
58 condition: str = "",
59 star_prefix: bool = False,
60 ) -> None:
61 self.path = path
62 self.element = element
63 self.condition = condition
65 def __str__(self) -> str:
66 path = str(self.path) + str(self.element)
67 if self.condition:
68 path += f"[{self.condition}]"
69 return path
71 def __repr__(self) -> str:
72 return f"{self.__class__.__name__}[{self}]"
74 def add_condition(self, condition: str, conjuction: str = "and") -> Self:
75 if self.condition:
76 self.condition = f"({self.condition}) {conjuction} ({condition})"
77 else:
78 self.condition = condition
79 return self
81 def add_name_test(self) -> None:
82 if self.element == "*":
83 # We weren't doing a test anyway
84 return
85 prefix, colon, local = self.element.partition(":")
86 if is_safe_name(prefix) and (not colon or local == "*" or is_safe_name(local)):
87 # A node test, not a name() comparison: name() returns the
88 # qualified name as written in the document, which would bypass
89 # the XPath prefix mapping for a prefixed name like "ns:f" or
90 # "ns:*" (from the CSS "ns|f" or "ns|*"), and would match
91 # elements in a default namespace for an unprefixed name (which
92 # the node test emitted for a bare "f" selector does not).
93 self.add_condition(f"self::{self.element}")
94 else:
95 self.add_condition(
96 f"name() = {GenericTranslator.xpath_literal(self.element)}"
97 )
98 self.element = "*"
100 def add_star_prefix(self) -> None:
101 """
102 Append '*/' to the path to keep the context constrained
103 to a single parent.
104 """
105 self.path += "*/"
107 def join(self, combiner: str, other: XPathExpr) -> Self:
108 path = str(self) + combiner
109 # Any "star prefix" is redundant when joining.
110 if other.path != "*/":
111 path += other.path
112 self.path = path
113 self.element = other.element
114 self.condition = other.condition
115 return self
118split_at_single_quotes = re.compile("('+)").split
120# The spec is actually more permissive than that, but don’t bother.
121# This is just for the fast path.
122# http://www.w3.org/TR/REC-xml/#NT-NameStartChar
123is_safe_name = re.compile("^[a-zA-Z_][a-zA-Z0-9_.-]*$").match
125# Test that the string is not empty and does not contain whitespace
126is_non_whitespace = re.compile(r"^[^ \t\r\n\f]+$").match
129#### Translation
132class GenericTranslator:
133 """
134 Translator for "generic" XML documents.
136 Everything is case-sensitive, no assumption is made on the meaning
137 of element names and attribute names.
139 """
141 ####
142 #### HERE BE DRAGONS
143 ####
144 #### You are welcome to hook into this to change some behavior,
145 #### but do so at your own risks.
146 #### Until it has received a lot more work and review,
147 #### I reserve the right to change this API in backward-incompatible ways
148 #### with any minor version of cssselect.
149 #### See https://github.com/scrapy/cssselect/pull/22
150 #### -- Simon Sapin.
151 ####
153 combinator_mapping = {
154 " ": "descendant",
155 ">": "child",
156 "+": "direct_adjacent",
157 "~": "indirect_adjacent",
158 }
160 # Used to match a combinator against the context node, in :not().
161 _reverse_combinator_mapping = {
162 " ": "ancestor::*",
163 ">": "parent::*",
164 "+": "preceding-sibling::*[1]",
165 "~": "preceding-sibling::*",
166 }
168 attribute_operator_mapping = {
169 "exists": "exists",
170 "=": "equals",
171 "~=": "includes",
172 "|=": "dashmatch",
173 "^=": "prefixmatch",
174 "$=": "suffixmatch",
175 "*=": "substringmatch",
176 "!=": "different", # XXX Not in Level 3 but meh
177 }
179 #: The attribute used for ID selectors depends on the document language:
180 #: http://www.w3.org/TR/selectors/#id-selectors
181 id_attribute = "id"
183 #: The attribute used for ``:lang()`` depends on the document language:
184 #: http://www.w3.org/TR/selectors/#lang-pseudo
185 lang_attribute = "xml:lang"
187 #: The case sensitivity of document language element names,
188 #: attribute names, and attribute values in selectors depends
189 #: on the document language.
190 #: http://www.w3.org/TR/selectors/#casesens
191 #:
192 #: When a document language defines one of these as case-insensitive,
193 #: cssselect assumes that the document parser makes the parsed values
194 #: lower-case. Making the selector lower-case too makes the comparaison
195 #: case-insensitive.
196 #:
197 #: In HTML, element names and attributes names (but not attribute values)
198 #: are case-insensitive. All of lxml.html, html5lib, BeautifulSoup4
199 #: and HTMLParser make them lower-case in their parse result, so
200 #: the assumption holds.
201 lower_case_element_names = False
202 lower_case_attribute_names = False
203 lower_case_attribute_values = False
205 # class used to represent and xpath expression
206 xpathexpr_cls = XPathExpr
208 def css_to_xpath(self, css: str, prefix: str = "descendant-or-self::") -> str:
209 """Translate a *group of selectors* to XPath.
211 Pseudo-elements are not supported here since XPath only knows
212 about "real" elements.
214 :param css:
215 A *group of selectors* as a string.
216 :param prefix:
217 This string is prepended to the XPath expression for each selector.
218 The default makes selectors scoped to the context node’s subtree.
219 :raises:
220 :class:`~cssselect.SelectorSyntaxError` on invalid selectors,
221 :class:`ExpressionError` on unknown/unsupported selectors,
222 including pseudo-elements.
223 :returns:
224 The equivalent XPath 1.0 expression as a string.
226 """
227 return " | ".join(
228 self.selector_to_xpath(selector, prefix, translate_pseudo_elements=True)
229 for selector in parse(css)
230 )
232 def selector_to_xpath(
233 self,
234 selector: Selector,
235 prefix: str = "descendant-or-self::",
236 translate_pseudo_elements: bool = False,
237 ) -> str:
238 """Translate a parsed selector to XPath.
241 :param selector:
242 A parsed :class:`Selector` object.
243 :param prefix:
244 This string is prepended to the resulting XPath expression.
245 The default makes selectors scoped to the context node’s subtree.
246 :param translate_pseudo_elements:
247 Unless this is set to ``True`` (as :meth:`css_to_xpath` does),
248 the :attr:`~Selector.pseudo_element` attribute of the selector
249 is ignored.
250 It is the caller's responsibility to reject selectors
251 with pseudo-elements, or to account for them somehow.
252 :raises:
253 :class:`ExpressionError` on unknown/unsupported selectors.
254 :returns:
255 The equivalent XPath 1.0 expression as a string.
257 """
258 tree = getattr(selector, "parsed_tree", None)
259 if not tree:
260 raise TypeError(f"Expected a parsed selector, got {selector!r}")
261 xpath = self.xpath(tree)
262 assert isinstance(xpath, self.xpathexpr_cls) # help debug a missing 'return'
263 if translate_pseudo_elements and selector.pseudo_element:
264 xpath = self.xpath_pseudo_element(xpath, selector.pseudo_element)
265 return (prefix or "") + str(xpath)
267 def xpath_pseudo_element(
268 self, xpath: XPathExpr, pseudo_element: PseudoElement
269 ) -> XPathExpr:
270 """Translate a pseudo-element.
272 Defaults to not supporting pseudo-elements at all,
273 but can be overridden by sub-classes.
275 """
276 raise ExpressionError("Pseudo-elements are not supported.")
278 @staticmethod
279 def xpath_literal(s: str) -> str:
280 s = str(s)
281 if "'" not in s:
282 s = f"'{s}'"
283 elif '"' not in s:
284 s = f'"{s}"'
285 else:
286 parts_quoted = [
287 f'"{part}"' if "'" in part else f"'{part}'"
288 for part in split_at_single_quotes(s)
289 if part
290 ]
291 s = "concat({})".format(",".join(parts_quoted))
292 return s
294 def xpath(self, parsed_selector: Tree) -> XPathExpr:
295 """Translate any parsed selector object."""
296 type_name = type(parsed_selector).__name__
297 method = cast(
298 "Callable[[Tree], XPathExpr] | None",
299 getattr(self, f"xpath_{type_name.lower()}", None),
300 )
301 if method is None:
302 raise ExpressionError(f"{type_name} is not supported.")
303 return method(parsed_selector)
305 # Dispatched by parsed object type
307 def xpath_combinedselector(self, combined: CombinedSelector) -> XPathExpr:
308 """Translate a combined selector."""
309 combinator = self.combinator_mapping[combined.combinator]
310 method = cast(
311 "Callable[[XPathExpr, XPathExpr], XPathExpr]",
312 getattr(self, f"xpath_{combinator}_combinator"),
313 )
314 return method(self.xpath(combined.selector), self.xpath(combined.subselector))
316 def xpath_negation(self, negation: Negation) -> XPathExpr:
317 xpath = self.xpath(negation.selector)
318 condition = self._xpath_match_condition(negation.subselector)
319 if condition is None:
320 # The argument matches every element, so :not() matches none.
321 return xpath.add_condition("0")
322 return xpath.add_condition(f"not({condition})")
324 def _xpath_match_condition(self, selector: Tree) -> str | None:
325 """Return a condition that holds for the elements matching *selector*,
326 or None if that is every element.
328 Unlike xpath(), which walks a selector from left to right, this
329 matches the whole selector against the context node, using reverse
330 axes for combinators.
331 """
332 if isinstance(selector, CombinedSelector):
333 axis = self._reverse_combinator_mapping[selector.combinator]
334 left = self._xpath_match_condition(selector.selector)
335 if left is not None:
336 axis = f"{axis}[{left}]"
337 right = self._xpath_match_condition(selector.subselector)
338 return axis if right is None else f"{right} and {axis}"
339 sub_xpath = self.xpath(selector)
340 sub_xpath.add_name_test()
341 return sub_xpath.condition or None
343 def xpath_relation(self, relation: Relation) -> XPathExpr:
344 xpath = self.xpath(relation.selector)
345 combinator = relation.combinator
346 subselector = relation.subselector
347 right = self.xpath(subselector.parsed_tree)
348 method = cast(
349 "Callable[[XPathExpr, XPathExpr], XPathExpr]",
350 getattr(
351 self,
352 f"xpath_relation_{self.combinator_mapping[cast('str', combinator.value)]}_combinator",
353 ),
354 )
355 return method(xpath, right)
357 def xpath_matching(self, matching: Matching) -> XPathExpr:
358 return self._xpath_add_selector_list_condition(
359 self.xpath(matching.selector), matching.selector_list
360 )
362 def xpath_specificityadjustment(self, matching: SpecificityAdjustment) -> XPathExpr:
363 return self._xpath_add_selector_list_condition(
364 self.xpath(matching.selector), matching.selector_list
365 )
367 def _xpath_add_selector_list_condition(
368 self, xpath: XPathExpr, selector_list: Iterable[Tree]
369 ) -> XPathExpr:
370 """Add a condition matching any selector of the list
371 (for :is() and :where())."""
372 condition = ""
373 for e in (self.xpath(selector) for selector in selector_list):
374 if e.path:
375 # Only a combined selector (e.g. "a b") translates to a path,
376 # which cannot be embedded into a predicate of the outer
377 # expression. The parser rejects combinators in these arguments,
378 # so this is only reachable through a hand-built Matching or
379 # SpecificityAdjustment node.
380 raise ExpressionError(
381 "Combined selectors are not supported inside "
382 ":is(), :where() and :matches()"
383 )
384 e.add_name_test()
385 if not e.condition:
386 # This argument matches any element, so the whole selector
387 # list does too: it adds no condition.
388 return xpath
389 condition = (
390 f"({condition}) or ({e.condition})" if condition else e.condition
391 )
392 return xpath.add_condition(condition)
394 def xpath_function(self, function: Function) -> XPathExpr:
395 """Translate a functional pseudo-class."""
396 method_name = "xpath_{}_function".format(function.name.replace("-", "_"))
397 method = cast(
398 "Callable[[XPathExpr, Function], XPathExpr] | None",
399 getattr(self, method_name, None),
400 )
401 if not method:
402 raise ExpressionError(f"The pseudo-class :{function.name}() is unknown")
403 return method(self.xpath(function.selector), function)
405 def xpath_pseudo(self, pseudo: Pseudo) -> XPathExpr:
406 """Translate a pseudo-class."""
407 method_name = "xpath_{}_pseudo".format(pseudo.ident.replace("-", "_"))
408 method = cast(
409 "Callable[[XPathExpr], XPathExpr] | None",
410 getattr(self, method_name, None),
411 )
412 if not method:
413 # TODO: better error message for pseudo-elements?
414 raise ExpressionError(f"The pseudo-class :{pseudo.ident} is unknown")
415 return method(self.xpath(pseudo.selector))
417 def xpath_attrib(self, selector: Attrib) -> XPathExpr:
418 """Translate an attribute selector."""
419 operator = self.attribute_operator_mapping[selector.operator]
420 method = cast(
421 "Callable[[XPathExpr, str, str | None], XPathExpr]",
422 getattr(self, f"xpath_attrib_{operator}"),
423 )
424 if self.lower_case_attribute_names:
425 name = selector.attrib.lower()
426 else:
427 name = selector.attrib
428 safe = is_safe_name(name)
429 if selector.namespace:
430 name = f"{selector.namespace}:{name}"
431 safe = safe and is_safe_name(selector.namespace)
432 if safe:
433 attrib = "@" + name
434 else:
435 attrib = f"attribute::*[name() = {self.xpath_literal(name)}]"
436 if selector.value is None:
437 value = None
438 elif self.lower_case_attribute_values:
439 value = cast("str", selector.value.value).lower()
440 else:
441 value = selector.value.value
442 return method(self.xpath(selector.selector), attrib, value)
444 def xpath_class(self, class_selector: Class) -> XPathExpr:
445 """Translate a class selector."""
446 # .foo is defined as [class~=foo] in the spec.
447 xpath = self.xpath(class_selector.selector)
448 return self.xpath_attrib_includes(xpath, "@class", class_selector.class_name)
450 def xpath_hash(self, id_selector: Hash) -> XPathExpr:
451 """Translate an ID selector."""
452 xpath = self.xpath(id_selector.selector)
453 return self.xpath_attrib_equals(xpath, "@id", id_selector.id)
455 def xpath_element(self, selector: Element) -> XPathExpr:
456 """Translate a type or universal selector."""
457 element = selector.element
458 if not element:
459 element = "*"
460 safe = True
461 else:
462 safe = bool(is_safe_name(element))
463 if self.lower_case_element_names:
464 element = element.lower()
465 if selector.namespace:
466 # Namespace prefixes are case-sensitive.
467 # http://www.w3.org/TR/css3-namespace/#prefixes
468 element = f"{selector.namespace}:{element}"
469 safe = safe and bool(is_safe_name(selector.namespace))
470 xpath = self.xpathexpr_cls(element=element)
471 if not safe:
472 # Not usable as an XPath name test (e.g. an escaped identifier
473 # like di\a0 v): compare the serialized name instead. Done here
474 # rather than through add_name_test(), which would mistake a ":"
475 # inside such a name for a namespace prefix separator.
476 xpath.add_condition(f"name() = {self.xpath_literal(element)}")
477 xpath.element = "*"
478 return xpath
480 # CombinedSelector: dispatch by combinator
482 def xpath_descendant_combinator(
483 self, left: XPathExpr, right: XPathExpr
484 ) -> XPathExpr:
485 """right is a child, grand-child or further descendant of left"""
486 return left.join("/descendant-or-self::*/", right)
488 def xpath_child_combinator(self, left: XPathExpr, right: XPathExpr) -> XPathExpr:
489 """right is an immediate child of left"""
490 return left.join("/", right)
492 def xpath_direct_adjacent_combinator(
493 self, left: XPathExpr, right: XPathExpr
494 ) -> XPathExpr:
495 """right is a sibling immediately after left"""
496 xpath = left.join("/following-sibling::", right)
497 xpath.add_name_test()
498 return xpath.add_condition("position() = 1")
500 def xpath_indirect_adjacent_combinator(
501 self, left: XPathExpr, right: XPathExpr
502 ) -> XPathExpr:
503 """right is a sibling after left, immediately or not"""
504 return left.join("/following-sibling::", right)
506 # The relative selector is kept in `condition` (instead of being folded
507 # into `path`/`element`) so that `element` stays a plain element name:
508 # later steps such as :first-of-type or :not() read and rewrite it.
510 def xpath_relation_descendant_combinator(
511 self, left: XPathExpr, right: XPathExpr
512 ) -> XPathExpr:
513 """right is a child, grand-child or further descendant of left; select left"""
514 return left.add_condition(f"descendant::{right}")
516 def xpath_relation_child_combinator(
517 self, left: XPathExpr, right: XPathExpr
518 ) -> XPathExpr:
519 """right is an immediate child of left; select left"""
520 return left.add_condition(f"./{right}")
522 def xpath_relation_direct_adjacent_combinator(
523 self, left: XPathExpr, right: XPathExpr
524 ) -> XPathExpr:
525 """right is a sibling immediately after left; select left"""
526 right.add_name_test()
527 right.add_condition("position() = 1")
528 return left.add_condition(f"following-sibling::{right}")
530 def xpath_relation_indirect_adjacent_combinator(
531 self, left: XPathExpr, right: XPathExpr
532 ) -> XPathExpr:
533 """right is a sibling after left, immediately or not; select left"""
534 return left.add_condition(f"following-sibling::{right}")
536 # Function: dispatch by function/pseudo-class name
538 def xpath_nth_child_function(
539 self,
540 xpath: XPathExpr,
541 function: Function,
542 last: bool = False,
543 add_name_test: bool = True,
544 ) -> XPathExpr:
545 try:
546 a, b = parse_series(function.arguments)
547 except ValueError as ex:
548 raise ExpressionError(f"Invalid series: '{function.arguments!r}'") from ex
550 # From https://www.w3.org/TR/css3-selectors/#structural-pseudos:
551 #
552 # :nth-child(an+b)
553 # an+b-1 siblings before
554 #
555 # :nth-last-child(an+b)
556 # an+b-1 siblings after
557 #
558 # :nth-of-type(an+b)
559 # an+b-1 siblings with the same expanded element name before
560 #
561 # :nth-last-of-type(an+b)
562 # an+b-1 siblings with the same expanded element name after
563 #
564 # So,
565 # for :nth-child and :nth-of-type
566 #
567 # count(preceding-sibling::<nodetest>) = an+b-1
568 #
569 # for :nth-last-child and :nth-last-of-type
570 #
571 # count(following-sibling::<nodetest>) = an+b-1
572 #
573 # therefore,
574 # count(...) - (b-1) ≡ 0 (mod a)
575 #
576 # if a == 0:
577 # ~~~~~~~~~~
578 # count(...) = b-1
579 #
580 # if a < 0:
581 # ~~~~~~~~~
582 # count(...) - b +1 <= 0
583 # -> count(...) <= b-1
584 #
585 # if a > 0:
586 # ~~~~~~~~~
587 # count(...) - b +1 >= 0
588 # -> count(...) >= b-1
590 # work with b-1 instead
591 b_min_1 = b - 1
593 # early-exit condition 1:
594 # ~~~~~~~~~~~~~~~~~~~~~~~
595 # for a == 1, nth-*(an+b) means n+b-1 siblings before/after,
596 # and since n ∈ {0, 1, 2, ...}, if b-1<=0,
597 # there is always an "n" matching any number of siblings (maybe none)
598 if a == 1 and b_min_1 <= 0:
599 return xpath
601 # early-exit condition 2:
602 # ~~~~~~~~~~~~~~~~~~~~~~~
603 # an+b-1 siblings with a<0 and (b-1)<0 is not possible
604 if a < 0 and b_min_1 < 0:
605 return xpath.add_condition("0")
607 # `add_name_test` boolean is inverted and somewhat counter-intuitive:
608 #
609 # nth_of_type() calls nth_child(add_name_test=False)
610 nodetest = "*" if add_name_test else f"{xpath.element}"
612 # count siblings before or after the element
613 if not last:
614 siblings_count = f"count(preceding-sibling::{nodetest})"
615 else:
616 siblings_count = f"count(following-sibling::{nodetest})"
618 # special case of fixed position: nth-*(0n+b)
619 # if a == 0:
620 # ~~~~~~~~~~
621 # count(***-sibling::***) = b-1
622 if a == 0:
623 return xpath.add_condition(f"{siblings_count} = {b_min_1}")
625 expressions = []
627 if a > 0:
628 # siblings count, an+b-1, is always >= 0,
629 # so if a>0, and (b-1)<=0, an "n" exists to satisfy this,
630 # therefore, the predicate is only interesting if (b-1)>0
631 if b_min_1 > 0:
632 expressions.append(f"{siblings_count} >= {b_min_1}")
633 else:
634 # if a<0, and (b-1)<0, no "n" satisfies this,
635 # this is tested above as an early exit condition
636 # otherwise,
637 expressions.append(f"{siblings_count} <= {b_min_1}")
639 # operations modulo 1 or -1 are simpler, one only needs to verify:
640 #
641 # - either:
642 # count(***-sibling::***) - (b-1) = n = 0, 1, 2, 3, etc.,
643 # i.e. count(***-sibling::***) >= (b-1)
644 #
645 # - or:
646 # count(***-sibling::***) - (b-1) = -n = 0, -1, -2, -3, etc.,
647 # i.e. count(***-sibling::***) <= (b-1)
648 # we just did above.
649 #
650 if abs(a) != 1:
651 # count(***-sibling::***) - (b-1) ≡ 0 (mod a)
652 left = siblings_count
654 # apply "modulo a" on 2nd term, -(b-1),
655 # to simplify things like "(... +6) % -3",
656 # and also make it positive with |a|
657 b_neg = (-b_min_1) % abs(a)
659 if b_neg != 0:
660 left = f"({left} +{b_neg})"
662 expressions.append(f"{left} mod {a} = 0")
664 template = "(%s)" if len(expressions) > 1 else "%s"
665 xpath.add_condition(
666 " and ".join(template % expression for expression in expressions)
667 )
668 return xpath
670 def xpath_nth_last_child_function(
671 self, xpath: XPathExpr, function: Function
672 ) -> XPathExpr:
673 return self.xpath_nth_child_function(xpath, function, last=True)
675 @staticmethod
676 def _check_of_type_element(xpath: XPathExpr, pseudo: str) -> None:
677 """Raise an exception if an -of-type pseudo-class can't be used with
678 the given element.
680 For "*" and namespace wildcards like "ns:*" the type of the element is
681 not known, and counting same-type siblings cannot be expressed as an
682 XPath 1.0 node test.
683 """
684 element = xpath.element
685 if element == "*" or element.endswith(":*"):
686 css_element = element.replace(":", "|")
687 raise ExpressionError(f"{css_element}:{pseudo} is not implemented")
689 def xpath_nth_of_type_function(
690 self, xpath: XPathExpr, function: Function
691 ) -> XPathExpr:
692 self._check_of_type_element(xpath, "nth-of-type()")
693 return self.xpath_nth_child_function(xpath, function, add_name_test=False)
695 def xpath_nth_last_of_type_function(
696 self, xpath: XPathExpr, function: Function
697 ) -> XPathExpr:
698 self._check_of_type_element(xpath, "nth-last-of-type()")
699 return self.xpath_nth_child_function(
700 xpath, function, last=True, add_name_test=False
701 )
703 def xpath_contains_function(
704 self, xpath: XPathExpr, function: Function
705 ) -> XPathExpr:
706 # Defined there, removed in later drafts:
707 # http://www.w3.org/TR/2001/CR-css3-selectors-20011113/#content-selectors
708 if function.argument_types() not in (["STRING"], ["IDENT"]):
709 raise ExpressionError(
710 f"Expected a single string or ident for :contains(), got {function.arguments!r}"
711 )
712 value = cast("str", function.arguments[0].value)
713 return xpath.add_condition(f"contains(., {self.xpath_literal(value)})")
715 def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr:
716 if function.argument_types() not in (["STRING"], ["IDENT"]):
717 raise ExpressionError(
718 f"Expected a single string or ident for :lang(), got {function.arguments!r}"
719 )
720 value = cast("str", function.arguments[0].value)
721 return xpath.add_condition(f"lang({self.xpath_literal(value)})")
723 # Pseudo: dispatch by pseudo-class name
725 def xpath_root_pseudo(self, xpath: XPathExpr) -> XPathExpr:
726 return xpath.add_condition("not(parent::*)")
728 # CSS immediate children (CSS ":scope > div" to XPath "child::div" or "./div")
729 # Works only at the start of a selector
730 # Needed to get immediate children of a processed selector in Scrapy
731 # for product in response.css('.product'):
732 # description = product.css(':scope > div::text').get()
733 def xpath_scope_pseudo(self, xpath: XPathExpr) -> XPathExpr:
734 xpath.add_name_test()
735 return xpath.add_condition("position() = 1")
737 def xpath_first_child_pseudo(self, xpath: XPathExpr) -> XPathExpr:
738 return xpath.add_condition("count(preceding-sibling::*) = 0")
740 def xpath_last_child_pseudo(self, xpath: XPathExpr) -> XPathExpr:
741 return xpath.add_condition("count(following-sibling::*) = 0")
743 def xpath_first_of_type_pseudo(self, xpath: XPathExpr) -> XPathExpr:
744 self._check_of_type_element(xpath, "first-of-type")
745 return xpath.add_condition(f"count(preceding-sibling::{xpath.element}) = 0")
747 def xpath_last_of_type_pseudo(self, xpath: XPathExpr) -> XPathExpr:
748 self._check_of_type_element(xpath, "last-of-type")
749 return xpath.add_condition(f"count(following-sibling::{xpath.element}) = 0")
751 def xpath_only_child_pseudo(self, xpath: XPathExpr) -> XPathExpr:
752 # Count siblings, not the parent's children: the root element has
753 # no parent, but it has no siblings either, so it must match.
754 return xpath.add_condition(
755 "count(preceding-sibling::*) = 0 and count(following-sibling::*) = 0"
756 )
758 def xpath_only_of_type_pseudo(self, xpath: XPathExpr) -> XPathExpr:
759 self._check_of_type_element(xpath, "only-of-type")
760 return xpath.add_condition(
761 f"count(preceding-sibling::{xpath.element}) = 0 "
762 f"and count(following-sibling::{xpath.element}) = 0"
763 )
765 def xpath_empty_pseudo(self, xpath: XPathExpr) -> XPathExpr:
766 return xpath.add_condition("not(*) and not(string-length())")
768 def pseudo_never_matches(self, xpath: XPathExpr) -> XPathExpr:
769 """Common implementation for pseudo-classes that never match."""
770 return xpath.add_condition("0")
772 xpath_link_pseudo = pseudo_never_matches
773 xpath_visited_pseudo = pseudo_never_matches
774 xpath_hover_pseudo = pseudo_never_matches
775 xpath_active_pseudo = pseudo_never_matches
776 xpath_focus_pseudo = pseudo_never_matches
777 xpath_target_pseudo = pseudo_never_matches
778 xpath_enabled_pseudo = pseudo_never_matches
779 xpath_disabled_pseudo = pseudo_never_matches
780 xpath_checked_pseudo = pseudo_never_matches
782 # Attrib: dispatch by attribute operator
784 def xpath_attrib_exists(
785 self, xpath: XPathExpr, name: str, value: str | None
786 ) -> XPathExpr:
787 assert not value
788 xpath.add_condition(name)
789 return xpath
791 def xpath_attrib_equals(
792 self, xpath: XPathExpr, name: str, value: str | None
793 ) -> XPathExpr:
794 assert value is not None
795 xpath.add_condition(f"{name} = {self.xpath_literal(value)}")
796 return xpath
798 def xpath_attrib_different(
799 self, xpath: XPathExpr, name: str, value: str | None
800 ) -> XPathExpr:
801 assert value is not None
802 # FIXME: this seems like a weird hack...
803 if value:
804 xpath.add_condition(f"not({name}) or {name} != {self.xpath_literal(value)}")
805 else:
806 xpath.add_condition(f"{name} != {self.xpath_literal(value)}")
807 return xpath
809 def xpath_attrib_includes(
810 self, xpath: XPathExpr, name: str, value: str | None
811 ) -> XPathExpr:
812 if value and is_non_whitespace(value):
813 arg = self.xpath_literal(" " + value + " ")
814 xpath.add_condition(
815 f"{name} and contains(concat(' ', normalize-space({name}), ' '), {arg})"
816 )
817 else:
818 xpath.add_condition("0")
819 return xpath
821 def xpath_attrib_dashmatch(
822 self, xpath: XPathExpr, name: str, value: str | None
823 ) -> XPathExpr:
824 assert value is not None
825 arg = self.xpath_literal(value)
826 arg_dash = self.xpath_literal(value + "-")
827 # Weird, but true...
828 xpath.add_condition(
829 f"{name} and ({name} = {arg} or starts-with({name}, {arg_dash}))"
830 )
831 return xpath
833 def xpath_attrib_prefixmatch(
834 self, xpath: XPathExpr, name: str, value: str | None
835 ) -> XPathExpr:
836 if value:
837 xpath.add_condition(
838 f"{name} and starts-with({name}, {self.xpath_literal(value)})"
839 )
840 else:
841 xpath.add_condition("0")
842 return xpath
844 def xpath_attrib_suffixmatch(
845 self, xpath: XPathExpr, name: str, value: str | None
846 ) -> XPathExpr:
847 if value:
848 # Oddly there is a starts-with in XPath 1.0, but not ends-with
849 xpath.add_condition(
850 f"{name} and substring({name}, string-length({name})-{len(value) - 1}) = {self.xpath_literal(value)}"
851 )
852 else:
853 xpath.add_condition("0")
854 return xpath
856 def xpath_attrib_substringmatch(
857 self, xpath: XPathExpr, name: str, value: str | None
858 ) -> XPathExpr:
859 if value:
860 # Attribute selectors are case sensitive
861 xpath.add_condition(
862 f"{name} and contains({name}, {self.xpath_literal(value)})"
863 )
864 else:
865 xpath.add_condition("0")
866 return xpath
869class HTMLTranslator(GenericTranslator):
870 """
871 Translator for (X)HTML documents.
873 Has a more useful implementation of some pseudo-classes based on
874 HTML-specific element names and attribute names, as described in
875 the `HTML5 specification`_. It assumes no-quirks mode.
876 The API is the same as :class:`GenericTranslator`.
878 .. _HTML5 specification: http://www.w3.org/TR/html5/links.html#selectors
880 :param xhtml:
881 If false (the default), element names and attribute names
882 are case-insensitive.
884 """
886 lang_attribute = "lang"
888 def __init__(self, xhtml: bool = False) -> None:
889 self.xhtml = xhtml # Might be useful for sub-classes?
890 if not xhtml:
891 # See their definition in GenericTranslator.
892 self.lower_case_element_names = True
893 self.lower_case_attribute_names = True
895 def xpath_checked_pseudo(self, xpath: XPathExpr) -> XPathExpr:
896 # FIXME: is this really all the elements?
897 return xpath.add_condition(
898 "(@selected and name(.) = 'option') or "
899 "(@checked "
900 "and (name(.) = 'input' or name(.) = 'command')"
901 "and (@type = 'checkbox' or @type = 'radio'))"
902 )
904 def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr:
905 if function.argument_types() not in (["STRING"], ["IDENT"]):
906 raise ExpressionError(
907 f"Expected a single string or ident for :lang(), got {function.arguments!r}"
908 )
909 value = function.arguments[0].value
910 assert value
911 arg = self.xpath_literal(value.lower() + "-")
912 return xpath.add_condition(
913 "ancestor-or-self::*[@lang][1][starts-with(concat("
914 # XPath 1.0 has no lower-case function...
915 f"translate(@{self.lang_attribute}, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', "
916 "'abcdefghijklmnopqrstuvwxyz'), "
917 f"'-'), {arg})]"
918 )
920 def xpath_link_pseudo(self, xpath: XPathExpr) -> XPathExpr:
921 return xpath.add_condition(
922 "@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')"
923 )
925 # Links are never visited, the implementation for :visited is the same
926 # as in GenericTranslator
928 def xpath_disabled_pseudo(self, xpath: XPathExpr) -> XPathExpr:
929 # http://www.w3.org/TR/html5/section-index.html#attributes-1
930 return xpath.add_condition(
931 """
932 (
933 @disabled and
934 (
935 (name(.) = 'input' and @type != 'hidden') or
936 name(.) = 'button' or
937 name(.) = 'select' or
938 name(.) = 'textarea' or
939 name(.) = 'command' or
940 name(.) = 'fieldset' or
941 name(.) = 'optgroup' or
942 name(.) = 'option'
943 )
944 ) or (
945 (
946 (name(.) = 'input' and @type != 'hidden') or
947 name(.) = 'button' or
948 name(.) = 'select' or
949 name(.) = 'textarea'
950 )
951 and ancestor::fieldset[@disabled]
952 )
953 """
954 )
955 # FIXME: in the second half, add "and is not a descendant of that
956 # fieldset element's first legend element child, if any."
958 def xpath_enabled_pseudo(self, xpath: XPathExpr) -> XPathExpr:
959 # http://www.w3.org/TR/html5/section-index.html#attributes-1
960 return xpath.add_condition(
961 """
962 (
963 @href and (
964 name(.) = 'a' or
965 name(.) = 'link' or
966 name(.) = 'area'
967 )
968 ) or (
969 (
970 name(.) = 'command' or
971 name(.) = 'fieldset' or
972 name(.) = 'optgroup'
973 )
974 and not(@disabled)
975 ) or (
976 (
977 (name(.) = 'input' and @type != 'hidden') or
978 name(.) = 'button' or
979 name(.) = 'select' or
980 name(.) = 'textarea' or
981 name(.) = 'keygen'
982 )
983 and not (@disabled or ancestor::fieldset[@disabled])
984 ) or (
985 name(.) = 'option' and not(
986 @disabled or ancestor::optgroup[@disabled]
987 )
988 )
989 """
990 )
991 # FIXME: ... or "li elements that are children of menu elements,
992 # and that have a child element that defines a command, if the first
993 # such element's Disabled State facet is false (not disabled)".
994 # FIXME: after ancestor::fieldset[@disabled], add "and is not a
995 # descendant of that fieldset element's first legend element child,
996 # if any."