Skip to content

Commit c44c595

Browse files
author
annbgn
committed
add support for :has(<relative operator>)
1 parent ffb931c commit c44c595

File tree

3 files changed

+68
-0
lines changed

3 files changed

+68
-0
lines changed

cssselect/parser.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,30 @@ def specificity(self):
250250
return a1 + a2, b1 + b2, c1 + c2
251251

252252

253+
class Relation(object):
254+
"""
255+
Represents selector:has(subselector)
256+
"""
257+
def __init__(self, selector, subselector):
258+
self.selector = selector
259+
self.subselector = subselector
260+
261+
def __repr__(self):
262+
return '%s[%r:has(%r)]' % (
263+
self.__class__.__name__, self.selector, self.subselector)
264+
265+
def canonical(self):
266+
subsel = self.subselector.canonical()
267+
if len(subsel) > 1:
268+
subsel = subsel.lstrip('*')
269+
return '%s:has(%s)' % (self.selector.canonical(), subsel)
270+
271+
def specificity(self):
272+
a1, b1, c1 = self.selector.specificity()
273+
a2, b2, c2 = self.subselector.specificity()
274+
return a1 + a2, b1 + b2, c1 + c2
275+
276+
253277
class Attrib(object):
254278
"""
255279
Represents selector[namespace|attrib operator value]
@@ -538,6 +562,9 @@ def parse_simple_selector(stream, inside_negation=False):
538562
if next != ('DELIM', ')'):
539563
raise SelectorSyntaxError("Expected ')', got %s" % (next,))
540564
result = Negation(result, argument)
565+
elif ident.lower() == 'has':
566+
arguments = parse_relative_selector(stream)
567+
result = Relation(result, arguments)
541568
else:
542569
result = Function(result, ident, parse_arguments(stream))
543570
else:
@@ -564,6 +591,24 @@ def parse_arguments(stream):
564591
"Expected an argument, got %s" % (next,))
565592

566593

594+
def parse_relative_selector(stream):
595+
arguments = []
596+
stream.skip_whitespace()
597+
next = stream.next()
598+
if next in [('DELIM', '+'), ('DELIM', '-'), ('DELIM', '>'), ('DELIM', '~')]:
599+
arguments.append(next)
600+
while 1:
601+
stream.skip_whitespace()
602+
next = stream.next()
603+
if next.type in ('IDENT', 'STRING', 'NUMBER'):
604+
arguments.append(Element(element=next.value))
605+
elif next == ('DELIM', ')'):
606+
return arguments
607+
else:
608+
raise SelectorSyntaxError(
609+
"Expected an argument, got %s" % (next,))
610+
611+
567612
def parse_attrib(selector, stream):
568613
stream.skip_whitespace()
569614
attrib = stream.next_ident_or_star()

cssselect/xpath.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ def xpath_negation(self, negation):
272272
else:
273273
return xpath.add_condition('0')
274274

275+
def xpath_relation(self, relation):
276+
xpath = self.xpath(relation.selector)
277+
combinator, subselector, *_ = relation.subselector
278+
method = getattr(self, 'xpath_%s_combinator' % self.combinator_mapping[combinator.value])
279+
return method(xpath, self.xpath(subselector))
280+
275281
def xpath_function(self, function):
276282
"""Translate a functional pseudo-class."""
277283
method = 'xpath_%s_function' % function.name.replace('-', '_')

tests/test_cssselect.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,13 @@ def specificity(css):
266266
assert specificity(':not(:empty)') == (0, 1, 0)
267267
assert specificity(':not(#foo)') == (1, 0, 0)
268268

269+
# assert specificity(':has(*)') == (0, 0, 0)
270+
# assert specificity(':has(foo)') == (0, 0, 1)
271+
# assert specificity(':has(.foo)') == (0, 1, 0)
272+
# assert specificity(':has([foo])') == (0, 1, 0)
273+
# assert specificity(':has(:empty)') == (0, 1, 0)
274+
# assert specificity(':has(#foo)') == (1, 0, 0)
275+
269276
assert specificity('foo:empty') == (0, 1, 1)
270277
assert specificity('foo:before') == (0, 0, 2)
271278
assert specificity('foo::before') == (0, 0, 2)
@@ -300,6 +307,12 @@ def css2css(css, res=None):
300307
css2css(':not(*[foo])', ':not([foo])')
301308
css2css(':not(:empty)')
302309
css2css(':not(#foo)')
310+
# css2css(':has(*)')
311+
# css2css(':has(foo)')
312+
# css2css(':has(*.foo)', ':has(.foo)')
313+
# css2css(':has(*[foo])', ':has([foo])')
314+
# css2css(':has(:empty)')
315+
# css2css(':has(#foo)')
303316
css2css('foo:empty')
304317
css2css('foo::before')
305318
css2css('foo:empty::before')
@@ -492,6 +505,7 @@ def xpath(css):
492505
"e[not(count(preceding-sibling::*) mod 2 = 0)]")
493506
assert xpath('e:nOT(*)') == (
494507
"e[0]") # never matches
508+
assert xpath('e:has(> f)') == 'e/f'
495509
assert xpath('e f') == (
496510
"e/descendant-or-self::*/f")
497511
assert xpath('e > f') == (
@@ -863,6 +877,9 @@ def pcss(main, *selectors, **kwargs):
863877
assert pcss('ol :Not(li[class])') == [
864878
'first-li', 'second-li', 'li-div',
865879
'fifth-li', 'sixth-li', 'seventh-li']
880+
# assert pcss('link:has(*)') == []
881+
# assert pcss('link:has([href])') == ['link-href']
882+
# assert pcss('ol:has(div)') == ['first-ol']
866883
assert pcss('ol.a.b.c > li.c:nth-child(3)') == ['third-li']
867884

868885
# Invalid characters in XPath element names, should not crash

0 commit comments

Comments
 (0)