Skip to content

Commit 12320dc

Browse files
committed
Implement rudimentary support for the relational pseudo-class :has()
1 parent 518e3e1 commit 12320dc

File tree

4 files changed

+96
-12
lines changed

4 files changed

+96
-12
lines changed

cssselect/parser.py

+54-9
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]
@@ -456,7 +480,7 @@ def parse_selector(stream):
456480
return result, pseudo_element
457481

458482

459-
def parse_simple_selector(stream, inside_negation=False):
483+
def parse_simple_selector(stream, nestable=True):
460484
stream.skip_whitespace()
461485
selector_start = len(stream.used)
462486
peek = stream.peek()
@@ -479,7 +503,7 @@ def parse_simple_selector(stream, inside_negation=False):
479503
while 1:
480504
peek = stream.peek()
481505
if peek.type in ('S', 'EOF') or peek.is_delim(',', '+', '>', '~') or (
482-
inside_negation and peek == ('DELIM', ')')):
506+
not nestable and peek == ('DELIM', ')')):
483507
break
484508
if pseudo_element:
485509
raise SelectorSyntaxError(
@@ -507,7 +531,8 @@ def parse_simple_selector(stream, inside_negation=False):
507531
pseudo_element, parse_arguments(stream))
508532
continue
509533
ident = stream.next_ident()
510-
if ident.lower() in ('first-line', 'first-letter',
534+
lowercase_indent = ident.lower()
535+
if lowercase_indent in ('first-line', 'first-letter',
511536
'before', 'after'):
512537
# Special case: CSS 2.1 pseudo-elements can have a single ':'
513538
# Any new pseudo-element must have two.
@@ -523,13 +548,16 @@ def parse_simple_selector(stream, inside_negation=False):
523548
'Got immediate child pseudo-element ":scope" '
524549
'not at the start of a selector')
525550
continue
551+
526552
stream.next()
527553
stream.skip_whitespace()
528-
if ident.lower() == 'not':
529-
if inside_negation:
530-
raise SelectorSyntaxError('Got nested :not()')
554+
555+
if lowercase_indent == 'not':
556+
if not nestable:
557+
raise SelectorSyntaxError(
558+
'Got :not() within :has() or another :not()')
531559
argument, argument_pseudo_element = parse_simple_selector(
532-
stream, inside_negation=True)
560+
stream, nestable=False)
533561
next = stream.next()
534562
if argument_pseudo_element:
535563
raise SelectorSyntaxError(
@@ -538,8 +566,25 @@ def parse_simple_selector(stream, inside_negation=False):
538566
if next != ('DELIM', ')'):
539567
raise SelectorSyntaxError("Expected ')', got %s" % (next,))
540568
result = Negation(result, argument)
541-
else:
542-
result = Function(result, ident, parse_arguments(stream))
569+
continue
570+
571+
if lowercase_indent == 'has':
572+
if not nestable:
573+
raise SelectorSyntaxError(
574+
'Got :has() within :not() or another :has()')
575+
argument, argument_pseudo_element = parse_simple_selector(
576+
stream, nestable=False)
577+
next = stream.next()
578+
if argument_pseudo_element:
579+
raise SelectorSyntaxError(
580+
'Got pseudo-element ::%s inside :has() at %s'
581+
% (argument_pseudo_element, next.pos))
582+
if next != ('DELIM', ')'):
583+
raise SelectorSyntaxError("Expected ')', got %s" % (next,))
584+
result = Relation(result, argument)
585+
continue
586+
587+
result = Function(result, ident, parse_arguments(stream))
543588
else:
544589
raise SelectorSyntaxError(
545590
"Expected selector, got %s" % (peek,))

cssselect/xpath.py

+8
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,14 @@ 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+
sub_xpath = self.xpath(relation.subselector)
278+
if sub_xpath.condition:
279+
return xpath.add_condition('%s' % sub_xpath.condition)
280+
else:
281+
return xpath.add_condition('*')
282+
275283
def xpath_function(self, function):
276284
"""Translate a functional pseudo-class."""
277285
method = 'xpath_%s_function' % function.name.replace('-', '_')

tests/test_cssselect.py

+33-2
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ def parse_many(first, *others):
145145
'Hash[Element[div]#foobar]']
146146
assert parse_many('div:not(div.foo)') == [
147147
'Negation[Element[div]:not(Class[Element[div].foo])]']
148+
assert parse_many('div:has(div.foo)') == [
149+
'Relation[Element[div]:has(Class[Element[div].foo])]']
148150
assert parse_many('td ~ th') == [
149151
'CombinedSelector[Element[td] ~ Element[th]]']
150152
assert parse_many(':scope > foo') == [
@@ -266,6 +268,13 @@ def specificity(css):
266268
assert specificity(':not(:empty)') == (0, 1, 0)
267269
assert specificity(':not(#foo)') == (1, 0, 0)
268270

271+
assert specificity(':has(*)') == (0, 0, 0)
272+
assert specificity(':has(foo)') == (0, 0, 1)
273+
assert specificity(':has(.foo)') == (0, 1, 0)
274+
assert specificity(':has([foo])') == (0, 1, 0)
275+
assert specificity(':has(:empty)') == (0, 1, 0)
276+
assert specificity(':has(#foo)') == (1, 0, 0)
277+
269278
assert specificity('foo:empty') == (0, 1, 1)
270279
assert specificity('foo:before') == (0, 0, 2)
271280
assert specificity('foo::before') == (0, 0, 2)
@@ -300,6 +309,12 @@ def css2css(css, res=None):
300309
css2css(':not(*[foo])', ':not([foo])')
301310
css2css(':not(:empty)')
302311
css2css(':not(#foo)')
312+
css2css(':has(*)')
313+
css2css(':has(foo)')
314+
css2css(':has(*.foo)', ':has(.foo)')
315+
css2css(':has(*[foo])', ':has([foo])')
316+
css2css(':has(:empty)')
317+
css2css(':has(#foo)')
303318
css2css('foo:empty')
304319
css2css('foo::before')
305320
css2css('foo:empty::before')
@@ -371,8 +386,8 @@ def get_error(css):
371386
"Got pseudo-element ::before not at the end of a selector")
372387
assert get_error(':not(:before)') == (
373388
"Got pseudo-element ::before inside :not() at 12")
374-
assert get_error(':not(:not(a))') == (
375-
"Got nested :not()")
389+
assert get_error(':has(:before)') == (
390+
"Got pseudo-element ::before inside :has() at 12")
376391
assert get_error(':scope > div :scope header') == (
377392
'Got immediate child pseudo-element ":scope" not at the start of a selector'
378393
)
@@ -381,6 +396,16 @@ def get_error(css):
381396
)
382397
assert get_error('> div p') == ("Expected selector, got <DELIM '>' at 0>")
383398

399+
# Unsupported nesting
400+
assert get_error(':has(:has(a))') == (
401+
'Got :has() within :not() or another :has()')
402+
assert get_error(':has(:not(a))') == (
403+
'Got :not() within :has() or another :not()')
404+
assert get_error(':not(:has(a))') == (
405+
'Got :has() within :not() or another :has()')
406+
assert get_error(':not(:not(a))') == (
407+
'Got :not() within :has() or another :not()')
408+
384409
def test_translation(self):
385410
def xpath(css):
386411
return _unicode(GenericTranslator().css_to_xpath(css, prefix=''))
@@ -490,6 +515,8 @@ def xpath(css):
490515
"e[@id = 'myid']")
491516
assert xpath('e:not(:nth-child(odd))') == (
492517
"e[not(count(preceding-sibling::*) mod 2 = 0)]")
518+
assert xpath('e:has(:nth-child(odd))') == (
519+
"e[count(preceding-sibling::*) mod 2 = 0]")
493520
assert xpath('e:nOT(*)') == (
494521
"e[0]") # never matches
495522
assert xpath('e f') == (
@@ -839,6 +866,9 @@ def pcss(main, *selectors, **kwargs):
839866
assert pcss('ol :Not(li[class])') == [
840867
'first-li', 'second-li', 'li-div',
841868
'fifth-li', 'sixth-li', 'seventh-li']
869+
assert pcss('link:has(*)') == []
870+
assert pcss('link:has([href])') == ['link-href']
871+
assert pcss('ol:has(div)') == ['first-ol']
842872
assert pcss('ol.a.b.c > li.c:nth-child(3)') == ['third-li']
843873

844874
# Invalid characters in XPath element names, should not crash
@@ -935,6 +965,7 @@ def count(selector):
935965
assert count(':scope > div > div[class=dialog]') == 1
936966
assert count(':scope > div div') == 242
937967

968+
938969
XMLLANG_IDS = '''
939970
<test>
940971
<a id="first" xml:lang="en">a</a>

tox.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ deps=
66
-r tests/requirements.txt
77

88
commands =
9-
py.test --cov-report term --cov=cssselect
9+
py.test --cov-report term --cov=cssselect {posargs:tests}

0 commit comments

Comments
 (0)