Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement rudimentary support for the relational pseudo-class :has() #96

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
63 changes: 54 additions & 9 deletions cssselect/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,30 @@ def specificity(self):
return a1 + a2, b1 + b2, c1 + c2


class Relation(object):
"""
Represents selector:has(subselector)
"""
def __init__(self, selector, subselector):
self.selector = selector
self.subselector = subselector

def __repr__(self):
return '%s[%r:has(%r)]' % (
self.__class__.__name__, self.selector, self.subselector)

def canonical(self):
subsel = self.subselector.canonical()
if len(subsel) > 1:
subsel = subsel.lstrip('*')
return '%s:has(%s)' % (self.selector.canonical(), subsel)

def specificity(self):
a1, b1, c1 = self.selector.specificity()
a2, b2, c2 = self.subselector.specificity()
return a1 + a2, b1 + b2, c1 + c2


class Attrib(object):
"""
Represents selector[namespace|attrib operator value]
Expand Down Expand Up @@ -456,7 +480,7 @@ def parse_selector(stream):
return result, pseudo_element


def parse_simple_selector(stream, inside_negation=False):
def parse_simple_selector(stream, nestable=True):
stream.skip_whitespace()
selector_start = len(stream.used)
peek = stream.peek()
Expand All @@ -479,7 +503,7 @@ def parse_simple_selector(stream, inside_negation=False):
while 1:
peek = stream.peek()
if peek.type in ('S', 'EOF') or peek.is_delim(',', '+', '>', '~') or (
inside_negation and peek == ('DELIM', ')')):
not nestable and peek == ('DELIM', ')')):
break
if pseudo_element:
raise SelectorSyntaxError(
Expand Down Expand Up @@ -507,7 +531,8 @@ def parse_simple_selector(stream, inside_negation=False):
pseudo_element, parse_arguments(stream))
continue
ident = stream.next_ident()
if ident.lower() in ('first-line', 'first-letter',
lowercase_indent = ident.lower()
if lowercase_indent in ('first-line', 'first-letter',
'before', 'after'):
# Special case: CSS 2.1 pseudo-elements can have a single ':'
# Any new pseudo-element must have two.
Expand All @@ -523,13 +548,16 @@ def parse_simple_selector(stream, inside_negation=False):
'Got immediate child pseudo-element ":scope" '
'not at the start of a selector')
continue

stream.next()
stream.skip_whitespace()
if ident.lower() == 'not':
if inside_negation:
raise SelectorSyntaxError('Got nested :not()')

if lowercase_indent == 'not':
if not nestable:
raise SelectorSyntaxError(
'Got :not() within :has() or another :not()')
argument, argument_pseudo_element = parse_simple_selector(
stream, inside_negation=True)
stream, nestable=False)
next = stream.next()
if argument_pseudo_element:
raise SelectorSyntaxError(
Expand All @@ -538,8 +566,25 @@ def parse_simple_selector(stream, inside_negation=False):
if next != ('DELIM', ')'):
raise SelectorSyntaxError("Expected ')', got %s" % (next,))
result = Negation(result, argument)
else:
result = Function(result, ident, parse_arguments(stream))
continue

if lowercase_indent == 'has':
if not nestable:
raise SelectorSyntaxError(
'Got :has() within :not() or another :has()')
argument, argument_pseudo_element = parse_simple_selector(
stream, nestable=False)
next = stream.next()
if argument_pseudo_element:
raise SelectorSyntaxError(
'Got pseudo-element ::%s inside :has() at %s'
% (argument_pseudo_element, next.pos))
if next != ('DELIM', ')'):
raise SelectorSyntaxError("Expected ')', got %s" % (next,))
result = Relation(result, argument)
continue

result = Function(result, ident, parse_arguments(stream))
else:
raise SelectorSyntaxError(
"Expected selector, got %s" % (peek,))
Expand Down
8 changes: 8 additions & 0 deletions cssselect/xpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ def xpath_negation(self, negation):
else:
return xpath.add_condition('0')

def xpath_relation(self, relation):
xpath = self.xpath(relation.selector)
sub_xpath = self.xpath(relation.subselector)
if sub_xpath.condition:
return xpath.add_condition('%s' % sub_xpath.condition)
else:
return xpath.add_condition('*')

def xpath_function(self, function):
"""Translate a functional pseudo-class."""
method = 'xpath_%s_function' % function.name.replace('-', '_')
Expand Down
41 changes: 39 additions & 2 deletions tests/test_cssselect.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ def parse_many(first, *others):
'Hash[Element[div]#foobar]']
assert parse_many('div:not(div.foo)') == [
'Negation[Element[div]:not(Class[Element[div].foo])]']
assert parse_many('div:has(div.foo)') == [
'Relation[Element[div]:has(Class[Element[div].foo])]']
assert parse_many('td ~ th') == [
'CombinedSelector[Element[td] ~ Element[th]]']
assert parse_many(':scope > foo') == [
Expand Down Expand Up @@ -266,6 +268,13 @@ def specificity(css):
assert specificity(':not(:empty)') == (0, 1, 0)
assert specificity(':not(#foo)') == (1, 0, 0)

assert specificity(':has(*)') == (0, 0, 0)
assert specificity(':has(foo)') == (0, 0, 1)
assert specificity(':has(.foo)') == (0, 1, 0)
assert specificity(':has([foo])') == (0, 1, 0)
assert specificity(':has(:empty)') == (0, 1, 0)
assert specificity(':has(#foo)') == (1, 0, 0)

assert specificity('foo:empty') == (0, 1, 1)
assert specificity('foo:before') == (0, 0, 2)
assert specificity('foo::before') == (0, 0, 2)
Expand Down Expand Up @@ -300,6 +309,12 @@ def css2css(css, res=None):
css2css(':not(*[foo])', ':not([foo])')
css2css(':not(:empty)')
css2css(':not(#foo)')
css2css(':has(*)')
css2css(':has(foo)')
css2css(':has(*.foo)', ':has(.foo)')
css2css(':has(*[foo])', ':has([foo])')
css2css(':has(:empty)')
css2css(':has(#foo)')
css2css('foo:empty')
css2css('foo::before')
css2css('foo:empty::before')
Expand Down Expand Up @@ -371,8 +386,8 @@ def get_error(css):
"Got pseudo-element ::before not at the end of a selector")
assert get_error(':not(:before)') == (
"Got pseudo-element ::before inside :not() at 12")
assert get_error(':not(:not(a))') == (
"Got nested :not()")
assert get_error(':has(:before)') == (
"Got pseudo-element ::before inside :has() at 12")
assert get_error(':scope > div :scope header') == (
'Got immediate child pseudo-element ":scope" not at the start of a selector'
)
Expand All @@ -381,6 +396,22 @@ def get_error(css):
)
assert get_error('> div p') == ("Expected selector, got <DELIM '>' at 0>")

# Unsupported nesting
assert get_error(':has(:has(a))') == (
'Got :has() within :not() or another :has()')
assert get_error(':has(:not(a))') == (
'Got :not() within :has() or another :not()')
assert get_error(':not(:has(a))') == (
'Got :has() within :not() or another :has()')
assert get_error(':not(:not(a))') == (
'Got :not() within :has() or another :not()')

# Unsupported complex selectors
assert get_error(':has(a, b)') == (
"Expected ')', got <DELIM ',' at 6>")
assert get_error(':not(a, b)') == (
"Expected ')', got <DELIM ',' at 6>")

def test_translation(self):
def xpath(css):
return _unicode(GenericTranslator().css_to_xpath(css, prefix=''))
Expand Down Expand Up @@ -490,6 +521,8 @@ def xpath(css):
"e[@id = 'myid']")
assert xpath('e:not(:nth-child(odd))') == (
"e[not(count(preceding-sibling::*) mod 2 = 0)]")
assert xpath('e:has(:nth-child(odd))') == (
"e[count(preceding-sibling::*) mod 2 = 0]")
assert xpath('e:nOT(*)') == (
"e[0]") # never matches
assert xpath('e f') == (
Expand Down Expand Up @@ -839,6 +872,9 @@ def pcss(main, *selectors, **kwargs):
assert pcss('ol :Not(li[class])') == [
'first-li', 'second-li', 'li-div',
'fifth-li', 'sixth-li', 'seventh-li']
assert pcss('link:has(*)') == []
assert pcss('link:has([href])') == ['link-href']
assert pcss('ol:has(div)') == ['first-ol']
assert pcss('ol.a.b.c > li.c:nth-child(3)') == ['third-li']

# Invalid characters in XPath element names, should not crash
Expand Down Expand Up @@ -935,6 +971,7 @@ def count(selector):
assert count(':scope > div > div[class=dialog]') == 1
assert count(':scope > div div') == 242


XMLLANG_IDS = '''
<test>
<a id="first" xml:lang="en">a</a>
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ deps=
-r tests/requirements.txt

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