Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/soupsieve/css_match.py: 68%
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"""CSS matcher."""
2from __future__ import annotations
3from datetime import datetime
4from math import isfinite
5from collections.abc import Hashable
6from . import util
7import re
8from . import css_types as ct
9import unicodedata
10import bs4
11from typing import (
12 Generator,
13 Iterator,
14 Iterable,
15 Any,
16 Callable,
17 Sequence,
18 overload,
19 Literal,
20 cast,
21 TYPE_CHECKING
22) # noqa: F401, F811
24# Empty tag pattern (whitespace okay)
25RE_NOT_EMPTY = re.compile('[^ \t\r\n\f]')
27RE_NOT_WS = re.compile('[^ \t\r\n\f]+')
29# Relationships
30REL_PARENT = ' '
31REL_CLOSE_PARENT = '>'
32REL_SIBLING = '~'
33REL_CLOSE_SIBLING = '+'
35# Relationships for :has() (forward looking)
36REL_HAS_PARENT = ': '
37REL_HAS_CLOSE_PARENT = ':>'
38REL_HAS_SIBLING = ':~'
39REL_HAS_CLOSE_SIBLING = ':+'
41# `bs4` imports this module while it is still initializing,
42# so these must only be evaluated when type checking.
43if TYPE_CHECKING: # pragma: no cover
44 MatchRequest = tuple[bs4.Tag, ct.SelectorList]
45 MatchGenerator = Generator[MatchRequest, bool, bool]
47NS_XHTML = 'http://www.w3.org/1999/xhtml'
48NS_XML = 'http://www.w3.org/XML/1998/namespace'
50DIR_FLAGS = ct.SEL_DIR_LTR | ct.SEL_DIR_RTL
51RANGES = ct.SEL_IN_RANGE | ct.SEL_OUT_OF_RANGE
53DIR_MAP = {
54 'ltr': ct.SEL_DIR_LTR,
55 'rtl': ct.SEL_DIR_RTL,
56 'auto': 0
57}
59RE_NUM = re.compile(r"^(?P<value>-?(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)$")
60RE_TIME = re.compile(r'^(?P<hour>[0-9]{2}):(?P<minutes>[0-9]{2})(?::(?P<seconds>[0-9]{2}(?:\.[0-9]{1,3})?))?$')
61RE_MONTH = re.compile(r'^(?P<year>[0-9]{4,})-(?P<month>[0-9]{2})$')
62RE_WEEK = re.compile(r'^(?P<year>[0-9]{4,})-W(?P<week>[0-9]{2})$')
63RE_DATE = re.compile(r'^(?P<year>[0-9]{4,})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})$')
64RE_DATETIME = re.compile(
65 r'^(?P<year>[0-9]{4,})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})T(?P<hour>[0-9]{2}):(?P<minutes>[0-9]{2})'
66 r'(?::(?P<seconds>[0-9]{2}(?:\.[0-9]{1,3})?))?$'
67)
68RE_WILD_STRIP = re.compile(r'(?:(?:-\*-)(?:\*(?:-|$))*|-\*$)')
70MONTHS_30 = (4, 6, 9, 11) # April, June, September, and November
71FEB = 2
72SHORT_MONTH = 30
73LONG_MONTH = 31
74FEB_MONTH = 28
75FEB_LEAP_MONTH = 29
76DAYS_IN_WEEK = 7
79def within(target: bs4.Tag, parent: bs4.Tag | bs4.BeautifulSoup, start: int, end: int | None = None) -> bool:
80 """Check if target is within data."""
82 contents = parent.contents
83 return any(contents[i] is target for i in range(start, end if end is not None else len(contents)))
86class _DocumentNav:
87 """Navigate a Beautiful Soup document."""
89 @classmethod
90 def assert_valid_input(cls, tag: Any) -> None:
91 """Check if valid input tag or document."""
93 # Fail on unexpected types.
94 if not cls.is_tag(tag):
95 raise TypeError(f"Expected a BeautifulSoup 'Tag', but instead received type {type(tag)}")
97 @staticmethod
98 def is_doc(obj: bs4.element.PageElement | None) -> bool:
99 """Is `BeautifulSoup` object."""
100 return isinstance(obj, bs4.BeautifulSoup)
102 @staticmethod
103 def is_tag(obj: bs4.element.PageElement | None) -> bool:
104 """Is tag."""
105 return isinstance(obj, bs4.Tag)
107 @staticmethod
108 def is_declaration(obj: bs4.element.PageElement | None) -> bool: # pragma: no cover
109 """Is declaration."""
110 return isinstance(obj, bs4.Declaration)
112 @staticmethod
113 def is_cdata(obj: bs4.element.PageElement | None) -> bool:
114 """Is CDATA."""
115 return isinstance(obj, bs4.CData)
117 @staticmethod
118 def is_processing_instruction(obj: bs4.element.PageElement | None) -> bool: # pragma: no cover
119 """Is processing instruction."""
120 return isinstance(obj, bs4.ProcessingInstruction)
122 @staticmethod
123 def is_navigable_string(obj: bs4.element.PageElement | None) -> bool:
124 """Is navigable string."""
125 return isinstance(obj, bs4.element.NavigableString)
127 @staticmethod
128 def is_special_string(obj: bs4.element.PageElement | None) -> bool:
129 """Is special string."""
130 return isinstance(obj, (bs4.Comment, bs4.Declaration, bs4.CData, bs4.ProcessingInstruction, bs4.Doctype))
132 @classmethod
133 def is_content_string(cls, obj: bs4.element.PageElement | None) -> bool:
134 """Check if node is content string."""
136 return cls.is_navigable_string(obj) and not cls.is_special_string(obj)
138 @staticmethod
139 def is_xml_tree(el: bs4.Tag | None) -> bool:
140 """Check if element (or document) is from a XML tree."""
142 return el is not None and bool(el._is_xml)
144 def is_iframe(self, el: bs4.Tag | None) -> bool:
145 """Check if element is an `iframe`."""
147 if el is None: # pragma: no cover
148 return False
150 return bool(
151 ((el.name if self.is_xml_tree(el) else util.lower(el.name)) == 'iframe') and
152 self.is_html_tag(el) # type: ignore[attr-defined]
153 )
155 def is_root(self, el: bs4.Tag) -> bool:
156 """
157 Return whether element is a root element.
159 We check that the element is the root of the tree (which we have already pre-calculated),
160 and we check if it is the root element under an `iframe`.
161 """
163 root = self.root and self.root is el # type: ignore[attr-defined]
164 if not root:
165 parent = self.get_parent(el)
166 root = parent is not None and self.is_html and self.is_iframe(parent) # type: ignore[attr-defined]
167 return root
169 def get_contents(self, el: bs4.Tag | None, no_iframe: bool = False) -> Iterator[bs4.element.PageElement]:
170 """Get contents or contents in reverse."""
172 if el is not None:
173 if not no_iframe or not self.is_iframe(el):
174 yield from el.contents
176 def get_tag_children(
177 self,
178 el: bs4.Tag | None,
179 start: int | None = None,
180 reverse: bool = False,
181 no_iframe: bool = False
182 ) -> Iterator[bs4.Tag]:
183 """Get tag children."""
185 return self.get_children(el, start, reverse, True, no_iframe)
187 @overload
188 def get_children(
189 self,
190 el: bs4.Tag | None,
191 start: int | None = None,
192 reverse: bool = False,
193 tags: Literal[True] = ...,
194 no_iframe: bool = False
195 ) -> Iterator[bs4.Tag]:
196 ...
198 @overload
199 def get_children(
200 self,
201 el: bs4.Tag | None,
202 start: int | None = None,
203 reverse: bool = False,
204 tags: Literal[False] = ...,
205 no_iframe: bool = False
206 ) -> Iterator[bs4.element.PageElement]:
207 ...
209 def get_children(
210 self,
211 el: bs4.Tag | None,
212 start: int | None = None,
213 reverse: bool = False,
214 tags: Literal[True] | Literal[False] = False,
215 no_iframe: bool = False
216 ) -> Iterator[bs4.element.PageElement]:
217 """Get children."""
219 if el is not None and (not no_iframe or not self.is_iframe(el)):
220 last = len(el.contents) - 1
221 if start is None:
222 index = last if reverse else 0
223 else:
224 index = start
225 end = -1 if reverse else last + 1
226 incr = -1 if reverse else 1
228 if 0 <= index <= last:
229 for i in range(index, end, incr):
230 node = el.contents[i]
231 if not tags or self.is_tag(node):
232 yield node
234 def get_tag_descendants(
235 self,
236 el: bs4.Tag | None,
237 no_iframe: bool = False
238 ) -> Iterator[bs4.Tag]:
239 """Specifically get tag descendants."""
241 yield from self.get_descendants(el, tags=True, no_iframe=no_iframe) # type: ignore[misc]
243 def get_descendants(
244 self,
245 el: bs4.Tag | None,
246 tags: bool = False,
247 no_iframe: bool = False
248 ) -> Iterator[bs4.element.PageElement]:
249 """Get descendants."""
251 if el is not None and (not no_iframe or not self.is_iframe(el)):
252 next_good = None
253 for child in el.descendants:
255 if next_good is not None:
256 if child is not next_good:
257 continue
258 next_good = None
260 if isinstance(child, bs4.Tag):
261 if no_iframe and self.is_iframe(child):
262 if child.next_sibling is not None:
263 next_good = child.next_sibling
264 else:
265 last_child: bs4.element.PageElement = child
266 while isinstance(last_child, bs4.Tag) and last_child.contents:
267 last_child = last_child.contents[-1]
268 next_good = last_child.next_element
269 yield child
270 if next_good is None:
271 break
272 # Coverage isn't seeing this even though it's executed
273 continue # pragma: no cover
274 yield child
276 elif not tags:
277 yield child
279 def get_parent(self, el: bs4.Tag | None, no_iframe: bool = False, no_doc: bool = True) -> bs4.Tag | None:
280 """Get parent."""
282 parent = el.parent if el is not None else None
283 if no_iframe and parent is not None and self.is_iframe(parent): # pragma: no cover
284 parent = None
285 if no_doc and self.is_doc(parent):
286 parent = None
287 return parent
289 @staticmethod
290 def get_tag_name(el: bs4.Tag | None) -> str | None:
291 """Get tag."""
293 return el.name if el is not None else None
295 @staticmethod
296 def get_prefix_name(el: bs4.Tag) -> str | None:
297 """Get prefix."""
299 return el.prefix
301 @staticmethod
302 def get_uri(el: bs4.Tag | None) -> str | None:
303 """Get namespace `URI`."""
305 return el.namespace if el is not None else None
307 @classmethod
308 def get_next_tag(cls, el: bs4.Tag) -> bs4.Tag | None:
309 """Get next sibling tag."""
311 return cls.get_next(el, tags=True) # type: ignore[return-value]
313 @classmethod
314 def get_next(cls, el: bs4.Tag, tags: bool = False) -> bs4.element.PageElement | None:
315 """Get next sibling tag."""
317 sibling = el.next_sibling
318 while tags and not isinstance(sibling, bs4.Tag) and sibling is not None:
319 sibling = sibling.next_sibling
321 if tags and not isinstance(sibling, bs4.Tag):
322 sibling = None
324 return sibling
326 @classmethod
327 def get_previous_tag(cls, el: bs4.Tag, tags: bool = True) -> bs4.Tag | None:
328 """Get previous sibling tag."""
330 return cls.get_previous(el, True) # type: ignore[return-value]
332 @classmethod
333 def get_previous(cls, el: bs4.Tag, tags: bool = False) -> bs4.element.PageElement | None:
334 """Get previous sibling tag."""
336 sibling = el.previous_sibling
337 while tags and not isinstance(sibling, bs4.Tag) and sibling is not None:
338 sibling = sibling.previous_sibling
340 if tags and not isinstance(sibling, bs4.Tag):
341 sibling = None
343 return sibling
345 @staticmethod
346 def has_html_ns(el: bs4.Tag | None) -> bool:
347 """
348 Check if element has an HTML namespace.
350 This is a bit different than whether a element is treated as having an HTML namespace,
351 like we do in the case of `is_html_tag`.
352 """
354 ns = getattr(el, 'namespace') if el is not None else None # noqa: B009
355 return bool(ns and ns == NS_XHTML)
357 @staticmethod
358 def split_namespace(el: bs4.Tag | None, attr_name: str) -> tuple[str | None, str | None]:
359 """Return namespace and attribute name without the prefix."""
361 if el is None: # pragma: no cover
362 return None, None
364 return getattr(attr_name, 'namespace', None), getattr(attr_name, 'name', None)
366 @classmethod
367 def get_attribute_by_name(
368 cls,
369 el: bs4.Tag,
370 name: str,
371 default: str | Sequence[str] | None = None
372 ) -> str | Sequence[str] | None:
373 """Get attribute by name."""
375 value = default
376 if el._is_xml:
377 if name in el.attrs:
378 v = el.attrs[name]
379 value = '' if v is None else v
380 else:
381 for k, v in el.attrs.items():
382 if util.lower(k) == name:
383 value = '' if v is None else v
384 break
385 return value
387 @classmethod
388 def iter_attributes(cls, el: bs4.Tag | None) -> Iterator[tuple[str, str | Sequence[str] | None]]:
389 """Iterate attributes."""
391 if el is not None:
392 for k, v in el.attrs.items():
393 yield k, '' if v is None else v
395 @classmethod
396 def get_classes(cls, el: bs4.Tag) -> Sequence[str]:
397 """Get classes."""
399 classes = cls.get_attribute_by_name(el, 'class', [])
400 if isinstance(classes, str):
401 classes = RE_NOT_WS.findall(classes)
402 return cast(Sequence[str], classes)
404 def get_text(self, el: bs4.Tag, no_iframe: bool = False) -> str:
405 """Get text."""
407 return ''.join(
408 [
409 node for node in self.get_descendants(el, no_iframe=no_iframe) # type: ignore[misc]
410 if self.is_content_string(node)
411 ]
412 )
414 def get_own_text(self, el: bs4.Tag, no_iframe: bool = False) -> list[str]:
415 """Get Own Text."""
417 return [
418 node for node in self.get_contents(el, no_iframe=no_iframe) if self.is_content_string(node) # type: ignore[misc]
419 ]
422class Inputs:
423 """Class for parsing and validating input items."""
425 @staticmethod
426 def validate_day(year: int, month: int, day: int) -> bool:
427 """Validate day."""
429 max_days = LONG_MONTH
430 if month == FEB:
431 max_days = FEB_LEAP_MONTH if ((year % 4 == 0) and (year % 100 != 0)) or (year % 400 == 0) else FEB_MONTH
432 elif month in MONTHS_30:
433 max_days = SHORT_MONTH
434 return 1 <= day <= max_days
436 @staticmethod
437 def validate_week(year: int, week: int) -> bool:
438 """Validate week."""
440 # Validate an ISO week number for `year`.
441 #
442 # Per ISO 8601 rules, the last ISO week of a year is the week
443 # containing Dec 28. Using Dec 28 guarantees we obtain the
444 # correct ISO week-number for the final week of `year`, even in
445 # years where Dec 31 falls in ISO week 01 of the following year.
446 #
447 # Example: if Dec 31 is a Thursday the year's last ISO week will
448 # be week 53; if Dec 31 is a Monday and that week is counted as
449 # week 1 of the next year, Dec 28 still belongs to the final
450 # week of the current ISO year and yields the correct max week.
451 #
452 # `datetime` stops at year 9999, but the calendar repeats every 400
453 # years (a whole number of weeks), so use the matching year from 2000
454 # to 2399, as `calendar.weekday()` does.
455 max_week = datetime(2000 + year % 400, 12, 28).isocalendar()[1]
456 return 1 <= week <= max_week
458 @staticmethod
459 def validate_month(month: int) -> bool:
460 """Validate month."""
462 return 1 <= month <= 12
464 @staticmethod
465 def validate_year(year: int) -> bool:
466 """Validate year."""
468 return 1 <= year
470 @staticmethod
471 def validate_hour(hour: int) -> bool:
472 """Validate hour."""
474 return 0 <= hour <= 23
476 @staticmethod
477 def validate_minutes(minutes: int) -> bool:
478 """Validate minutes."""
480 return 0 <= minutes <= 59
482 @staticmethod
483 def validate_seconds(seconds: float) -> bool:
484 """Validate seconds."""
486 return 0 <= seconds < 60
488 @classmethod
489 def parse_value(cls, itype: str, value: str | None) -> tuple[float, ...] | None:
490 """Parse the input value."""
492 parsed: tuple[float, ...] | None = None
493 if value is None:
494 return value
495 if itype == "date":
496 m = RE_DATE.match(value)
497 if m:
498 year = int(m.group('year'), 10)
499 month = int(m.group('month'), 10)
500 day = int(m.group('day'), 10)
501 if cls.validate_year(year) and cls.validate_month(month) and cls.validate_day(year, month, day):
502 parsed = (year, month, day)
503 elif itype == "month":
504 m = RE_MONTH.match(value)
505 if m:
506 year = int(m.group('year'), 10)
507 month = int(m.group('month'), 10)
508 if cls.validate_year(year) and cls.validate_month(month):
509 parsed = (year, month)
510 elif itype == "week":
511 m = RE_WEEK.match(value)
512 if m:
513 year = int(m.group('year'), 10)
514 week = int(m.group('week'), 10)
515 if cls.validate_year(year) and cls.validate_week(year, week):
516 parsed = (year, week)
517 elif itype == "time":
518 m = RE_TIME.match(value)
519 if m:
520 hour = int(m.group('hour'), 10)
521 minutes = int(m.group('minutes'), 10)
522 seconds = float(m.group('seconds') or 0)
523 if cls.validate_hour(hour) and cls.validate_minutes(minutes) and cls.validate_seconds(seconds):
524 parsed = (hour, minutes, seconds)
525 elif itype == "datetime-local":
526 m = RE_DATETIME.match(value)
527 if m:
528 year = int(m.group('year'), 10)
529 month = int(m.group('month'), 10)
530 day = int(m.group('day'), 10)
531 hour = int(m.group('hour'), 10)
532 minutes = int(m.group('minutes'), 10)
533 seconds = float(m.group('seconds') or 0)
534 if (
535 cls.validate_year(year) and cls.validate_month(month) and cls.validate_day(year, month, day) and
536 cls.validate_hour(hour) and cls.validate_minutes(minutes) and cls.validate_seconds(seconds)
537 ):
538 parsed = (year, month, day, hour, minutes, seconds)
539 elif itype in ("number", "range"):
540 m = RE_NUM.match(value)
541 if m:
542 number = float(m.group('value'))
543 if isfinite(number):
544 parsed = (number,)
545 return parsed
548class CSSMatch(_DocumentNav):
549 """Perform CSS matching."""
551 def __init__(
552 self,
553 selectors: ct.SelectorList,
554 scope: bs4.Tag | None,
555 namespaces: ct.Namespaces | None,
556 flags: int
557 ) -> None:
558 """Initialize."""
560 self.assert_valid_input(scope)
561 self.tag = scope
562 self.cached_meta_lang: list[tuple[str, str]] = []
563 self.cached_default_forms: list[tuple[bs4.Tag, bs4.Tag]] = []
564 self.cached_indeterminate_forms: list[tuple[bs4.Tag, str, bool]] = []
565 self.selectors = selectors
566 self.namespaces: ct.Namespaces | dict[str, str] = {} if namespaces is None else namespaces
567 self.flags = flags
568 self.enable_cache = not bool(self.flags & util.NOCACHE)
569 self.iframe_restrict = False
570 self.nth_cache: dict[Hashable, list[int]] = {}
571 self.sib_cache: dict[Hashable, int] = {}
572 self.dir_cache: dict[int, int] = {}
574 # Find the root element for the whole tree
575 doc = scope
576 parent = self.get_parent(doc)
577 while parent:
578 doc = parent
579 parent = self.get_parent(doc)
580 root: bs4.Tag | None = None
581 if not self.is_doc(doc):
582 root = doc
583 else:
584 for child in self.get_tag_children(doc):
585 root = child
586 break
588 self.root = root
589 self.scope = scope if scope is not doc else root
590 self.has_html_namespace = self.has_html_ns(root)
592 # A document can be both XML and HTML (XHTML)
593 self.is_xml = self.is_xml_tree(doc)
594 self.is_html = not self.is_xml or self.has_html_namespace
596 def reset(self) -> None: # pragma: no cover
597 """Reset."""
599 self.nth_cache.clear()
600 self.sib_cache.clear()
601 self.dir_cache.clear()
603 def supports_namespaces(self) -> bool:
604 """Check if namespaces are supported in the HTML type."""
606 return self.is_xml or self.has_html_namespace
608 def get_tag_ns(self, el: bs4.Tag | None) -> str:
609 """Get tag namespace."""
611 namespace = ''
612 if el is None: # pragma: no cover
613 return namespace
615 if self.supports_namespaces():
616 ns = self.get_uri(el)
617 if ns:
618 namespace = ns
619 else:
620 namespace = NS_XHTML
621 return namespace
623 def is_html_tag(self, el: bs4.Tag | None) -> bool:
624 """Check if tag is in HTML namespace."""
626 return self.get_tag_ns(el) == NS_XHTML
628 def get_tag(self, el: bs4.Tag | None) -> str | None:
629 """Get tag."""
631 name = self.get_tag_name(el)
632 return util.lower(name) if name is not None and not self.is_xml else name
634 def get_prefix(self, el: bs4.Tag) -> str | None:
635 """Get prefix."""
637 prefix = self.get_prefix_name(el)
638 return util.lower(prefix) if prefix is not None and not self.is_xml else prefix
640 def find_bidi(self, el: bs4.Tag) -> int | None:
641 """Get directionality from element text."""
643 stack = [self.get_children(el)]
644 while stack:
645 for node in stack[-1]:
647 # Analyze child text nodes
648 if self.is_tag(node):
650 # Avoid analyzing certain elements specified in the specification.
651 direction = DIR_MAP.get(util.lower(self.get_attribute_by_name(node, 'dir', '')), None)
652 name = self.get_tag(node)
653 if (
654 (name and name in ('bdi', 'script', 'style', 'textarea', 'iframe')) or
655 not self.is_html_tag(node) or
656 direction is not None
657 ):
658 continue # pragma: no cover
660 # Check directionality of this node's text, then resume with the remaining siblings.
661 stack.append(self.get_children(node))
662 break
664 # Skip `doctype` comments, etc.
665 if self.is_special_string(node):
666 continue
668 # Analyze text nodes for directionality.
669 for c in cast('bs4.element.NavigableString', node):
670 bidi = unicodedata.bidirectional(c)
671 if bidi in ('AL', 'R', 'L'):
672 return ct.SEL_DIR_LTR if bidi == 'L' else ct.SEL_DIR_RTL
673 else:
674 # All children have been analyzed and direction could not be determined.
675 stack.pop()
676 return None
678 def extended_language_filter(self, lang_range: str, lang_tag: str) -> bool:
679 """Filter the language tags."""
681 match = True
682 lang_range = RE_WILD_STRIP.sub('-', lang_range).lower()
683 ranges = lang_range.split('-')
684 subtags = lang_tag.lower().split('-')
685 length = len(ranges)
686 slength = len(subtags)
687 rindex = 0
688 sindex = 0
689 r = ranges[rindex]
690 s = subtags[sindex]
692 # Empty specified language should match unspecified language attributes
693 if length == 1 and slength == 1 and not r and r == s:
694 return True
696 # Primary tag needs to match
697 if (r != '*' and r != s) or (r == '*' and slength == 1 and not s):
698 match = False
700 rindex += 1
701 sindex += 1
703 # Match until we run out of ranges
704 while match and rindex < length:
705 r = ranges[rindex]
706 try:
707 s = subtags[sindex]
708 except IndexError:
709 # Ran out of subtags,
710 # but we still have ranges
711 match = False
712 continue
714 # Empty range
715 if not r:
716 match = False
717 continue
719 # Matched range
720 elif s == r:
721 rindex += 1
723 # Implicit wildcard cannot match
724 # singletons
725 elif len(s) == 1:
726 match = False
727 continue
729 # Implicitly matched, so grab next subtag
730 sindex += 1
732 return match
734 def match_attribute_name(
735 self,
736 el: bs4.Tag,
737 attr: str,
738 prefix: str | None
739 ) -> str | Sequence[str] | None:
740 """Match attribute name and return value if it exists."""
742 value = None
743 if self.supports_namespaces():
744 value = None
745 # If we have not defined namespaces, we can't very well find them, so don't bother trying.
746 if prefix:
747 ns = self.namespaces.get(prefix)
748 if ns is None and prefix != '*':
749 return None
750 else:
751 ns = None
753 for k, v in self.iter_attributes(el):
755 # Get attribute parts
756 namespace, name = self.split_namespace(el, k)
758 # Can't match a prefix attribute as we haven't specified one to match
759 # Try to match it normally as a whole `p:a` as selector may be trying `p\:a`.
760 if ns is None:
761 if (self.is_xml and attr == k) or (not self.is_xml and util.lower(attr) == util.lower(k)):
762 value = v
763 break
764 # Coverage is not finding this even though it is executed.
765 # Adding a print statement before this (and erasing coverage) causes coverage to find the line.
766 # Ignore the false positive message.
767 continue # pragma: no cover
769 # We can't match our desired prefix attribute as the attribute doesn't have a prefix
770 if namespace is None or (ns != namespace and prefix != '*'):
771 continue
773 # The attribute doesn't match.
774 if (util.lower(attr) != util.lower(name)) if not self.is_xml else (attr != name):
775 continue
777 value = v
778 break
779 else:
780 for k, v in self.iter_attributes(el):
781 if util.lower(attr) != util.lower(k):
782 continue
783 value = v
784 break
785 return value
787 def match_namespace(self, el: bs4.Tag, tag: ct.SelectorTag) -> bool:
788 """Match the namespace of the element."""
790 match = True
791 namespace = self.get_tag_ns(el)
792 default_namespace = self.namespaces.get('')
793 tag_ns = '' if tag.prefix is None else self.namespaces.get(tag.prefix)
794 # We must match the default namespace if one is not provided
795 if tag.prefix is None and (default_namespace is not None and namespace != default_namespace):
796 match = False
797 # If we specified `|tag`, we must not have a namespace.
798 elif (tag.prefix is not None and tag.prefix == '' and namespace):
799 match = False
800 # Verify prefix matches
801 elif (
802 tag.prefix and
803 tag.prefix != '*' and (tag_ns is None or namespace != tag_ns)
804 ):
805 match = False
806 return match
808 def match_attributes(self, el: bs4.Tag, attributes: tuple[ct.SelectorAttribute, ...]) -> bool:
809 """Match attributes."""
811 match = True
812 if attributes:
813 for a in attributes:
814 temp = self.match_attribute_name(el, a.attribute, a.prefix)
815 pattern = a.xml_type_pattern if self.is_xml and a.xml_type_pattern else a.pattern
816 if temp is None and a.inverse:
817 continue
818 if temp is None:
819 match = False
820 break
821 value = temp if isinstance(temp, str) else ' '.join(temp)
822 if pattern is None:
823 continue
824 elif pattern.match(value) is None:
825 match = False
826 break
827 return match
829 def match_tagname(self, el: bs4.Tag, tag: ct.SelectorTag) -> bool:
830 """Match tag name."""
832 name = (util.lower(tag.name) if not self.is_xml and tag.name is not None else tag.name)
833 return not (
834 name is not None and
835 name not in (self.get_tag(el), '*')
836 )
838 def match_tag(self, el: bs4.Tag, tag: ct.SelectorTag | None) -> bool:
839 """Match the tag."""
841 match = True
842 if tag is not None:
843 # Verify namespace
844 if not self.match_tagname(el, tag):
845 match = False
846 if match and not self.match_namespace(el, tag):
847 match = False
848 return match
850 def match_general_sibling(self, el: bs4.Tag, relation: ct.SelectorList) -> MatchGenerator:
851 """Match general sibling combinator."""
853 found = False
855 if relation[0] is ct.Null: # pragma: no cover
856 return found
858 pkey: int | None = None
859 key: tuple[int, int] | None = None
861 # Get the parent and use the ID for the cache key
862 parent = self.get_parent(el)
863 if parent is None: # pragma: no cover
864 return found
865 pkey = id(parent)
867 # Check the cache to see if we already know where the first sibling is,
868 # and if we do, check if we are on the correct side of it.
869 # If we've previously searched and found no sibling, there is no sibling.
870 # Lastly, if this is our first time, setup the cache.
871 reverse = relation[0].rel_type == REL_HAS_SIBLING
872 start = len(parent) - 1 if reverse else 0
873 if pkey is not None:
874 key = (pkey, id(relation))
875 if key in self.sib_cache:
876 index = self.sib_cache[key]
877 if index >= 0:
878 a, b = (index + 1, start + 1) if reverse else (start, index)
879 return not within(el, parent, a, b)
880 else:
881 return False
882 self.sib_cache[key] = start
884 # Start at the furthest endpoint and walk back towards the element looking for siblings.
885 # The current element counts as a sibling, but will not cause a match.
886 passed = False
887 incr = -1 if reverse else 1
888 for child in self.get_children(parent, start=start, reverse=reverse):
889 start += incr
890 if not isinstance(child, bs4.Tag):
891 continue
893 # Flag that we are passing the element.
894 # Any siblings we find aren't valid for this element.
895 if child is el:
896 passed = True
898 # We found the furthest sibling.
899 if (yield child, relation):
900 found = True
901 break
903 # Cache the index of the sibling or mark as there being no siblings.
904 if pkey and key:
905 self.sib_cache[key] = start if found else -1
907 # If we passed the current element and then found a sibling, it doesn't count as a match.
908 if passed:
909 found = False
911 return found
913 def match_past_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> MatchGenerator:
914 """Match past relationship."""
916 found = False
917 # I don't think this can ever happen, but it makes `mypy` happy
918 if relation[0] is ct.Null: # pragma: no cover
919 return found
921 if relation[0].rel_type == REL_PARENT:
922 parent: bs4.Tag | None = el
923 while not found and parent and (parent := self.get_parent(parent, no_iframe=self.iframe_restrict)):
924 found = parent is not None and (yield parent, relation)
925 elif relation[0].rel_type == REL_CLOSE_PARENT:
926 parent = self.get_parent(el, no_iframe=self.iframe_restrict)
927 found = parent is not None and (yield parent, relation)
928 elif relation[0].rel_type == REL_SIBLING:
929 if self.enable_cache:
930 found = yield from self.match_general_sibling(el, relation)
931 else:
932 sibling: bs4.Tag | None = el
933 while not found and sibling and (sibling := self.get_previous_tag(sibling)):
934 found = sibling is not None and (yield sibling, relation)
935 elif relation[0].rel_type == REL_CLOSE_SIBLING:
936 sibling = self.get_previous_tag(el)
937 found = sibling is not None and (yield sibling, relation)
938 return found
940 def match_future_child(
941 self,
942 parent: bs4.Tag,
943 relation: ct.SelectorList,
944 recursive: bool = False
945 ) -> MatchGenerator:
946 """Match future child."""
948 match = False
949 if recursive:
950 children: Callable[..., Iterator[bs4.Tag]] = self.get_tag_descendants
951 else:
952 children = self.get_tag_children
953 for child in children(parent, no_iframe=self.iframe_restrict):
954 if (yield child, relation):
955 match = True
956 break
957 return match
959 def match_future_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> MatchGenerator:
960 """Match future relationship."""
962 found = False
963 # I don't think this can ever happen, but it makes `mypy` happy
964 if relation[0] is ct.Null: # pragma: no cover
965 return found
967 if relation[0].rel_type == REL_HAS_PARENT:
968 found = yield from self.match_future_child(el, relation, True)
969 elif relation[0].rel_type == REL_HAS_CLOSE_PARENT:
970 found = yield from self.match_future_child(el, relation)
971 elif relation[0].rel_type == REL_HAS_SIBLING:
972 if self.enable_cache:
973 found = yield from self.match_general_sibling(el, relation)
974 else:
975 sibling: bs4.Tag | None = el
976 while not found and sibling and (sibling := self.get_next_tag(sibling)):
977 found = yield sibling, relation
978 elif relation[0].rel_type == REL_HAS_CLOSE_SIBLING:
979 sibling = self.get_next_tag(el)
980 found = sibling is not None and (yield sibling, relation)
981 return found
983 def match_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> MatchGenerator:
984 """Match relationship to other elements."""
986 sel = relation[0]
987 if sel is not ct.Null and sel.rel_type is not None and sel.rel_type.startswith(':'):
988 return self.match_future_relations(el, relation)
989 return self.match_past_relations(el, relation)
991 def match_id(self, el: bs4.Tag, ids: tuple[str, ...]) -> bool:
992 """Match element's ID."""
994 found = True
995 for i in ids:
996 if i != self.get_attribute_by_name(el, 'id', ''):
997 found = False
998 break
999 return found
1001 def match_classes(self, el: bs4.Tag, classes: tuple[str, ...]) -> bool:
1002 """Match element's classes."""
1004 current_classes = self.get_classes(el)
1005 found = True
1006 for c in classes:
1007 if c not in current_classes:
1008 found = False
1009 break
1010 return found
1012 def match_root(self, el: bs4.Tag) -> bool:
1013 """Match element as root."""
1015 is_root = self.is_root(el)
1016 if is_root:
1017 sibling: Any = self.get_previous(el)
1018 while is_root and sibling is not None:
1019 if (
1020 self.is_tag(sibling) or (self.is_content_string(sibling) and sibling.strip()) or
1021 self.is_cdata(sibling)
1022 ):
1023 is_root = False
1024 else:
1025 sibling = self.get_previous(sibling)
1026 if is_root:
1027 sibling = self.get_next(el)
1028 while is_root and sibling is not None:
1029 if (
1030 self.is_tag(sibling) or (self.is_content_string(sibling) and sibling.strip()) or
1031 self.is_cdata(sibling)
1032 ):
1033 is_root = False
1034 else:
1035 sibling = self.get_next(sibling)
1036 return is_root
1038 def match_scope(self, el: bs4.Tag) -> bool:
1039 """Match element as scope."""
1041 return self.scope is el
1043 def match_nth_tag_type(self, el: bs4.Tag, child: bs4.Tag) -> bool:
1044 """Match tag type for `nth` matches."""
1046 return (
1047 (self.get_tag(child) == self.get_tag(el)) and
1048 (self.get_tag_ns(child) == self.get_tag_ns(el))
1049 )
1051 def match_nth(self, el: bs4.Tag, nth: tuple[ct.SelectorNth, ...]) -> MatchGenerator:
1052 """Match `nth` elements."""
1054 key: tuple[int, int, str | None, str | None] | None = None
1055 start = rindex = 0
1056 incr = rincr = 0
1058 # Get the parent and use the ID for the cache key
1059 parent = self.get_parent(el, no_doc=False)
1060 pkey = id(parent) if self.enable_cache and parent else None
1062 # Test element against the `nth` selectors.
1063 matched = True
1064 for n in nth:
1065 matched = False
1066 last = n.last
1067 key = None
1069 # Prepare the child iterator and get the starting, real index and the relative index
1070 if pkey is not None and parent:
1071 # Get last info from the cache
1072 if pkey:
1073 key = (
1074 (pkey, id(n), self.get_tag(el), self.get_tag_ns(el))
1075 if n.of_type
1076 else (pkey, id(n), None, None)
1077 )
1079 valid = False
1080 if key and key in self.nth_cache:
1081 start, rindex = self.nth_cache[key]
1082 if within(el, parent, start):
1083 last = False
1084 rincr = -1 if n.last else 1
1085 valid = True
1087 # Start/overwrite the cache if the cache was empty or invalid
1088 if not valid:
1089 start, rindex = len(parent) - 1 if last else 0, 0
1090 self.nth_cache[key] = [start, rindex]
1091 rincr = 1
1093 incr = 1 if not last else -1
1094 children = self.get_children(parent, start=start, reverse=last)
1096 # Non-cached handling of parented element
1097 elif parent:
1098 rindex = 0
1099 start = len(parent) - 1 if last else 0
1100 rincr = incr = 1
1101 children = self.get_children(parent, start=start, reverse=last)
1103 # No parent, just evaluate the element against the selectors
1104 else:
1105 start = rindex = 0
1106 rincr = incr = 1
1107 children = iter([el])
1109 # Find index of element compared to its siblings and check the index conditions
1110 child: bs4.Tag
1111 for child in children:
1112 start += incr
1114 # We only care about tags
1115 if not self.is_tag(child):
1116 continue
1118 # Handle `of S` in `nth-child` and handle `of-type`
1119 if (
1120 (n.selectors and not (yield child, n.selectors)) or
1121 (n.of_type and not self.match_nth_tag_type(el, child))
1122 ):
1123 if child is el:
1124 # Cached "last" lookups count down from `rindex`, so it must be the
1125 # position `el` would have had, even though `el` did not match.
1126 if last:
1127 rindex += 1
1128 break
1129 continue
1131 # Test the relative index against the `nth` requirement.
1132 rindex += rincr
1133 if child is el:
1134 if n.a != 0:
1135 v = (rindex - n.b) / n.a
1136 matched = v.is_integer() and v >= 0
1137 else:
1138 matched = rindex == n.b and n.b >= 1
1139 break
1141 # "Last index" selectors evaluate first from the bottom and then evaluate
1142 # from the first found element top-down. Start will be incremented in the
1143 # wrong direction, so increment it and step over the current index.
1144 if last:
1145 start += 2
1147 # Update the cache
1148 if key:
1149 self.nth_cache[key] = [start, rindex]
1151 # If we failed to match any `nth` selectors, quit.
1152 if not matched:
1153 break
1155 return matched
1157 def match_empty(self, el: bs4.Tag) -> bool:
1158 """Check if element is empty (if requested)."""
1160 is_empty = True
1161 child: Any
1162 for child in self.get_children(el):
1163 if self.is_tag(child):
1164 is_empty = False
1165 break
1166 elif self.is_content_string(child) and RE_NOT_EMPTY.search(child):
1167 is_empty = False
1168 break
1169 return is_empty
1171 def match_subselectors(self, el: bs4.Tag, selectors: tuple[ct.SelectorList, ...]) -> MatchGenerator:
1172 """Match selectors."""
1174 match = True
1175 for sel in selectors:
1176 if not (yield el, sel):
1177 match = False
1178 return match
1180 def match_contains(self, el: bs4.Tag, contains: tuple[ct.SelectorContains, ...]) -> bool:
1181 """Match element if it contains text."""
1183 match = True
1184 content: str | None = None
1185 own_content: Sequence[str] | None = None
1186 for contain_list in contains:
1187 found = False
1188 for text in contain_list.text:
1189 if contain_list.own:
1190 if own_content is None:
1191 own_content = self.get_own_text(el, no_iframe=self.is_html)
1192 for c in own_content:
1193 if text in c:
1194 found = True
1195 break
1196 if found:
1197 break
1198 else:
1199 if content is None:
1200 content = self.get_text(el, no_iframe=self.is_html)
1201 if text in content:
1202 found = True
1203 break
1204 if not found:
1205 match = False
1206 return match
1208 def match_default(self, el: bs4.Tag) -> bool:
1209 """Match default."""
1211 match = False
1213 # Find this input's form
1214 form: bs4.Tag | None = None
1215 parent = self.get_parent(el, no_iframe=True)
1216 while parent and form is None:
1217 if self.get_tag(parent) == 'form' and self.is_html_tag(parent):
1218 form = parent
1219 else:
1220 parent = self.get_parent(parent, no_iframe=True)
1222 if form is not None:
1223 # Look in form cache to see if we've already located its default button
1224 found_form = False
1225 for f, t in self.cached_default_forms:
1226 if f is form:
1227 found_form = True
1228 if t is el:
1229 match = True
1230 break
1232 # We didn't have the form cached, so look for its default button
1233 if not found_form:
1234 for child in self.get_tag_descendants(form, no_iframe=True):
1235 name = self.get_tag(child)
1236 # Can't do nested forms (haven't figured out why we never hit this)
1237 if name == 'form': # pragma: no cover
1238 break
1239 if name in ('input', 'button'):
1240 v = util.lower(cast(str, self.get_attribute_by_name(child, 'type', '')))
1241 if name == 'input':
1242 submit = v in ('submit', 'image')
1243 else:
1244 # A button with a missing or invalid `type` submits, unless it invokes a command or is
1245 # a child of a `select`.
1246 submit = v == 'submit' or (
1247 v not in ('button', 'reset') and
1248 self.get_attribute_by_name(child, 'command') is None and
1249 self.get_attribute_by_name(child, 'commandfor') is None and
1250 self.get_tag(self.get_parent(child, no_iframe=True)) != 'select'
1251 )
1252 if submit:
1253 self.cached_default_forms.append((form, child))
1254 if el is child:
1255 match = True
1256 break
1257 return match
1259 def match_indeterminate(self, el: bs4.Tag) -> bool:
1260 """Match default."""
1262 match = False
1263 name = cast(str, self.get_attribute_by_name(el, 'name'))
1265 def get_parent_form(el: bs4.Tag) -> bs4.Tag | None:
1266 """Find this input's form."""
1267 form = None
1268 parent = self.get_parent(el, no_iframe=True)
1269 while form is None:
1270 if self.get_tag(parent) == 'form' and self.is_html_tag(parent):
1271 form = parent
1272 break
1273 last_parent = parent
1274 parent = self.get_parent(parent, no_iframe=True)
1275 if parent is None:
1276 form = last_parent
1277 break
1278 return form
1280 form = get_parent_form(el)
1282 # Look in form cache to see if we've already evaluated that its fellow radio buttons are indeterminate
1283 if form is not None:
1284 found_form = False
1285 for f, n, i in self.cached_indeterminate_forms:
1286 if f is form and n == name:
1287 found_form = True
1288 if i is True:
1289 match = True
1290 break
1292 # We didn't have the form cached, so validate that the radio button is indeterminate
1293 if not found_form:
1294 checked = False
1295 for child in self.get_tag_descendants(form, no_iframe=True):
1296 if child is el:
1297 continue
1298 tag_name = self.get_tag(child)
1299 if tag_name == 'input':
1300 is_radio = False
1301 check = False
1302 has_name = False
1303 for k, v in self.iter_attributes(child):
1304 if util.lower(k) == 'type' and util.lower(v) == 'radio':
1305 is_radio = True
1306 elif util.lower(k) == 'name' and v == name:
1307 has_name = True
1308 elif util.lower(k) == 'checked':
1309 check = True
1310 if is_radio and check and has_name and get_parent_form(child) is form:
1311 checked = True
1312 break
1313 if checked:
1314 break
1315 if not checked:
1316 match = True
1317 self.cached_indeterminate_forms.append((form, name, match))
1319 return match
1321 def match_lang(self, el: bs4.Tag, langs: tuple[ct.SelectorLang, ...]) -> bool:
1322 """Match languages."""
1324 match = False
1325 has_ns = self.supports_namespaces()
1326 root = self.root
1327 has_html_namespace = self.has_html_namespace
1329 # Walk parents looking for `lang` (HTML) or `xml:lang` XML property.
1330 parent: bs4.Tag | None = el
1331 found_lang = None
1332 last = None
1333 while not found_lang:
1334 has_html_ns = self.has_html_ns(parent)
1335 for k, v in self.iter_attributes(parent):
1336 attr_ns, attr = self.split_namespace(parent, k)
1337 if (
1338 ((not has_ns or has_html_ns) and (util.lower(k) if not self.is_xml else k) == 'lang') or
1339 (
1340 has_ns and not has_html_ns and attr_ns == NS_XML and
1341 (util.lower(attr) if not self.is_xml and attr is not None else attr) == 'lang'
1342 )
1343 ):
1344 found_lang = v
1345 break
1346 last = parent
1347 parent = self.get_parent(parent, no_iframe=self.is_html)
1349 if parent is None:
1350 root = last
1351 has_html_namespace = self.has_html_ns(root)
1352 parent = last
1353 break
1355 # Use cached meta language.
1356 if found_lang is None and self.cached_meta_lang:
1357 for cache in self.cached_meta_lang:
1358 if root is not None and cast(str, root) is cache[0]:
1359 found_lang = cache[1]
1361 # If we couldn't find a language, and the document is HTML, look to meta to determine language.
1362 if found_lang is None and (not self.is_xml or (has_html_namespace and root and root.name == 'html')):
1363 # Find head
1364 found = False
1365 for tag in ('html', 'head'):
1366 found = False
1367 for child in self.get_tag_children(parent, no_iframe=self.is_html):
1368 if self.get_tag(child) == tag and self.is_html_tag(child):
1369 found = True
1370 parent = child
1371 break
1372 if found: # pragma: no cover
1373 break
1375 # Search meta tags
1376 if found and parent is not None:
1377 for child2 in parent:
1378 if isinstance(child2, bs4.Tag) and self.get_tag(child2) == 'meta' and self.is_html_tag(parent):
1379 c_lang = False
1380 content = None
1381 for k, v in self.iter_attributes(child2):
1382 if util.lower(k) == 'http-equiv' and util.lower(v) == 'content-language':
1383 c_lang = True
1384 if util.lower(k) == 'content':
1385 content = v
1386 if c_lang and content:
1387 found_lang = content
1388 self.cached_meta_lang.append((cast(str, root), cast(str, found_lang)))
1389 break
1390 if found_lang is not None:
1391 break
1392 if found_lang is None:
1393 self.cached_meta_lang.append((cast(str, root), ''))
1395 # If we determined a language, compare.
1396 if found_lang is not None:
1397 for patterns in langs:
1398 match = False
1399 for pattern in patterns:
1400 if self.extended_language_filter(pattern, cast(str, found_lang)):
1401 match = True
1402 if not match:
1403 break
1405 return match
1407 def match_dir(self, el: bs4.Tag | None, directionality: int) -> bool:
1408 """Check directionality."""
1410 # If we have to match both left and right, we can't match either.
1411 if directionality & ct.SEL_DIR_LTR and directionality & ct.SEL_DIR_RTL:
1412 return False
1414 # Every element visited while resolving shares the resolved direction,
1415 # so cache it to avoid walking the same ancestors again for each element.
1416 visited: list[int] = []
1417 direction = self.resolve_dir(el, visited)
1418 if self.enable_cache:
1419 for key in visited:
1420 self.dir_cache[key] = direction
1421 return direction == directionality
1423 def resolve_dir(self, el: bs4.Tag | None, visited: list[int]) -> int:
1424 """Resolve the direction of an element, returning `0` if it cannot be resolved."""
1426 while True:
1427 if el is None or not self.is_html_tag(el):
1428 return 0
1430 key = id(el)
1431 if key in self.dir_cache:
1432 return self.dir_cache[key]
1433 visited.append(key)
1435 # Element has defined direction of left to right or right to left
1436 direction = DIR_MAP.get(util.lower(self.get_attribute_by_name(el, 'dir', '')), None)
1437 if direction not in (None, 0):
1438 return direction
1440 # Element is the document element (the root) and no direction assigned, assume left to right.
1441 is_root = self.is_root(el)
1442 if is_root and direction is None:
1443 return ct.SEL_DIR_LTR
1445 # If `input[type=telephone]` and no direction is assigned, assume left to right.
1446 name = self.get_tag(el)
1447 is_input = name == 'input'
1448 is_textarea = name == 'textarea'
1449 is_bdi = name == 'bdi'
1450 itype = util.lower(self.get_attribute_by_name(el, 'type', '')) if is_input else ''
1451 if is_input and itype == 'tel' and direction is None:
1452 return ct.SEL_DIR_LTR
1454 # Auto handling for text inputs
1455 if ((is_input and itype in ('text', 'search', 'tel', 'url', 'email')) or is_textarea) and direction == 0:
1456 if is_textarea:
1457 value = ''.join(
1458 node for node in self.get_contents(el, no_iframe=True) # type: ignore[misc]
1459 if self.is_content_string(node)
1460 )
1461 else:
1462 value = cast(str, self.get_attribute_by_name(el, 'value', ''))
1463 for c in value:
1464 bidi = unicodedata.bidirectional(c)
1465 if bidi in ('AL', 'R', 'L'):
1466 direction = ct.SEL_DIR_LTR if bidi == 'L' else ct.SEL_DIR_RTL
1467 return ct.SEL_DIR_LTR if bidi == 'L' else ct.SEL_DIR_RTL
1468 # No strong directional characters (or no value), assume left to right
1469 return ct.SEL_DIR_LTR
1471 # Auto handling for `bdi` and other non text inputs.
1472 if (is_bdi and direction is None) or direction == 0:
1473 direction = self.find_bidi(el)
1474 if direction is not None:
1475 return direction
1476 # Auto directionality could not be determined, assume left to right
1477 return ct.SEL_DIR_LTR
1479 # Match parents direction
1480 el = self.get_parent(el, no_iframe=True)
1482 def match_range(self, el: bs4.Tag, condition: int) -> bool:
1483 """
1484 Match range.
1486 Behavior is modeled after what we see in browsers. Browsers seem to evaluate
1487 if the value is out of range, and if not, it is in range. So a missing value
1488 will not evaluate out of range; therefore, value is in range. Personally, I
1489 feel like this should evaluate as neither in or out of range.
1490 """
1492 out_of_range = False
1494 itype = util.lower(cast(str, self.get_attribute_by_name(el, 'type', '')))
1495 mn = Inputs.parse_value(itype, cast(str, self.get_attribute_by_name(el, 'min', None)))
1496 mx = Inputs.parse_value(itype, cast(str, self.get_attribute_by_name(el, 'max', None)))
1498 # There is no valid min or max, so we cannot evaluate a range
1499 if mn is None and mx is None:
1500 return False
1502 value = Inputs.parse_value(itype, cast(str, self.get_attribute_by_name(el, 'value', None)))
1503 if value is not None:
1504 if itype in ("date", "datetime-local", "month", "week", "number", "range"):
1505 if mn is not None and value < mn:
1506 out_of_range = True
1507 if not out_of_range and mx is not None and value > mx:
1508 out_of_range = True
1509 elif itype == "time":
1510 if mn is not None and mx is not None and mn > mx:
1511 # Time is periodic, so this is a reversed/discontinuous range
1512 if value < mn and value > mx:
1513 out_of_range = True
1514 else:
1515 if mn is not None and value < mn:
1516 out_of_range = True
1517 if not out_of_range and mx is not None and value > mx:
1518 out_of_range = True
1520 return not out_of_range if condition & ct.SEL_IN_RANGE else out_of_range
1522 def match_defined(self, el: bs4.Tag) -> bool:
1523 """
1524 Match defined.
1526 `:defined` is related to custom elements in a browser.
1528 - If the document is XML (not XHTML), all tags will match.
1529 - Tags that are not custom (don't have a hyphen) are marked defined.
1530 - If the tag has a prefix (without or without a namespace), it will not match.
1532 This is of course requires the parser to provide us with the proper prefix and namespace info,
1533 if it doesn't, there is nothing we can do.
1534 """
1536 name = self.get_tag(el)
1537 return (
1538 name is not None and (
1539 name.find('-') == -1 or
1540 name.find(':') != -1 or
1541 self.get_prefix(el) is not None
1542 )
1543 )
1545 def match_placeholder_shown(self, el: bs4.Tag) -> bool:
1546 """
1547 Match placeholder shown according to HTML spec.
1549 - text area should be checked if they have content. A single newline does not count as content.
1551 """
1553 match = False
1554 content = self.get_text(el)
1555 if content in ('', '\n'):
1556 match = True
1558 return match
1560 def match_editable(self, el: bs4.Tag) -> bool:
1561 """
1562 Match editing hosts and the elements that are editable inside them.
1564 The nearest `contenteditable` attribute with a valid state decides: an empty value, `true` and
1565 `plaintext-only` make the element editable, and `false` does not. Any other value inherits
1566 the state of the parent.
1567 """
1569 node = el # type: bs4.Tag | None
1570 while node is not None:
1571 state = self.get_attribute_by_name(node, 'contenteditable')
1572 if isinstance(state, str):
1573 state = util.lower(state)
1574 if state in ('', 'true', 'plaintext-only'):
1575 return True
1576 if state == 'false':
1577 return False
1578 node = self.get_parent(node)
1579 return False
1581 def match_selectors(self, el: bs4.Tag, selectors: ct.SelectorList) -> bool:
1582 """
1583 Check if element matches one of the selectors.
1585 Since some selectors can have complex relationships, they may need to recursively
1586 check if another relative element matches specific selectors. To avoid recursion,
1587 generators are yielded instead whenever it needs to check if a relative element
1588 satisfies specific selectors. These can be compared and the result sent back.
1589 We can check the end result of the generator once it is satisfied.
1590 """
1592 # Most matches never need a nested match, so avoid setting up the stack unless needed.
1593 # `frame` yields a `bool` when it is done.
1594 frame = self._match_selectors(el, selectors)
1595 item = next(frame)
1596 if item.__class__ is bool:
1597 return item
1599 # We need to setup a stack as a selector has sent back a match request.
1600 stack = [frame, self._match_selectors(*cast('MatchRequest', item))]
1601 result: bool | None = None
1602 while True:
1603 item = stack[-1].send(result) # type: ignore[arg-type]
1604 if item.__class__ is bool:
1605 stack.pop()
1606 if not stack:
1607 return item
1608 result = item
1609 else:
1610 stack.append(self._match_selectors(*cast('MatchRequest', item)))
1611 result = None
1613 def _match_selectors(self, el: bs4.Tag, selectors: ct.SelectorList) -> Generator[MatchRequest | bool, bool, None]:
1614 """
1615 Check if element matches one of the selectors, yielding requests to match nested selectors.
1617 Yields `MatchRequests` or affirmation as to whether a selector was matched. The match request
1618 requires a boolean to be sent back to communicate whether the match was successful.
1619 """
1621 match = False
1622 is_not = selectors.is_not
1623 is_html = selectors.is_html
1625 # Internal selector lists that use the HTML flag, will automatically get the `html` namespace.
1626 if is_html:
1627 namespaces = self.namespaces
1628 iframe_restrict = self.iframe_restrict
1629 self.namespaces = {'html': NS_XHTML}
1630 self.iframe_restrict = True
1632 if not is_html or self.is_html:
1633 for selector in selectors:
1634 match = is_not
1635 # We have a un-matchable situation (like `:focus` as you can focus an element in this environment)
1636 if selector is ct.Null:
1637 continue
1638 # Verify tag matches
1639 if not self.match_tag(el, selector.tag):
1640 continue
1641 # Verify tag is defined
1642 if selector.flags & ct.SEL_DEFINED and not self.match_defined(el):
1643 continue
1644 # Verify element is root
1645 if selector.flags & ct.SEL_ROOT and not self.match_root(el):
1646 continue
1647 # Verify element is scope
1648 if selector.flags & ct.SEL_SCOPE and not self.match_scope(el):
1649 continue
1650 # Verify element has placeholder shown
1651 if selector.flags & ct.SEL_PLACEHOLDER_SHOWN and not self.match_placeholder_shown(el):
1652 continue
1653 # Verify element is an editing host or editable
1654 if selector.flags & ct.SEL_EDITABLE and not self.match_editable(el):
1655 continue
1656 # Verify `nth` matches
1657 if selector.nth and not (yield from self.match_nth(el, selector.nth)):
1658 continue
1659 if selector.flags & ct.SEL_EMPTY and not self.match_empty(el):
1660 continue
1661 # Verify id matches
1662 if selector.ids and not self.match_id(el, selector.ids):
1663 continue
1664 # Verify classes match
1665 if selector.classes and not self.match_classes(el, selector.classes):
1666 continue
1667 # Verify attribute(s) match
1668 if not self.match_attributes(el, selector.attributes):
1669 continue
1670 # Verify ranges
1671 if selector.flags & RANGES and not self.match_range(el, selector.flags & RANGES):
1672 continue
1673 # Verify language patterns
1674 if selector.lang and not self.match_lang(el, selector.lang):
1675 continue
1676 # Verify pseudo selector patterns
1677 if selector.selectors and not (yield from self.match_subselectors(el, selector.selectors)):
1678 continue
1679 # Verify relationship selectors
1680 if selector.relation and not (yield from self.match_relations(el, selector.relation)):
1681 continue
1682 # Validate that the current default selector match corresponds to the first submit button in the form
1683 if selector.flags & ct.SEL_DEFAULT and not self.match_default(el):
1684 continue
1685 # Validate that the unset radio button is among radio buttons with the same name in a form that are
1686 # also not set.
1687 if selector.flags & ct.SEL_INDETERMINATE and not self.match_indeterminate(el):
1688 continue
1689 # Validate element directionality
1690 if selector.flags & DIR_FLAGS and not self.match_dir(el, selector.flags & DIR_FLAGS):
1691 continue
1692 # Validate that the tag contains the specified text.
1693 if selector.contains and not self.match_contains(el, selector.contains):
1694 continue
1695 match = not is_not
1696 break
1698 # Restore actual namespaces being used for external selector lists
1699 if is_html:
1700 self.namespaces = namespaces
1701 self.iframe_restrict = iframe_restrict
1703 yield match
1705 def select(self, limit: int = 0) -> Iterator[bs4.Tag]:
1706 """Match all tags under the targeted tag."""
1708 lim = None if limit < 1 else limit
1710 for child in self.get_tag_descendants(self.tag):
1711 if self.match(child):
1712 yield child
1713 if lim is not None:
1714 lim -= 1
1715 if lim < 1:
1716 break
1718 def closest(self) -> bs4.Tag | None:
1719 """Match closest ancestor."""
1721 current: bs4.Tag | None = self.tag
1722 closest = None
1723 while closest is None and current is not None:
1724 if self.match(current):
1725 closest = current
1726 else:
1727 current = self.get_parent(current)
1728 return closest
1730 def filter(self) -> list[bs4.Tag]: # noqa A001
1731 """Filter tag's children."""
1733 return [
1734 tag for tag in self.get_contents(self.tag)
1735 if isinstance(tag, bs4.Tag) and self.match(tag)
1736 ]
1738 def match(self, el: bs4.Tag) -> bool:
1739 """Match."""
1741 return not self.is_doc(el) and self.is_tag(el) and self.match_selectors(el, self.selectors)
1744class SoupSieve(ct.Immutable):
1745 """Compiled Soup Sieve selector matching object."""
1747 pattern: str
1748 selectors: ct.SelectorList
1749 namespaces: ct.Namespaces | None
1750 custom: dict[str, str]
1751 flags: int
1753 __slots__ = ("pattern", "selectors", "namespaces", "custom", "flags", "_hash")
1755 def __init__(
1756 self,
1757 pattern: str,
1758 selectors: ct.SelectorList,
1759 namespaces: ct.Namespaces | None,
1760 custom: ct.CustomSelectors | None,
1761 flags: int
1762 ):
1763 """Initialize."""
1765 super().__init__(
1766 pattern=pattern,
1767 selectors=selectors,
1768 namespaces=namespaces,
1769 custom=custom,
1770 flags=flags
1771 )
1773 def match(self, tag: bs4.Tag) -> bool:
1774 """Match."""
1776 return CSSMatch(self.selectors, tag, self.namespaces, self.flags).match(tag)
1778 def closest(self, tag: bs4.Tag) -> bs4.Tag | None:
1779 """Match closest ancestor."""
1781 return CSSMatch(self.selectors, tag, self.namespaces, self.flags).closest()
1783 def filter(self, iterable: Iterable[bs4.Tag]) -> list[bs4.Tag]: # noqa A001
1784 """
1785 Filter.
1787 `CSSMatch` can cache certain searches for tags of the same document,
1788 so if we are given a tag, all tags are from the same document,
1789 and we can take advantage of the optimization.
1791 Any other kind of iterable could have tags from different documents or detached tags,
1792 so for those, we use a new `CSSMatch` for each item in the iterable.
1793 """
1795 if isinstance(iterable, bs4.Tag):
1796 return CSSMatch(self.selectors, iterable, self.namespaces, self.flags).filter()
1797 else:
1798 # There is no guarantee that elements are from the same document, evaluate them separately.
1799 return [node for node in iterable if not CSSMatch.is_navigable_string(node) and self.match(node)]
1801 def select_one(self, tag: bs4.Tag) -> bs4.Tag | None:
1802 """Select a single tag."""
1804 tags = self.select(tag, limit=1)
1805 return tags[0] if tags else None
1807 def select(self, tag: bs4.Tag, limit: int = 0) -> list[bs4.Tag]:
1808 """Select the specified tags."""
1810 return list(self.iselect(tag, limit))
1812 def iselect(self, tag: bs4.Tag, limit: int = 0) -> Iterator[bs4.Tag]:
1813 """Iterate the specified tags."""
1815 yield from CSSMatch(self.selectors, tag, self.namespaces, self.flags).select(limit)
1817 def __repr__(self) -> str: # pragma: no cover
1818 """Representation."""
1820 return (
1821 f"SoupSieve(pattern={self.pattern!r}, namespaces={self.namespaces!r}, "
1822 f"custom={self.custom!r}, flags={self.flags!r})"
1823 )
1825 __str__ = __repr__
1828ct.pickle_register(SoupSieve)