diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 96eac3f..8efd222 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -622,18 +622,30 @@ def xpath_nth_last_child_function( ) -> XPathExpr: return self.xpath_nth_child_function(xpath, function, last=True) + @staticmethod + def _check_of_type_element(xpath: XPathExpr, pseudo: str) -> None: + """Raise an exception if an -of-type pseudo-class can't be used with + the given element. + + For "*" and namespace wildcards like "ns:*" the type of the element is + not known, and counting same-type siblings cannot be expressed as an + XPath 1.0 node test. + """ + element = xpath.element + if element == "*" or element.endswith(":*"): + css_element = element.replace(":", "|") + raise ExpressionError(f"{css_element}:{pseudo} is not implemented") + def xpath_nth_of_type_function( self, xpath: XPathExpr, function: Function ) -> XPathExpr: - if xpath.element == "*": - raise ExpressionError("*:nth-of-type() is not implemented") + self._check_of_type_element(xpath, "nth-of-type()") return self.xpath_nth_child_function(xpath, function, add_name_test=False) def xpath_nth_last_of_type_function( self, xpath: XPathExpr, function: Function ) -> XPathExpr: - if xpath.element == "*": - raise ExpressionError("*:nth-of-type() is not implemented") + self._check_of_type_element(xpath, "nth-last-of-type()") return self.xpath_nth_child_function( xpath, function, last=True, add_name_test=False ) @@ -678,22 +690,26 @@ def xpath_last_child_pseudo(self, xpath: XPathExpr) -> XPathExpr: return xpath.add_condition("count(following-sibling::*) = 0") def xpath_first_of_type_pseudo(self, xpath: XPathExpr) -> XPathExpr: - if xpath.element == "*": - raise ExpressionError("*:first-of-type is not implemented") + self._check_of_type_element(xpath, "first-of-type") return xpath.add_condition(f"count(preceding-sibling::{xpath.element}) = 0") def xpath_last_of_type_pseudo(self, xpath: XPathExpr) -> XPathExpr: - if xpath.element == "*": - raise ExpressionError("*:last-of-type is not implemented") + self._check_of_type_element(xpath, "last-of-type") return xpath.add_condition(f"count(following-sibling::{xpath.element}) = 0") def xpath_only_child_pseudo(self, xpath: XPathExpr) -> XPathExpr: - return xpath.add_condition("count(parent::*/child::*) = 1") + # Count siblings, not the parent's children: the root element has + # no parent, but it has no siblings either, so it must match. + return xpath.add_condition( + "count(preceding-sibling::*) = 0 and count(following-sibling::*) = 0" + ) def xpath_only_of_type_pseudo(self, xpath: XPathExpr) -> XPathExpr: - if xpath.element == "*": - raise ExpressionError("*:only-of-type is not implemented") - return xpath.add_condition(f"count(parent::*/child::{xpath.element}) = 1") + self._check_of_type_element(xpath, "only-of-type") + return xpath.add_condition( + f"count(preceding-sibling::{xpath.element}) = 0 " + f"and count(following-sibling::{xpath.element}) = 0" + ) def xpath_empty_pseudo(self, xpath: XPathExpr) -> XPathExpr: return xpath.add_condition("not(*) and not(string-length())") diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index dc67bb7..299b134 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -515,8 +515,12 @@ def xpath(css: str) -> str: assert xpath("e:last-child") == ("e[count(following-sibling::*) = 0]") assert xpath("e:first-of-type") == ("e[count(preceding-sibling::e) = 0]") assert xpath("e:last-of-type") == ("e[count(following-sibling::e) = 0]") - assert xpath("e:only-child") == ("e[count(parent::*/child::*) = 1]") - assert xpath("e:only-of-type") == ("e[count(parent::*/child::e) = 1]") + assert xpath("e:only-child") == ( + "e[count(preceding-sibling::*) = 0 and count(following-sibling::*) = 0]" + ) + assert xpath("e:only-of-type") == ( + "e[count(preceding-sibling::e) = 0 and count(following-sibling::e) = 0]" + ) assert xpath("e:empty") == ("e[not(*) and not(string-length())]") assert xpath("e:EmPTY") == ("e[not(*) and not(string-length())]") assert xpath("e:root") == ("e[not(parent::*)]") @@ -576,6 +580,16 @@ def xpath(css: str) -> str: xpath(":nth-of-type(1)") with pytest.raises(ExpressionError): xpath(":nth-last-of-type(1)") + with pytest.raises(ExpressionError): + xpath("ns|*:first-of-type") + with pytest.raises(ExpressionError): + xpath("ns|*:only-of-type") + with pytest.raises(ExpressionError): + xpath("ns|*:last-of-type") + with pytest.raises(ExpressionError): + xpath("ns|*:nth-of-type(1)") + with pytest.raises(ExpressionError): + xpath("ns|*:nth-last-of-type(1)") with pytest.raises(ExpressionError): xpath(":nth-child(n-)") with pytest.raises(ExpressionError): @@ -977,9 +991,13 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: assert pcss("span:only-child") == ["foobar-span"] assert pcss("li div:only-child") == ["li-div"] assert pcss("div *:only-child") == ["li-div", "foobar-span"] + # The root element has no siblings, so it matches :only-child + # (just like :first-child and :last-child) + assert pcss("html:only-child") == ["html"] with pytest.raises(ExpressionError): pcss("p *:only-of-type") assert pcss("p:only-of-type") == ["paragraph"] + assert pcss("html:only-of-type") == ["html"] assert pcss("a:empty", "a:EMpty") == ["name-anchor"] assert pcss("li:empty") == ["third-li", "fourth-li", "fifth-li", "sixth-li"] assert pcss(":root", "html:root") == ["html"]