From 43ef5331ccaede4df13d4242c7aaf01c2940869f Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 6 Jul 2023 09:54:38 +0400 Subject: [PATCH 01/34] Drop Python 3.7 support (#141) --- .github/workflows/tests.yml | 2 +- docs/conf.py | 2 +- setup.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 28a230f..369fd68 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] + python-version: [3.8, 3.9, "3.10", "3.11"] steps: - uses: actions/checkout@v2 diff --git a/docs/conf.py b/docs/conf.py index d63672f..5524479 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -247,7 +247,7 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/': None} +intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} # --- Nitpicking options ------------------------------------------------------ diff --git a/setup.py b/setup.py index 48f3d40..88dc31a 100644 --- a/setup.py +++ b/setup.py @@ -31,13 +31,12 @@ "cssselect": ["py.typed"], }, include_package_data=True, - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", From 8e7ad5083300e487c1fa43f4c4291ff3368d835c Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 6 Oct 2023 13:10:10 +0400 Subject: [PATCH 02/34] Add Python 3.12 to supported versions. (#142) * Add Python 3.12 to supported versions. * Add setuptools to tox.ini deps. * Actually run tests on 3.12. --- .flake8 | 3 ++- .github/workflows/checks.yml | 16 ++++++++-------- .github/workflows/publish.yml | 8 ++++---- .github/workflows/tests.yml | 6 +++--- setup.py | 1 + tox.ini | 5 +++-- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/.flake8 b/.flake8 index 4315a12..8b0608f 100644 --- a/.flake8 +++ b/.flake8 @@ -2,7 +2,8 @@ max-line-length = 99 ignore = W503 - E266 # too many leading '#' for block comment + # too many leading '#' for block comment + E266 exclude = .git .tox diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 85b51ce..847d788 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,30 +7,30 @@ jobs: strategy: matrix: include: - - python-version: 3 + - python-version: 3.12 env: TOXENV: black - - python-version: 3 + - python-version: 3.12 env: TOXENV: flake8 - - python-version: 3 + - python-version: 3.12 env: TOXENV: pylint - - python-version: 3 + - python-version: 3.12 env: TOXENV: security - - python-version: 3 + - python-version: 3.12 env: TOXENV: docs - - python-version: 3 + - python-version: 3.12 env: TOXENV: typing steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 977a42d..67d9c5a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,12 +7,12 @@ jobs: if: startsWith(github.event.ref, 'refs/tags/') steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Set up Python 3.8 - uses: actions/setup-python@v2 + - name: Set up Python 3.12 + uses: actions/setup-python@v4 with: - python-version: 3 + python-version: 3.12 - name: Check Tag id: check-release-tag diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 369fd68..0de2aa2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,13 +6,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/setup.py b/setup.py index 88dc31a..f7b51eb 100644 --- a/setup.py +++ b/setup.py @@ -41,5 +41,6 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], ) diff --git a/tox.ini b/tox.ini index a400382..c618dfb 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ deps = lxml>=4.4 pytest-cov>=2.8 pytest>=5.4 + setuptools sybil commands = pytest --cov=cssselect \ @@ -20,14 +21,14 @@ commands = [testenv:flake8] deps = - flake8==5.0.4 + flake8==6.1.0 commands = flake8 {posargs: cssselect setup.py tests docs/conf.py} [testenv:pylint] deps = {[testenv]deps} - pylint==2.15.3 + pylint==3.0.0 commands = pylint {posargs: cssselect setup.py tests docs} From ec4d1ea6a1c71a2c21d83ce77f9ac08eccd73f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 19 Dec 2023 12:54:06 +0100 Subject: [PATCH 03/34] Add .readthedocs.yml (#143) --- .readthedocs.yml | 15 +++++++++++++++ docs/conf.py | 2 +- docs/requirements.txt | 2 ++ tox.ini | 3 +-- 4 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 .readthedocs.yml create mode 100644 docs/requirements.txt diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..7d13c50 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,15 @@ +version: 2 +formats: all +sphinx: + configuration: docs/conf.py + fail_on_warning: true +build: + os: ubuntu-22.04 + tools: + # For available versions, see: + # https://docs.readthedocs.io/en/stable/config-file/v2.html#build-tools-python + python: "3.12" # Keep in sync with .github/workflows/checks.yml +python: + install: + - requirements: docs/requirements.txt + - path: . diff --git a/docs/conf.py b/docs/conf.py index 5524479..811de25 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -95,7 +95,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'classic' +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..d5476d8 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx==7.2.6 +sphinx-rtd-theme==2.0.0 diff --git a/tox.ini b/tox.ini index c618dfb..24dec48 100644 --- a/tox.ini +++ b/tox.ini @@ -41,8 +41,7 @@ commands = [testenv:docs] changedir = docs deps = - sphinx - sphinx_rtd_theme + -r docs/requirements.txt commands = sphinx-build -W -b html . {envtmpdir}/html From e91101b37f82558db84a6b8ee9a6dba1fd2ae0bb Mon Sep 17 00:00:00 2001 From: Emmanuel Rondan Date: Thu, 11 Apr 2024 12:58:57 -0300 Subject: [PATCH 04/34] applying pre-commit hooks and adding config files --- .bandit.yml | 4 + .flake8 | 1 + .git-blame-ignore-revs | 1 + .github/workflows/checks.yml | 12 +-- .isort.cfg | 2 + .pre-commit-config.yaml | 18 ++++ cssselect/__init__.py | 6 +- cssselect/parser.py | 93 ++++++++++++----- cssselect/xpath.py | 139 ++++++++++++++++++------- docs/conf.py | 147 +++++++++++++------------- docs/conftest.py | 5 +- pyproject.toml | 11 +- setup.py | 3 +- tests/test_cssselect.py | 196 ++++++++++++++++++++++++++--------- tox.ini | 25 ++--- 15 files changed, 443 insertions(+), 220 deletions(-) create mode 100644 .git-blame-ignore-revs create mode 100644 .isort.cfg create mode 100644 .pre-commit-config.yaml diff --git a/.bandit.yml b/.bandit.yml index 7fcde04..4f60a02 100644 --- a/.bandit.yml +++ b/.bandit.yml @@ -1,2 +1,6 @@ skips: - B101 +- B311 +- B320 +- B410 +exclude_dirs: ['tests'] diff --git a/.flake8 b/.flake8 index 8b0608f..2417f2e 100644 --- a/.flake8 +++ b/.flake8 @@ -4,6 +4,7 @@ ignore = W503 # too many leading '#' for block comment E266 + E704 exclude = .git .tox diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e746ff9 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1 @@ +# applying pre-commit hooks to the project \ No newline at end of file diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 847d788..1e9a243 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,12 +7,6 @@ jobs: strategy: matrix: include: - - python-version: 3.12 - env: - TOXENV: black - - python-version: 3.12 - env: - TOXENV: flake8 - python-version: 3.12 env: TOXENV: pylint @@ -40,3 +34,9 @@ jobs: pip install -U pip pip install -U tox tox + + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pre-commit/action@v3.0.0 diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..6860bdb --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +profile = black \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a27d3db --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: +- repo: https://github.com/PyCQA/bandit + rev: 1.7.8 + hooks: + - id: bandit + args: [-r, -c, .bandit.yml] +- repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 +- repo: https://github.com/psf/black.git + rev: 24.3.0 + hooks: + - id: black +- repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort \ No newline at end of file diff --git a/cssselect/__init__.py b/cssselect/__init__.py index 77f028b..a59995c 100644 --- a/cssselect/__init__.py +++ b/cssselect/__init__.py @@ -14,13 +14,13 @@ """ from cssselect.parser import ( - parse, - Selector, FunctionalPseudoElement, + Selector, SelectorError, SelectorSyntaxError, + parse, ) -from cssselect.xpath import GenericTranslator, HTMLTranslator, ExpressionError +from cssselect.xpath import ExpressionError, GenericTranslator, HTMLTranslator __all__ = ( "ExpressionError", diff --git a/cssselect/parser.py b/cssselect/parser.py index 25a650c..354713d 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -12,9 +12,9 @@ """ -import sys -import re import operator +import re +import sys import typing from typing import Iterable, Iterator, List, Optional, Sequence, Tuple, Union @@ -67,9 +67,13 @@ class Selector: """ - def __init__(self, tree: Tree, pseudo_element: Optional[PseudoElement] = None) -> None: + def __init__( + self, tree: Tree, pseudo_element: Optional[PseudoElement] = None + ) -> None: self.parsed_tree = tree - if pseudo_element is not None and not isinstance(pseudo_element, FunctionalPseudoElement): + if pseudo_element is not None and not isinstance( + pseudo_element, FunctionalPseudoElement + ): pseudo_element = ascii_lower(pseudo_element) #: A :class:`FunctionalPseudoElement`, #: or the identifier for the pseudo-element as a string, @@ -247,7 +251,11 @@ def __init__(self, selector: Tree, subselector: Tree) -> None: self.subselector = subselector def __repr__(self) -> str: - return "%s[%r:not(%r)]" % (self.__class__.__name__, self.selector, self.subselector) + return "%s[%r:not(%r)]" % ( + self.__class__.__name__, + self.selector, + self.subselector, + ) def canonical(self) -> str: subsel = self.subselector.canonical() @@ -317,7 +325,10 @@ def canonical(self) -> str: for s in self.selector_list: selarg = s.canonical() selector_arguments.append(selarg.lstrip("*")) - return "%s:is(%s)" % (self.selector.canonical(), ", ".join(map(str, selector_arguments))) + return "%s:is(%s)" % ( + self.selector.canonical(), + ", ".join(map(str, selector_arguments)), + ) def specificity(self) -> Tuple[int, int, int]: return max(x.specificity() for x in self.selector_list) @@ -367,14 +378,17 @@ def __init__( attrib: str, operator: 'typing.Literal["exists"]', value: None, - ) -> None: - ... + ) -> None: ... @typing.overload def __init__( - self, selector: Tree, namespace: Optional[str], attrib: str, operator: str, value: "Token" - ) -> None: - ... + self, + selector: Tree, + namespace: Optional[str], + attrib: str, + operator: str, + value: "Token", + ) -> None: ... def __init__( self, @@ -415,7 +429,11 @@ def canonical(self) -> str: if self.operator == "exists": op = attrib else: - op = "%s%s%s" % (attrib, self.operator, typing.cast("Token", self.value).css()) + op = "%s%s%s" % ( + attrib, + self.operator, + typing.cast("Token", self.value).css(), + ) return "%s[%s]" % (self.selector.canonical(), op) @@ -433,7 +451,9 @@ class Element: """ - def __init__(self, namespace: Optional[str] = None, element: Optional[str] = None) -> None: + def __init__( + self, namespace: Optional[str] = None, element: Optional[str] = None + ) -> None: self.namespace = namespace self.element = element @@ -486,7 +506,12 @@ def __repr__(self) -> str: comb = "" else: comb = self.combinator - return "%s[%r %s %r]" % (self.__class__.__name__, self.selector, comb, self.subselector) + return "%s[%r %s %r]" % ( + self.__class__.__name__, + self.selector, + comb, + self.subselector, + ) def canonical(self) -> str: subsel = self.subselector.canonical() @@ -509,7 +534,9 @@ def specificity(self) -> Tuple[int, int, int]: _id_re = re.compile(r"^[ \t\r\n\f]*([a-zA-Z]*)#([a-zA-Z0-9_-]+)[ \t\r\n\f]*$") # foo.bar or .bar -_class_re = re.compile(r"^[ \t\r\n\f]*([a-zA-Z]*)\.([a-zA-Z][a-zA-Z0-9_-]*)[ \t\r\n\f]*$") +_class_re = re.compile( + r"^[ \t\r\n\f]*([a-zA-Z]*)\.([a-zA-Z][a-zA-Z0-9_-]*)[ \t\r\n\f]*$" +) def parse(css: str) -> List[Selector]: @@ -536,7 +563,9 @@ def parse(css: str) -> List[Selector]: return [Selector(Hash(Element(element=match.group(1) or None), match.group(2)))] match = _class_re.match(css) if match is not None: - return [Selector(Class(Element(element=match.group(1) or None), match.group(2)))] + return [ + Selector(Class(Element(element=match.group(1) or None), match.group(2))) + ] stream = TokenStream(tokenize(css)) stream.source = css @@ -708,7 +737,10 @@ def parse_arguments(stream: "TokenStream") -> List["Token"]: while 1: stream.skip_whitespace() next = stream.next() - if next.type in ("IDENT", "STRING", "NUMBER") or next in [("DELIM", "+"), ("DELIM", "-")]: + if next.type in ("IDENT", "STRING", "NUMBER") or next in [ + ("DELIM", "+"), + ("DELIM", "-"), + ]: arguments.append(next) elif next == ("DELIM", ")"): return arguments @@ -729,7 +761,10 @@ def parse_relative_selector(stream: "TokenStream") -> Tuple["Token", Selector]: combinator = Token("DELIM", " ", pos=0) while 1: - if next.type in ("IDENT", "STRING", "NUMBER") or next in [("DELIM", "."), ("DELIM", "*")]: + if next.type in ("IDENT", "STRING", "NUMBER") or next in [ + ("DELIM", "."), + ("DELIM", "*"), + ]: subselector += typing.cast(str, next.value) elif next == ("DELIM", ")"): result = parse(subselector) @@ -787,7 +822,9 @@ def parse_attrib(selector: Tree, stream: "TokenStream") -> Attrib: return Attrib(selector, namespace, typing.cast(str, attrib), "exists", None) elif next == ("DELIM", "="): op = "=" - elif next.is_delim("^", "$", "*", "~", "|", "!") and (stream.peek() == ("DELIM", "=")): + elif next.is_delim("^", "$", "*", "~", "|", "!") and ( + stream.peek() == ("DELIM", "=") + ): op = typing.cast(str, next.value) + "=" stream.next() else: @@ -850,12 +887,12 @@ def __new__( type_: 'typing.Literal["IDENT", "HASH", "STRING", "S", "DELIM", "NUMBER"]', value: str, pos: int, - ) -> "Token": - ... + ) -> "Token": ... @typing.overload - def __new__(cls, type_: 'typing.Literal["EOF"]', value: None, pos: int) -> "Token": - ... + def __new__( + cls, type_: 'typing.Literal["EOF"]', value: None, pos: int + ) -> "Token": ... def __new__(cls, type_: str, value: Optional[str], pos: int) -> "Token": obj = tuple.__new__(cls, (type_, value)) @@ -910,8 +947,7 @@ class TokenMacros: class MatchFunc(typing.Protocol): def __call__( self, string: str, pos: int = ..., endpos: int = ... - ) -> Optional["re.Match[str]"]: - ... + ) -> Optional["re.Match[str]"]: ... def _compile(pattern: str) -> "MatchFunc": @@ -970,7 +1006,8 @@ def tokenize(s: str) -> Iterator[Token]: match = _match_hash(s, pos=pos) if match: value = _sub_simple_escape( - _replace_simple, _sub_unicode_escape(_replace_unicode, match.group()[1:]) + _replace_simple, + _sub_unicode_escape(_replace_unicode, match.group()[1:]), ) yield Token("HASH", value, pos) pos = match.end() @@ -987,7 +1024,9 @@ def tokenize(s: str) -> Iterator[Token]: raise SelectorSyntaxError("Invalid string at %s" % pos) value = _sub_simple_escape( _replace_simple, - _sub_unicode_escape(_replace_unicode, _sub_newline_escape("", match.group())), + _sub_unicode_escape( + _replace_unicode, _sub_newline_escape("", match.group()) + ), ) yield Token("STRING", value, pos) pos = end_pos + 1 diff --git a/cssselect/xpath.py b/cssselect/xpath.py index fd28c47..4255f66 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -18,23 +18,23 @@ from typing import Optional from cssselect.parser import ( - parse, - parse_series, - PseudoElement, - Selector, - SelectorError, - Tree, - Element, - Hash, + Attrib, Class, + CombinedSelector, + Element, Function, - Pseudo, - Attrib, + Hash, + Matching, Negation, + Pseudo, + PseudoElement, Relation, - Matching, + Selector, + SelectorError, SpecificityAdjustment, - CombinedSelector, + Tree, + parse, + parse_series, ) @@ -58,7 +58,11 @@ class ExpressionError(SelectorError, RuntimeError): class XPathExpr: def __init__( - self, path: str = "", element: str = "*", condition: str = "", star_prefix: bool = False + self, + path: str = "", + element: str = "*", + condition: str = "", + star_prefix: bool = False, ) -> None: self.path = path self.element = element @@ -84,7 +88,9 @@ def add_name_test(self) -> None: if self.element == "*": # We weren't doing a test anyway return - self.add_condition("name() = %s" % GenericTranslator.xpath_literal(self.element)) + self.add_condition( + "name() = %s" % GenericTranslator.xpath_literal(self.element) + ) self.element = "*" def add_star_prefix(self) -> None: @@ -107,7 +113,9 @@ def join( path += other.path self.path = path if not has_inner_condition: - self.element = other.element + closing_combiner if closing_combiner else other.element + self.element = ( + other.element + closing_combiner if closing_combiner else other.element + ) self.condition = other.condition else: self.element = other.element @@ -259,7 +267,9 @@ def selector_to_xpath( xpath = self.xpath_pseudo_element(xpath, selector.pseudo_element) return (prefix or "") + str(xpath) - def xpath_pseudo_element(self, xpath: XPathExpr, pseudo_element: PseudoElement) -> XPathExpr: + def xpath_pseudo_element( + self, xpath: XPathExpr, pseudo_element: PseudoElement + ) -> XPathExpr: """Translate a pseudo-element. Defaults to not supporting pseudo-elements at all, @@ -300,7 +310,8 @@ def xpath_combinedselector(self, combined: CombinedSelector) -> XPathExpr: combinator = self.combinator_mapping[combined.combinator] method = getattr(self, "xpath_%s_combinator" % combinator) return typing.cast( - XPathExpr, method(self.xpath(combined.selector), self.xpath(combined.subselector)) + XPathExpr, + method(self.xpath(combined.selector), self.xpath(combined.subselector)), ) def xpath_negation(self, negation: Negation) -> XPathExpr: @@ -381,7 +392,9 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr: value = typing.cast(str, selector.value.value).lower() else: value = selector.value.value - return typing.cast(XPathExpr, method(self.xpath(selector.selector), attrib, value)) + return typing.cast( + XPathExpr, method(self.xpath(selector.selector), attrib, value) + ) def xpath_class(self, class_selector: Class) -> XPathExpr: """Translate a class selector.""" @@ -416,7 +429,9 @@ def xpath_element(self, selector: Element) -> XPathExpr: # CombinedSelector: dispatch by combinator - def xpath_descendant_combinator(self, left: XPathExpr, right: XPathExpr) -> XPathExpr: + def xpath_descendant_combinator( + self, left: XPathExpr, right: XPathExpr + ) -> XPathExpr: """right is a child, grand-child or further descendant of left""" return left.join("/descendant-or-self::*/", right) @@ -424,21 +439,31 @@ def xpath_child_combinator(self, left: XPathExpr, right: XPathExpr) -> XPathExpr """right is an immediate child of left""" return left.join("/", right) - def xpath_direct_adjacent_combinator(self, left: XPathExpr, right: XPathExpr) -> XPathExpr: + def xpath_direct_adjacent_combinator( + self, left: XPathExpr, right: XPathExpr + ) -> XPathExpr: """right is a sibling immediately after left""" xpath = left.join("/following-sibling::", right) xpath.add_name_test() return xpath.add_condition("position() = 1") - def xpath_indirect_adjacent_combinator(self, left: XPathExpr, right: XPathExpr) -> XPathExpr: + def xpath_indirect_adjacent_combinator( + self, left: XPathExpr, right: XPathExpr + ) -> XPathExpr: """right is a sibling after left, immediately or not""" return left.join("/following-sibling::", right) - def xpath_relation_descendant_combinator(self, left: XPathExpr, right: XPathExpr) -> XPathExpr: + def xpath_relation_descendant_combinator( + self, left: XPathExpr, right: XPathExpr + ) -> XPathExpr: """right is a child, grand-child or further descendant of left; select left""" - return left.join("[descendant::", right, closing_combiner="]", has_inner_condition=True) + return left.join( + "[descendant::", right, closing_combiner="]", has_inner_condition=True + ) - def xpath_relation_child_combinator(self, left: XPathExpr, right: XPathExpr) -> XPathExpr: + def xpath_relation_child_combinator( + self, left: XPathExpr, right: XPathExpr + ) -> XPathExpr: """right is an immediate child of left; select left""" return left.join("[./", right, closing_combiner="]") @@ -447,7 +472,9 @@ def xpath_relation_direct_adjacent_combinator( ) -> XPathExpr: """right is a sibling immediately after left; select left""" xpath = left.add_condition( - "following-sibling::*[(name() = '{}') and (position() = 1)]".format(right.element) + "following-sibling::*[(name() = '{}') and (position() = 1)]".format( + right.element + ) ) return xpath @@ -460,7 +487,11 @@ def xpath_relation_indirect_adjacent_combinator( # Function: dispatch by function/pseudo-class name def xpath_nth_child_function( - self, xpath: XPathExpr, function: Function, last: bool = False, add_name_test: bool = True + self, + xpath: XPathExpr, + function: Function, + last: bool = False, + add_name_test: bool = True, ) -> XPathExpr: try: a, b = parse_series(function.arguments) @@ -589,28 +620,41 @@ def xpath_nth_child_function( template = "(%s)" else: template = "%s" - xpath.add_condition(" and ".join(template % expression for expression in expressions)) + xpath.add_condition( + " and ".join(template % expression for expression in expressions) + ) return xpath - def xpath_nth_last_child_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: + def xpath_nth_last_child_function( + self, xpath: XPathExpr, function: Function + ) -> XPathExpr: return self.xpath_nth_child_function(xpath, function, last=True) - def xpath_nth_of_type_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: + def xpath_nth_of_type_function( + self, xpath: XPathExpr, function: Function + ) -> XPathExpr: if xpath.element == "*": raise ExpressionError("*:nth-of-type() is not implemented") 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: + def xpath_nth_last_of_type_function( + self, xpath: XPathExpr, function: Function + ) -> XPathExpr: if xpath.element == "*": raise ExpressionError("*:nth-of-type() is not implemented") - return self.xpath_nth_child_function(xpath, function, last=True, add_name_test=False) + return self.xpath_nth_child_function( + xpath, function, last=True, add_name_test=False + ) - def xpath_contains_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: + def xpath_contains_function( + self, xpath: XPathExpr, function: Function + ) -> XPathExpr: # Defined there, removed in later drafts: # http://www.w3.org/TR/2001/CR-css3-selectors-20011113/#content-selectors if function.argument_types() not in (["STRING"], ["IDENT"]): raise ExpressionError( - "Expected a single string or ident for :contains(), got %r" % function.arguments + "Expected a single string or ident for :contains(), got %r" + % function.arguments ) value = typing.cast(str, function.arguments[0].value) return xpath.add_condition("contains(., %s)" % self.xpath_literal(value)) @@ -618,7 +662,8 @@ def xpath_contains_function(self, xpath: XPathExpr, function: Function) -> XPath def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: if function.argument_types() not in (["STRING"], ["IDENT"]): raise ExpressionError( - "Expected a single string or ident for :lang(), got %r" % function.arguments + "Expected a single string or ident for :lang(), got %r" + % function.arguments ) value = typing.cast(str, function.arguments[0].value) return xpath.add_condition("lang(%s)" % (self.xpath_literal(value))) @@ -679,12 +724,16 @@ def pseudo_never_matches(self, xpath: XPathExpr) -> XPathExpr: # Attrib: dispatch by attribute operator - def xpath_attrib_exists(self, xpath: XPathExpr, name: str, value: Optional[str]) -> XPathExpr: + def xpath_attrib_exists( + self, xpath: XPathExpr, name: str, value: Optional[str] + ) -> XPathExpr: assert not value xpath.add_condition(name) return xpath - def xpath_attrib_equals(self, xpath: XPathExpr, name: str, value: Optional[str]) -> XPathExpr: + def xpath_attrib_equals( + self, xpath: XPathExpr, name: str, value: Optional[str] + ) -> XPathExpr: assert value is not None xpath.add_condition("%s = %s" % (name, self.xpath_literal(value))) return xpath @@ -695,7 +744,9 @@ def xpath_attrib_different( assert value is not None # FIXME: this seems like a weird hack... if value: - xpath.add_condition("not(%s) or %s != %s" % (name, name, self.xpath_literal(value))) + xpath.add_condition( + "not(%s) or %s != %s" % (name, name, self.xpath_literal(value)) + ) else: xpath.add_condition("%s != %s" % (name, self.xpath_literal(value))) return xpath @@ -719,7 +770,13 @@ def xpath_attrib_dashmatch( # Weird, but true... xpath.add_condition( "%s and (%s = %s or starts-with(%s, %s))" - % (name, name, self.xpath_literal(value), name, self.xpath_literal(value + "-")) + % ( + name, + name, + self.xpath_literal(value), + name, + self.xpath_literal(value + "-"), + ) ) return xpath @@ -798,7 +855,8 @@ def xpath_checked_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: if function.argument_types() not in (["STRING"], ["IDENT"]): raise ExpressionError( - "Expected a single string or ident for :lang(), got %r" % function.arguments + "Expected a single string or ident for :lang(), got %r" + % function.arguments ) value = function.arguments[0].value assert value @@ -807,7 +865,8 @@ def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr # XPath 1.0 has no lower-case function... "translate(@%s, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', " "'abcdefghijklmnopqrstuvwxyz'), " - "'-'), %s)]" % (self.lang_attribute, self.xpath_literal(value.lower() + "-")) + "'-'), %s)]" + % (self.lang_attribute, self.xpath_literal(value.lower() + "-")) ) def xpath_link_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore diff --git a/docs/conf.py b/docs/conf.py index 811de25..aa5ae22 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,83 +12,86 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os, re +import os +import re +import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', - 'sphinx.ext.doctest'] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.doctest"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'cssselect' -copyright = '2012-2017, Simon Sapin, Scrapy developers' +project = "cssselect" +copyright = "2012-2017, Simon Sapin, Scrapy developers" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The full version, including alpha/beta/rc tags. -with open(os.path.join(os.path.dirname(__file__), '..', 'cssselect', '__init__.py')) as init_file: +with open( + os.path.join(os.path.dirname(__file__), "..", "cssselect", "__init__.py") +) as init_file: init_py = init_file.read() release = re.search('VERSION = "([^"]+)"', init_py).group(1) # The short X.Y version. -version = release.rstrip('dev') +version = release.rstrip("dev") # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- @@ -100,129 +103,123 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -#html_static_path = ['_static'] +# html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'cssselectdoc' +htmlhelp_basename = "cssselectdoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'cssselect.tex', 'cssselect Documentation', - 'Simon Sapin', 'manual'), + ("index", "cssselect.tex", "cssselect Documentation", "Simon Sapin", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'cssselect', 'cssselect Documentation', - ['Simon Sapin'], 1) -] +man_pages = [("index", "cssselect", "cssselect Documentation", ["Simon Sapin"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -231,23 +228,29 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'cssselect', 'cssselect Documentation', - 'Simon Sapin', 'cssselect', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "cssselect", + "cssselect Documentation", + "Simon Sapin", + "cssselect", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} # --- Nitpicking options ------------------------------------------------------ @@ -255,5 +258,5 @@ nitpicky = True nitpick_ignore = [ # explicitly not a part of the public API - ('py:class', 'cssselect.parser.Token'), + ("py:class", "cssselect.parser.Token"), ] diff --git a/docs/conftest.py b/docs/conftest.py index 9d16bb7..a71d108 100644 --- a/docs/conftest.py +++ b/docs/conftest.py @@ -3,6 +3,7 @@ from sybil import Sybil from sybil.parsers.doctest import DocTestParser from sybil.parsers.skip import skip + try: # sybil 3.0.0+ from sybil.parsers.codeblock import PythonCodeBlockParser @@ -13,8 +14,8 @@ pytest_collect_file = Sybil( parsers=[ DocTestParser(optionflags=ELLIPSIS | NORMALIZE_WHITESPACE), - PythonCodeBlockParser(future_imports=['print_function']), + PythonCodeBlockParser(future_imports=["print_function"]), skip, ], - pattern='*.rst', + pattern="*.rst", ).pytest() diff --git a/pyproject.toml b/pyproject.toml index 57a5583..261fe3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,11 @@ +[tool.isort] +profile = "black" +multi_line_output = 3 + +[tool.mypy] +check_untyped_defs = true +ignore_missing_imports = true +no_warn_no_return = true + [tool.black] -line-length = 99 +target-version = ["py38", "py39", "py310", "py311", "py312"] \ No newline at end of file diff --git a/setup.py b/setup.py index f7b51eb..43eecc0 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- -import re import os.path +import re from setuptools import setup - ROOT = os.path.dirname(__file__) with open(os.path.join(ROOT, "README.rst")) as readme_file: README = readme_file.read() diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 2c9e94c..32c1683 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -23,27 +23,31 @@ from typing import List, Optional, Sequence, Tuple from lxml import etree, html + from cssselect import ( - parse, + ExpressionError, GenericTranslator, HTMLTranslator, SelectorSyntaxError, - ExpressionError, + parse, ) from cssselect.parser import ( - tokenize, - parse_series, - PseudoElement, - FunctionalPseudoElement, Function, + FunctionalPseudoElement, + PseudoElement, Token, + parse_series, + tokenize, ) from cssselect.xpath import XPathExpr class TestCssselect(unittest.TestCase): def test_tokenizer(self) -> None: - tokens = [str(item) for item in tokenize(r'E\ é > f [a~="y\"x"]:nth(/* fu /]* */-3.7)')] + tokens = [ + str(item) + for item in tokenize(r'E\ é > f [a~="y\"x"]:nth(/* fu /]* */-3.7)') + ] assert tokens == [ "", "", @@ -70,7 +74,10 @@ def repr_parse(css: str) -> List[str]: selectors = parse(css) for selector in selectors: assert selector.pseudo_element is None - return [repr(selector.parsed_tree).replace("(u'", "('") for selector in selectors] + return [ + repr(selector.parsed_tree).replace("(u'", "('") + for selector in selectors + ] def parse_many(first: str, *others: str) -> List[str]: result = repr_parse(first) @@ -95,7 +102,9 @@ def parse_many(first: str, *others: str) -> List[str]: "div\r>\n\n\n.foo", "div\f>\f.foo", ) == ["CombinedSelector[Element[div] > Class[Element[*].foo]]"] - assert parse_many("td.foo,.bar", "td.foo, .bar", "td.foo\t\r\n\f ,\t\r\n\f .bar") == [ + assert parse_many( + "td.foo,.bar", "td.foo, .bar", "td.foo\t\r\n\f ,\t\r\n\f .bar" + ) == [ "Class[Element[td].foo]", "Class[Element[*].bar]", ] @@ -123,11 +132,15 @@ def parse_many(first: str, *others: str) -> List[str]: assert parse_many("a[hreflang |= 'en']", "a[hreflang|=en]") == [ "Attrib[Element[a][hreflang |= 'en']]" ] - assert parse_many("div:nth-child(10)") == ["Function[Element[div]:nth-child(['10'])]"] + assert parse_many("div:nth-child(10)") == [ + "Function[Element[div]:nth-child(['10'])]" + ] assert parse_many(":nth-child(2n+2)") == [ "Function[Element[*]:nth-child(['2', 'n', '+2'])]" ] - assert parse_many("div:nth-of-type(10)") == ["Function[Element[div]:nth-of-type(['10'])]"] + assert parse_many("div:nth-of-type(10)") == [ + "Function[Element[div]:nth-of-type(['10'])]" + ] assert parse_many("div div:nth-of-type(10) .aclass") == [ "CombinedSelector[CombinedSelector[Element[div] " "Function[Element[div]:nth-of-type(['10'])]] " @@ -135,7 +148,9 @@ def parse_many(first: str, *others: str) -> List[str]: ] assert parse_many("label:only") == ["Pseudo[Element[label]:only]"] assert parse_many("a:lang(fr)") == ["Function[Element[a]:lang(['fr'])]"] - assert parse_many('div:contains("foo")') == ["Function[Element[div]:contains(['foo'])]"] + assert parse_many('div:contains("foo")') == [ + "Function[Element[div]:contains(['foo'])]" + ] assert parse_many("div#foobar") == ["Hash[Element[div]#foobar]"] assert parse_many("div:not(div.foo)") == [ "Negation[Element[div]:not(Class[Element[div].foo])]" @@ -253,7 +268,10 @@ def test_pseudo_repr(css: str) -> str: assert selector.pseudo_element == "foo" assert tr.selector_to_xpath(selector, prefix="") == "e" self.assertRaises( - ExpressionError, tr.selector_to_xpath, selector, translate_pseudo_elements=True + ExpressionError, + tr.selector_to_xpath, + selector, + translate_pseudo_elements=True, ) # Special test for the unicode symbols and ':scope' element if check @@ -301,7 +319,11 @@ def specificity(css: str) -> Tuple[int, int, int]: assert specificity("foo::before") == (0, 0, 2) assert specificity("foo:empty::before") == (0, 1, 2) - assert specificity("#lorem + foo#ipsum:first-child > bar:first-line") == (2, 1, 3) + assert specificity("#lorem + foo#ipsum:first-child > bar:first-line") == ( + 2, + 1, + 3, + ) def test_css_export(self) -> None: def css2css(css: str, res: Optional[str] = None) -> None: @@ -354,7 +376,9 @@ def get_error(css: str) -> Optional[str]: assert get_error("attributes(href)/html/body/a") == ( "Expected selector, got " ) - assert get_error("attributes(href)") == ("Expected selector, got ") + assert get_error("attributes(href)") == ( + "Expected selector, got " + ) assert get_error("html/body/a") == ("Expected selector, got ") assert get_error(" ") == ("Expected selector, got ") assert get_error("div, ") == ("Expected selector, got ") @@ -369,10 +393,14 @@ def get_error(css: str) -> Optional[str]: assert get_error("[*]") == ("Expected '|', got ") assert get_error("[foo|]") == ("Expected ident, got ") assert get_error("[#]") == ("Expected ident or '*', got ") - assert get_error("[foo=#]") == ("Expected string or ident, got ") + assert get_error("[foo=#]") == ( + "Expected string or ident, got " + ) assert get_error("[href]a") == ("Expected selector, got ") assert get_error("[rel=stylesheet]") is None - assert get_error("[rel:stylesheet]") == ("Operator expected, got ") + assert get_error("[rel:stylesheet]") == ( + "Operator expected, got " + ) assert get_error("[rel=stylesheet") == ("Expected ']', got ") assert get_error(":lang(fr)") is None assert get_error(":lang(fr") == ("Expected an argument, got ") @@ -386,12 +414,20 @@ def get_error(css: str) -> Optional[str]: assert get_error("li:before a") == ( "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(:before)") == ( + "Got pseudo-element ::before inside :not() at 12" + ) assert get_error(":not(:not(a))") == ("Got nested :not()") - assert get_error(":is(:before)") == ("Got pseudo-element ::before inside function") + assert get_error(":is(:before)") == ( + "Got pseudo-element ::before inside function" + ) assert get_error(":is(a b)") == ("Expected an argument, got ") - assert get_error(":where(:before)") == ("Got pseudo-element ::before inside function") - assert get_error(":where(a b)") == ("Expected an argument, got ") + assert get_error(":where(:before)") == ( + "Got pseudo-element ::before inside function" + ) + assert get_error(":where(a b)") == ( + "Expected an argument, got " + ) assert get_error(":scope > div :scope header") == ( 'Got immediate child pseudo-element ":scope" not at the start of a selector' ) @@ -446,19 +482,29 @@ def xpath(css: str) -> str: "e[(count(preceding-sibling::*) >= 1) and " "((count(preceding-sibling::*) +2) mod 3 = 0)]" ) - assert xpath("e:nth-child(3n-2)") == ("e[count(preceding-sibling::*) mod 3 = 0]") + assert xpath("e:nth-child(3n-2)") == ( + "e[count(preceding-sibling::*) mod 3 = 0]" + ) assert xpath("e:nth-child(-n+6)") == ("e[count(preceding-sibling::*) <= 5]") assert xpath("e:nth-last-child(1)") == ("e[count(following-sibling::*) = 0]") - assert xpath("e:nth-last-child(2n)") == ("e[(count(following-sibling::*) +1) mod 2 = 0]") - assert xpath("e:nth-last-child(2n+1)") == ("e[count(following-sibling::*) mod 2 = 0]") + assert xpath("e:nth-last-child(2n)") == ( + "e[(count(following-sibling::*) +1) mod 2 = 0]" + ) + assert xpath("e:nth-last-child(2n+1)") == ( + "e[count(following-sibling::*) mod 2 = 0]" + ) assert xpath("e:nth-last-child(2n+2)") == ( "e[(count(following-sibling::*) >= 1) and " "((count(following-sibling::*) +1) mod 2 = 0)]" ) - assert xpath("e:nth-last-child(3n+1)") == ("e[count(following-sibling::*) mod 3 = 0]") + assert xpath("e:nth-last-child(3n+1)") == ( + "e[count(following-sibling::*) mod 3 = 0]" + ) # represents the two last e elements - assert xpath("e:nth-last-child(-n+2)") == ("e[count(following-sibling::*) <= 1]") + assert xpath("e:nth-last-child(-n+2)") == ( + "e[count(following-sibling::*) <= 1]" + ) assert xpath("e:nth-of-type(1)") == ("e[count(preceding-sibling::e) = 0]") assert xpath("e:nth-last-of-type(1)") == ("e[count(following-sibling::e) = 0]") @@ -486,24 +532,32 @@ def xpath(css: str) -> str: assert xpath("e:has(f)") == "e[descendant::f]" assert xpath("e:has(~ f)") == "e[following-sibling::f]" assert ( - xpath("e:has(+ f)") == "e[following-sibling::*[(name() = 'f') and (position() = 1)]]" + xpath("e:has(+ f)") + == "e[following-sibling::*[(name() = 'f') and (position() = 1)]]" ) assert xpath('e:contains("foo")') == ("e[contains(., 'foo')]") assert xpath("e:ConTains(foo)") == ("e[contains(., 'foo')]") assert xpath("e.warning") == ( - "e[@class and contains(" "concat(' ', normalize-space(@class), ' '), ' warning ')]" + "e[@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' warning ')]" ) assert xpath("e#myid") == ("e[@id = 'myid']") - assert xpath("e:not(:nth-child(odd))") == ("e[not(count(preceding-sibling::*) mod 2 = 0)]") + assert xpath("e:not(:nth-child(odd))") == ( + "e[not(count(preceding-sibling::*) mod 2 = 0)]" + ) assert xpath("e:nOT(*)") == ("e[0]") # never matches assert xpath("e f") == ("e/descendant-or-self::*/f") assert xpath("e > f") == ("e/f") - assert xpath("e + f") == ("e/following-sibling::*[(name() = 'f') and (position() = 1)]") + assert xpath("e + f") == ( + "e/following-sibling::*[(name() = 'f') and (position() = 1)]" + ) assert xpath("e ~ f") == ("e/following-sibling::f") assert xpath("e ~ f:nth-child(3)") == ( "e/following-sibling::f[count(preceding-sibling::*) = 2]" ) - assert xpath("div#container p") == ("div[@id = 'container']/descendant-or-self::*/p") + assert xpath("div#container p") == ( + "div[@id = 'container']/descendant-or-self::*/p" + ) assert xpath("e:where(foo)") == "e[name() = 'foo']" assert xpath("e:where(foo, bar)") == "e[(name() = 'foo') or (name() = 'bar')]" @@ -539,10 +593,18 @@ def test_unicode(self) -> None: def test_quoting(self) -> None: css_to_xpath = GenericTranslator().css_to_xpath - assert css_to_xpath('*[aval="\'"]') == ("""descendant-or-self::*[@aval = "'"]""") - assert css_to_xpath("*[aval=\"'''\"]") == ("""descendant-or-self::*[@aval = "'''"]""") - assert css_to_xpath("*[aval='\"']") == ("""descendant-or-self::*[@aval = '"']""") - assert css_to_xpath('*[aval=\'"""\']') == ('''descendant-or-self::*[@aval = '"""']''') + assert css_to_xpath('*[aval="\'"]') == ( + """descendant-or-self::*[@aval = "'"]""" + ) + assert css_to_xpath("*[aval=\"'''\"]") == ( + """descendant-or-self::*[@aval = "'''"]""" + ) + assert css_to_xpath("*[aval='\"']") == ( + """descendant-or-self::*[@aval = '"']""" + ) + assert css_to_xpath('*[aval=\'"""\']') == ( + '''descendant-or-self::*[@aval = '"""']''' + ) assert css_to_xpath(':scope > div[dataimg=""]') == ( "descendant-or-self::*[1]/div[@dataimg = '']" ) @@ -575,7 +637,8 @@ def xpath_pseudo_element( method = getattr(self, method_name, None) if not method: raise ExpressionError( - "The functional pseudo-element ::%s() is unknown" % pseudo_element.name + "The functional pseudo-element ::%s() is unknown" + % pseudo_element.name ) xpath = method(xpath, pseudo_element.arguments) else: @@ -592,7 +655,9 @@ def xpath_pseudo_element( # functional pseudo-class: # elements that have a certain number of attributes - def xpath_nb_attr_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: + def xpath_nb_attr_function( + self, xpath: XPathExpr, function: Function + ) -> XPathExpr: assert function.arguments[0].value nb_attributes = int(function.arguments[0].value) return xpath.add_condition("count(@*)=%d" % nb_attributes) @@ -616,7 +681,9 @@ def xpath_attr_functional_pseudo_element( # pseudo-element: # element's text() nodes - def xpath_text_node_simple_pseudo_element(self, xpath: XPathExpr) -> XPathExpr: + def xpath_text_node_simple_pseudo_element( + self, xpath: XPathExpr + ) -> XPathExpr: other = XPathExpr( "text()", "", @@ -625,7 +692,9 @@ def xpath_text_node_simple_pseudo_element(self, xpath: XPathExpr) -> XPathExpr: # pseudo-element: # element's href attribute - def xpath_attr_href_simple_pseudo_element(self, xpath: XPathExpr) -> XPathExpr: + def xpath_attr_href_simple_pseudo_element( + self, xpath: XPathExpr + ) -> XPathExpr: other = XPathExpr( "@href", "", @@ -656,7 +725,9 @@ def xpath(css: str) -> str: assert str(XPathExpr("", "", condition="@href")) == "[@href]" document = etree.fromstring(OPERATOR_PRECEDENCE_IDS) - sort_key = dict((el, count) for count, el in enumerate(document.iter())).__getitem__ + sort_key = dict( + (el, count) for count, el in enumerate(document.iter()) + ).__getitem__ def operator_id(selector: str) -> List[str]: xpath = CustomTranslator().css_to_xpath(selector) @@ -698,7 +769,9 @@ def series(css: str) -> Optional[Tuple[int, int]]: def test_lang(self) -> None: document = etree.fromstring(XMLLANG_IDS) - sort_key = dict((el, count) for count, el in enumerate(document.iter())).__getitem__ + sort_key = dict( + (el, count) for count, el in enumerate(document.iter()) + ).__getitem__ css_to_xpath = GenericTranslator().css_to_xpath def langid(selector: str) -> List[str]: @@ -714,7 +787,13 @@ def langid(selector: str) -> List[str]: assert langid(":lang(ru)") == ["sixth"] assert langid(":lang('ZH')") == ["eighth"] assert langid(":lang(de) :lang(zh)") == ["eighth"] - assert langid(":lang(en), :lang(zh)") == ["first", "second", "third", "fourth", "eighth"] + assert langid(":lang(en), :lang(zh)") == [ + "first", + "second", + "third", + "fourth", + "eighth", + ] assert langid(":lang(es)") == [] def test_argument_types(self) -> None: @@ -747,7 +826,9 @@ def argument_types(css: str) -> List[str]: def test_select(self) -> None: document = etree.fromstring(HTML_IDS) - sort_key = dict((el, count) for count, el in enumerate(document.iter())).__getitem__ + sort_key = dict( + (el, count) for count, el in enumerate(document.iter()) + ).__getitem__ css_to_xpath = GenericTranslator().css_to_xpath html_css_to_xpath = HTMLTranslator().css_to_xpath @@ -769,7 +850,14 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> List[str]: return result all_ids = pcss("*") - assert all_ids[:6] == ["html", "nil", "link-href", "link-nohref", "nil", "outer-div"] + assert all_ids[:6] == [ + "html", + "nil", + "link-href", + "link-nohref", + "nil", + "outer-div", + ] assert all_ids[-1:] == ["foobar-span"] assert pcss("div") == ["outer-div", "li-div", "foobar-div"] assert pcss("DIV", html_only=True) == [ @@ -780,7 +868,9 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> List[str]: assert pcss("div div") == ["li-div"] assert pcss("div, div div") == ["outer-div", "li-div", "foobar-div"] assert pcss("a[name]") == ["name-anchor"] - assert pcss("a[NAme]", html_only=True) == ["name-anchor"] # case-insensitive in HTML: + assert pcss("a[NAme]", html_only=True) == [ + "name-anchor" + ] # case-insensitive in HTML: assert pcss("a[rel]") == ["tag-anchor", "nofollow-anchor"] assert pcss('a[rel="tag"]') == ["tag-anchor"] assert pcss('a[href*="localhost"]') == ["tag-anchor"] @@ -798,7 +888,10 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> List[str]: assert pcss('*[lang|="en"]', '[lang|="en-US"]') == [] assert pcss('*[lang|="e"]') == [] # ... :lang() is not. - assert pcss(':lang("EN")', "*:lang(en-US)", html_only=True) == ["second-li", "li-div"] + assert pcss(':lang("EN")', "*:lang(en-US)", html_only=True) == [ + "second-li", + "li-div", + ] assert pcss(':lang("e")', html_only=True) == [] assert pcss(":scope > div") == [] assert pcss(":scope body") == ["nil"] @@ -852,7 +945,11 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> List[str]: "seventh-li", ] assert pcss("li:nth-last-child(2n+2)") == ["second-li", "fourth-li", "sixth-li"] - assert pcss("li:nth-last-child(3n+1)") == ["first-li", "fourth-li", "seventh-li"] + assert pcss("li:nth-last-child(3n+1)") == [ + "first-li", + "fourth-li", + "seventh-li", + ] assert pcss("ol:first-of-type") == ["first-ol"] assert pcss("ol:nth-child(1)") == [] assert pcss("ol:nth-of-type(2)") == ["second-ol"] @@ -901,7 +998,10 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> List[str]: assert pcss('*:contains("E")') == [] # case-sensitive assert pcss(".a", ".b", "*.a", "ol.a") == ["first-ol"] assert pcss(".c", "*.c") == ["first-ol", "third-li", "fourth-li"] - assert pcss("ol *.c", "ol li.c", "li ~ li.c", "ol > li.c") == ["third-li", "fourth-li"] + assert pcss("ol *.c", "ol li.c", "li ~ li.c", "ol > li.c") == [ + "third-li", + "fourth-li", + ] assert pcss("#first-li", "li#first-li", "*#first-li") == ["first-li"] assert pcss("li div", "li > div", "div div") == ["li-div"] assert pcss("div > div") == [] diff --git a/tox.ini b/tox.ini index 24dec48..6831d3f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = black,flake8,pylint,security,py,docs +envlist = pre-commit,pylint,py,docs,typing [testenv] deps = @@ -13,18 +13,6 @@ commands = --cov-report=term-missing --cov-report=html --cov-report=xml \ --verbose {posargs: cssselect tests docs} -[testenv:black] -deps = - black==22.10.0 -commands = - black --check {posargs: cssselect setup.py tests} - -[testenv:flake8] -deps = - flake8==6.1.0 -commands = - flake8 {posargs: cssselect setup.py tests docs/conf.py} - [testenv:pylint] deps = {[testenv]deps} @@ -32,12 +20,6 @@ deps = commands = pylint {posargs: cssselect setup.py tests docs} -[testenv:security] -deps = - bandit -commands = - bandit -r -c .bandit.yml {posargs: cssselect} - [testenv:docs] changedir = docs deps = @@ -52,3 +34,8 @@ deps = mypy==0.982 commands = mypy --strict {posargs: cssselect tests} + +[testenv:pre-commit] +deps = pre-commit +commands = pre-commit run --all-files --show-diff-on-failure +skip_install = true \ No newline at end of file From ae04981df42b59c9cdaecfcb1a02a00534052360 Mon Sep 17 00:00:00 2001 From: Emmanuel Rondan Date: Thu, 11 Apr 2024 12:59:32 -0300 Subject: [PATCH 05/34] ignoring pre-commit commit from blame --- .git-blame-ignore-revs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index e746ff9..9d2c8f6 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1 +1,2 @@ -# applying pre-commit hooks to the project \ No newline at end of file +# applying pre-commit hooks to the project +e91101b37f82558db84a6b8ee9a6dba1fd2ae0bb \ No newline at end of file From 24ae330f70acd56bd54a0f98c261efe2201f6e14 Mon Sep 17 00:00:00 2001 From: Emmanuel Rondan Date: Thu, 11 Apr 2024 14:22:18 -0300 Subject: [PATCH 06/34] removing security from github CI --- .github/workflows/checks.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1e9a243..5b6cfbf 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -10,9 +10,6 @@ jobs: - python-version: 3.12 env: TOXENV: pylint - - python-version: 3.12 - env: - TOXENV: security - python-version: 3.12 env: TOXENV: docs From 15df23ef2176521c45fde954e5476ee2a4696b03 Mon Sep 17 00:00:00 2001 From: Laerte Pereira <5853172+Laerte@users.noreply.github.com> Date: Mon, 22 Apr 2024 04:57:29 -0300 Subject: [PATCH 07/34] Add official PyPy support (#147) --- .github/workflows/tests.yml | 2 +- setup.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0de2aa2..a1a0524 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "pypy3.9", "pypy3.10"] steps: - uses: actions/checkout@v4 diff --git a/setup.py b/setup.py index 43eecc0..f01a174 100644 --- a/setup.py +++ b/setup.py @@ -41,5 +41,7 @@ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", ], ) From 9e2ff26036d7e026b76bf48b328a1de281f7c66e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 16 Oct 2024 13:34:27 +0500 Subject: [PATCH 08/34] Add Python 3.13, drop Python 3.8, update tool versions, add twinecheck (#148) --- .github/workflows/checks.yml | 14 +++++++++----- .github/workflows/publish.yml | 6 +++--- .github/workflows/tests.yml | 5 +++-- .pre-commit-config.yaml | 8 ++++---- MANIFEST.in | 2 +- pylintrc | 1 + setup.py | 6 +++--- tox.ini | 17 +++++++++++++---- 8 files changed, 37 insertions(+), 22 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 5b6cfbf..cf0e689 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -5,23 +5,27 @@ jobs: checks: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: include: - - python-version: 3.12 + - python-version: 3.13 env: TOXENV: pylint - - python-version: 3.12 + - python-version: 3.12 # Keep in sync with .readthedocs.yml env: TOXENV: docs - - python-version: 3.12 + - python-version: 3.13 env: TOXENV: typing + - python-version: 3.13 + env: + TOXENV: twinecheck steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -36,4 +40,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pre-commit/action@v3.0.0 + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 67d9c5a..36f80b5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,10 +9,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v4 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 with: - python-version: 3.12 + python-version: 3.13 - name: Check Tag id: check-release-tag diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a1a0524..70b6c77 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,14 +5,15 @@ jobs: tests: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "pypy3.9", "pypy3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a27d3db..ab99544 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,18 @@ repos: - repo: https://github.com/PyCQA/bandit - rev: 1.7.8 + rev: 1.7.10 hooks: - id: bandit args: [-r, -c, .bandit.yml] - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 + rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/psf/black.git - rev: 24.3.0 + rev: 24.10.0 hooks: - id: black - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - - id: isort \ No newline at end of file + - id: isort diff --git a/MANIFEST.in b/MANIFEST.in index 7fc2933..5561683 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include AUTHORS CHANGES LICENSE README.rst tox.ini .coveragerc py.typed +include AUTHORS CHANGES LICENSE README.rst tox.ini .coveragerc cssselect/py.typed recursive-include docs * recursive-include tests * prune docs/_build diff --git a/pylintrc b/pylintrc index e35425e..5a4647b 100644 --- a/pylintrc +++ b/pylintrc @@ -23,6 +23,7 @@ disable=assignment-from-no-return, too-many-branches, too-many-function-args, too-many-lines, + too-many-positional-arguments, too-many-public-methods, too-many-statements, undefined-variable, diff --git a/setup.py b/setup.py index f01a174..4c5d49d 100644 --- a/setup.py +++ b/setup.py @@ -22,25 +22,25 @@ maintainer_email="paul.tremberth@gmail.com", description="cssselect parses CSS3 Selectors and translates them to XPath 1.0", long_description=README, + long_description_content_type="text/x-rst", url="https://github.com/scrapy/cssselect", license="BSD", packages=["cssselect"], - test_suite="cssselect.tests", package_data={ "cssselect": ["py.typed"], }, include_package_data=True, - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], diff --git a/tox.ini b/tox.ini index 6831d3f..616d223 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ commands = [testenv:pylint] deps = {[testenv]deps} - pylint==3.0.0 + pylint==3.3.1 commands = pylint {posargs: cssselect setup.py tests docs} @@ -30,12 +30,21 @@ commands = [testenv:typing] deps = {[testenv]deps} - lxml-stubs==0.4.0 - mypy==0.982 + mypy==1.11.2 + types-lxml==2024.9.16 commands = mypy --strict {posargs: cssselect tests} [testenv:pre-commit] deps = pre-commit commands = pre-commit run --all-files --show-diff-on-failure -skip_install = true \ No newline at end of file +skip_install = true + +[testenv:twinecheck] +basepython = python3 +deps = + twine==5.1.1 + build==1.2.2 +commands = + python -m build --sdist + twine check dist/* From 0b3b9f278a1e50fc3236483d1b3d25b78db20021 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 27 Jan 2025 14:16:28 +0400 Subject: [PATCH 09/34] Remove a deprecated function. (#149) --- cssselect/xpath.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 4255f66..ee59f89 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -14,7 +14,6 @@ import re import typing -import warnings from typing import Optional from cssselect.parser import ( @@ -38,17 +37,6 @@ ) -@typing.no_type_check -def _unicode_safe_getattr(obj, name, default=None): - warnings.warn( - "_unicode_safe_getattr is deprecated and will be removed in the" - " next release, use getattr() instead", - DeprecationWarning, - stacklevel=2, - ) - return getattr(obj, name, default) - - class ExpressionError(SelectorError, RuntimeError): """Unknown or unsupported selector (eg. pseudo-class).""" From 58b436f4adf9eaf9844ada7d1003d6dfd672c2a8 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 3 Feb 2025 20:36:00 +0400 Subject: [PATCH 10/34] Migrate to ruff (#150) * Add pyupgrade. * Bump tool versions. * Migrate to ruff. * Simplify typing casts. * Cleanup pylintrc. * More cleanup. * Move tool configs to pyproject.toml. * Update the nitpicky config. * Exclude TYPE_CHECKING blocks from coverage. * Remove an extra newline. --- .bandit.yml | 6 - .bumpversion.cfg | 6 - .coveragerc | 10 -- .flake8 | 16 --- .github/workflows/tests.yml | 2 +- .isort.cfg | 2 - .pre-commit-config.yaml | 21 +-- cssselect/__init__.py | 17 ++- cssselect/parser.py | 276 +++++++++++++++++------------------- cssselect/xpath.py | 169 +++++++++++----------- docs/conf.py | 11 +- pylintrc | 33 ----- pyproject.toml | 178 ++++++++++++++++++++++- setup.cfg | 13 -- setup.py | 12 +- tests/test_cssselect.py | 102 +++++++------ tox.ini | 6 +- 17 files changed, 463 insertions(+), 417 deletions(-) delete mode 100644 .bandit.yml delete mode 100644 .bumpversion.cfg delete mode 100644 .coveragerc delete mode 100644 .flake8 delete mode 100644 .isort.cfg delete mode 100644 pylintrc delete mode 100644 setup.cfg diff --git a/.bandit.yml b/.bandit.yml deleted file mode 100644 index 4f60a02..0000000 --- a/.bandit.yml +++ /dev/null @@ -1,6 +0,0 @@ -skips: -- B101 -- B311 -- B320 -- B410 -exclude_dirs: ['tests'] diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 56cfabc..0000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[bumpversion] -current_version = 1.2.0 -commit = True -tag = True - -[bumpversion:file:cssselect/__init__.py] diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index ed1fac6..0000000 --- a/.coveragerc +++ /dev/null @@ -1,10 +0,0 @@ -[run] -branch = True -source = cssselect - -[report] -exclude_lines = - pragma: no cover - def __repr__ - if sys.version_info - if __name__ == '__main__': diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 2417f2e..0000000 --- a/.flake8 +++ /dev/null @@ -1,16 +0,0 @@ -[flake8] -max-line-length = 99 -ignore = - W503 - # too many leading '#' for block comment - E266 - E704 -exclude = - .git - .tox - venv* - - # pending revision - docs/conf.py -per-file-ignores = - cssselect/__init__.py:F401 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 70b6c77..427c4ad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,4 +24,4 @@ jobs: tox -e py - name: Upload coverage report - run: bash <(curl -s https://codecov.io/bash) + uses: codecov/codecov-action@v5 diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 6860bdb..0000000 --- a/.isort.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[settings] -profile = black \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab99544..b1829a6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,7 @@ repos: -- repo: https://github.com/PyCQA/bandit - rev: 1.7.10 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.4 hooks: - - id: bandit - args: [-r, -c, .bandit.yml] -- repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 - hooks: - - id: flake8 -- repo: https://github.com/psf/black.git - rev: 24.10.0 - hooks: - - id: black -- repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort + - id: ruff + args: [ --fix ] + - id: ruff-format diff --git a/cssselect/__init__.py b/cssselect/__init__.py index a59995c..c53b539 100644 --- a/cssselect/__init__.py +++ b/cssselect/__init__.py @@ -1,15 +1,14 @@ -# -*- coding: utf-8 -*- """ - CSS Selectors based on XPath - ============================ +CSS Selectors based on XPath +============================ - This module supports selecting XML/HTML elements based on CSS selectors. - See the `CSSSelector` class for details. +This module supports selecting XML/HTML elements based on CSS selectors. +See the `CSSSelector` class for details. - :copyright: (c) 2007-2012 Ian Bicking and contributors. - See AUTHORS for more details. - :license: BSD, see LICENSE for more details. +:copyright: (c) 2007-2012 Ian Bicking and contributors. +See AUTHORS for more details. +:license: BSD, see LICENSE for more details. """ @@ -27,10 +26,10 @@ "FunctionalPseudoElement", "GenericTranslator", "HTMLTranslator", - "parse", "Selector", "SelectorError", "SelectorSyntaxError", + "parse", ) VERSION = "1.2.0" diff --git a/cssselect/parser.py b/cssselect/parser.py index 354713d..d16751f 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -1,22 +1,28 @@ -# -*- coding: utf-8 -*- """ - cssselect.parser - ================ +cssselect.parser +================ - Tokenizer, parser and parsed objects for CSS selectors. +Tokenizer, parser and parsed objects for CSS selectors. - :copyright: (c) 2007-2012 Ian Bicking and contributors. - See AUTHORS for more details. - :license: BSD, see LICENSE for more details. +:copyright: (c) 2007-2012 Ian Bicking and contributors. +See AUTHORS for more details. +:license: BSD, see LICENSE for more details. """ +from __future__ import annotations + import operator import re import sys -import typing -from typing import Iterable, Iterator, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Literal, Optional, Protocol, Union, cast, overload + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Sequence + + # typing.Self requires Python 3.11 + from typing_extensions import Self def ascii_lower(string: str) -> str: @@ -67,9 +73,7 @@ class Selector: """ - def __init__( - self, tree: Tree, pseudo_element: Optional[PseudoElement] = None - ) -> None: + def __init__(self, tree: Tree, pseudo_element: PseudoElement | None = None) -> None: self.parsed_tree = tree if pseudo_element is not None and not isinstance( pseudo_element, FunctionalPseudoElement @@ -119,7 +123,7 @@ def canonical(self) -> str: res = res.lstrip("*") return res - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: """Return the specificity_ of this selector as a tuple of 3 integers. .. _specificity: http://www.w3.org/TR/selectors/#specificity @@ -146,7 +150,7 @@ def __repr__(self) -> str: def canonical(self) -> str: return "%s.%s" % (self.selector.canonical(), self.class_name) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() b += 1 return a, b, c @@ -170,7 +174,7 @@ class FunctionalPseudoElement: """ - def __init__(self, name: str, arguments: Sequence["Token"]): + def __init__(self, name: str, arguments: Sequence[Token]): self.name = ascii_lower(name) self.arguments = arguments @@ -181,7 +185,7 @@ def __repr__(self) -> str: [token.value for token in self.arguments], ) - def argument_types(self) -> List[str]: + def argument_types(self) -> list[str]: return [token.type for token in self.arguments] def canonical(self) -> str: @@ -194,7 +198,7 @@ class Function: Represents selector:name(expr) """ - def __init__(self, selector: Tree, name: str, arguments: Sequence["Token"]) -> None: + def __init__(self, selector: Tree, name: str, arguments: Sequence[Token]) -> None: self.selector = selector self.name = ascii_lower(name) self.arguments = arguments @@ -207,14 +211,14 @@ def __repr__(self) -> str: [token.value for token in self.arguments], ) - def argument_types(self) -> List[str]: + def argument_types(self) -> list[str]: return [token.type for token in self.arguments] def canonical(self) -> str: args = "".join(token.css() for token in self.arguments) return "%s:%s(%s)" % (self.selector.canonical(), self.name, args) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() b += 1 return a, b, c @@ -235,7 +239,7 @@ def __repr__(self) -> str: def canonical(self) -> str: return "%s:%s" % (self.selector.canonical(), self.ident) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() b += 1 return a, b, c @@ -263,7 +267,7 @@ def canonical(self) -> str: subsel = subsel.lstrip("*") return "%s:not(%s)" % (self.selector.canonical(), subsel) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: a1, b1, c1 = self.selector.specificity() a2, b2, c2 = self.subselector.specificity() return a1 + a2, b1 + b2, c1 + c2 @@ -274,7 +278,7 @@ class Relation: Represents selector:has(subselector) """ - def __init__(self, selector: Tree, combinator: "Token", subselector: Selector): + def __init__(self, selector: Tree, combinator: Token, subselector: Selector): self.selector = selector self.combinator = combinator self.subselector = subselector @@ -288,17 +292,17 @@ def __repr__(self) -> str: def canonical(self) -> str: try: - subsel = self.subselector[0].canonical() # type: ignore + subsel = self.subselector[0].canonical() # type: ignore[index] except TypeError: subsel = self.subselector.canonical() if len(subsel) > 1: subsel = subsel.lstrip("*") return "%s:has(%s)" % (self.selector.canonical(), subsel) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: a1, b1, c1 = self.selector.specificity() try: - a2, b2, c2 = self.subselector[-1].specificity() # type: ignore + a2, b2, c2 = self.subselector[-1].specificity() # type: ignore[index] except TypeError: a2, b2, c2 = self.subselector.specificity() return a1 + a2, b1 + b2, c1 + c2 @@ -330,7 +334,7 @@ def canonical(self) -> str: ", ".join(map(str, selector_arguments)), ) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: return max(x.specificity() for x in self.selector_list) @@ -340,7 +344,7 @@ class SpecificityAdjustment: Same as selector:is(selector_list), but its specificity is always 0 """ - def __init__(self, selector: Tree, selector_list: List[Tree]): + def __init__(self, selector: Tree, selector_list: list[Tree]): self.selector = selector self.selector_list = selector_list @@ -361,7 +365,7 @@ def canonical(self) -> str: ", ".join(map(str, selector_arguments)), ) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: return 0, 0, 0 @@ -370,33 +374,33 @@ class Attrib: Represents selector[namespace|attrib operator value] """ - @typing.overload + @overload def __init__( self, selector: Tree, - namespace: Optional[str], + namespace: str | None, attrib: str, - operator: 'typing.Literal["exists"]', + operator: Literal["exists"], value: None, ) -> None: ... - @typing.overload + @overload def __init__( self, selector: Tree, - namespace: Optional[str], + namespace: str | None, attrib: str, operator: str, - value: "Token", + value: Token, ) -> None: ... def __init__( self, selector: Tree, - namespace: Optional[str], + namespace: str | None, attrib: str, operator: str, - value: Optional["Token"], + value: Token | None, ) -> None: self.selector = selector self.namespace = namespace @@ -411,14 +415,14 @@ def __repr__(self) -> str: attrib = self.attrib if self.operator == "exists": return "%s[%r[%s]]" % (self.__class__.__name__, self.selector, attrib) - else: - return "%s[%r[%s %s %r]]" % ( - self.__class__.__name__, - self.selector, - attrib, - self.operator, - typing.cast("Token", self.value).value, - ) + assert self.value is not None + return "%s[%r[%s %s %r]]" % ( + self.__class__.__name__, + self.selector, + attrib, + self.operator, + self.value.value, + ) def canonical(self) -> str: if self.namespace: @@ -429,15 +433,16 @@ def canonical(self) -> str: if self.operator == "exists": op = attrib else: + assert self.value is not None op = "%s%s%s" % ( attrib, self.operator, - typing.cast("Token", self.value).css(), + self.value.css(), ) return "%s[%s]" % (self.selector.canonical(), op) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() b += 1 return a, b, c @@ -452,7 +457,7 @@ class Element: """ def __init__( - self, namespace: Optional[str] = None, element: Optional[str] = None + self, namespace: str | None = None, element: str | None = None ) -> None: self.namespace = namespace self.element = element @@ -466,11 +471,10 @@ def canonical(self) -> str: element = "%s|%s" % (self.namespace, element) return element - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: if self.element: return 0, 0, 1 - else: - return 0, 0, 0 + return 0, 0, 0 class Hash: @@ -488,7 +492,7 @@ def __repr__(self) -> str: def canonical(self) -> str: return "%s#%s" % (self.selector.canonical(), self.id) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() a += 1 return a, b, c @@ -502,10 +506,7 @@ def __init__(self, selector: Tree, combinator: str, subselector: Tree) -> None: self.subselector = subselector def __repr__(self) -> str: - if self.combinator == " ": - comb = "" - else: - comb = self.combinator + comb = "" if self.combinator == " " else self.combinator return "%s[%r %s %r]" % ( self.__class__.__name__, self.selector, @@ -519,7 +520,7 @@ def canonical(self) -> str: subsel = subsel.lstrip("*") return "%s %s %s" % (self.selector.canonical(), self.combinator, subsel) - def specificity(self) -> Tuple[int, int, int]: + def specificity(self) -> tuple[int, int, int]: a1, b1, c1 = self.selector.specificity() a2, b2, c2 = self.subselector.specificity() return a1 + a2, b1 + b2, c1 + c2 @@ -539,7 +540,7 @@ def specificity(self) -> Tuple[int, int, int]: ) -def parse(css: str) -> List[Selector]: +def parse(css: str) -> list[Selector]: """Parse a CSS *group of selectors*. If you don't care about pseudo-elements or selector specificity, @@ -581,7 +582,7 @@ def parse(css: str) -> List[Selector]: # raise -def parse_selector_group(stream: "TokenStream") -> Iterator[Selector]: +def parse_selector_group(stream: TokenStream) -> Iterator[Selector]: stream.skip_whitespace() while 1: yield Selector(*parse_selector(stream)) @@ -592,7 +593,7 @@ def parse_selector_group(stream: "TokenStream") -> Iterator[Selector]: break -def parse_selector(stream: "TokenStream") -> Tuple[Tree, Optional[PseudoElement]]: +def parse_selector(stream: TokenStream) -> tuple[Tree, PseudoElement | None]: result, pseudo_element = parse_simple_selector(stream) while 1: stream.skip_whitespace() @@ -605,7 +606,7 @@ def parse_selector(stream: "TokenStream") -> Tuple[Tree, Optional[PseudoElement] ) if peek.is_delim("+", ">", "~"): # A combinator - combinator = typing.cast(str, stream.next().value) + combinator = cast(str, stream.next().value) stream.skip_whitespace() else: # By exclusion, the last parse_simple_selector() ended @@ -617,8 +618,8 @@ def parse_selector(stream: "TokenStream") -> Tuple[Tree, Optional[PseudoElement] def parse_simple_selector( - stream: "TokenStream", inside_negation: bool = False -) -> Tuple[Tree, Optional[PseudoElement]]: + stream: TokenStream, inside_negation: bool = False +) -> tuple[Tree, PseudoElement | None]: stream.skip_whitespace() selector_start = len(stream.used) peek = stream.peek() @@ -637,7 +638,7 @@ def parse_simple_selector( else: element = namespace = None result: Tree = Element(namespace, element) - pseudo_element: Optional[PseudoElement] = None + pseudo_element: PseudoElement | None = None while 1: peek = stream.peek() if ( @@ -651,7 +652,7 @@ def parse_simple_selector( "Got pseudo-element ::%s not at the end of a selector" % pseudo_element ) if peek.type == "HASH": - result = Hash(result, typing.cast(str, stream.next().value)) + result = Hash(result, cast(str, stream.next().value)) elif peek == ("DELIM", "."): stream.next() result = Class(result, stream.next_ident()) @@ -680,21 +681,20 @@ def parse_simple_selector( continue if stream.peek() != ("DELIM", "("): result = Pseudo(result, ident) - if repr(result) == "Pseudo[Element[*]:scope]": - if not ( - len(stream.used) == 2 - or (len(stream.used) == 3 and stream.used[0].type == "S") - or (len(stream.used) >= 3 and stream.used[-3].is_delim(",")) - or ( - len(stream.used) >= 4 - and stream.used[-3].type == "S" - and stream.used[-4].is_delim(",") - ) - ): - raise SelectorSyntaxError( - 'Got immediate child pseudo-element ":scope" ' - "not at the start of a selector" - ) + if repr(result) == "Pseudo[Element[*]:scope]" and not ( + len(stream.used) == 2 + or (len(stream.used) == 3 and stream.used[0].type == "S") + or (len(stream.used) >= 3 and stream.used[-3].is_delim(",")) + or ( + len(stream.used) >= 4 + and stream.used[-3].type == "S" + and stream.used[-4].is_delim(",") + ) + ): + raise SelectorSyntaxError( + 'Got immediate child pseudo-element ":scope" ' + "not at the start of a selector" + ) continue stream.next() stream.skip_whitespace() @@ -732,9 +732,9 @@ def parse_simple_selector( return result, pseudo_element -def parse_arguments(stream: "TokenStream") -> List["Token"]: - arguments: List["Token"] = [] - while 1: +def parse_arguments(stream: TokenStream) -> list[Token]: + arguments: list[Token] = [] + while 1: # noqa: RET503 stream.skip_whitespace() next = stream.next() if next.type in ("IDENT", "STRING", "NUMBER") or next in [ @@ -748,7 +748,7 @@ def parse_arguments(stream: "TokenStream") -> List["Token"]: raise SelectorSyntaxError("Expected an argument, got %s" % (next,)) -def parse_relative_selector(stream: "TokenStream") -> Tuple["Token", Selector]: +def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: stream.skip_whitespace() subselector = "" next = stream.next() @@ -760,12 +760,12 @@ def parse_relative_selector(stream: "TokenStream") -> Tuple["Token", Selector]: else: combinator = Token("DELIM", " ", pos=0) - while 1: + while 1: # noqa: RET503 if next.type in ("IDENT", "STRING", "NUMBER") or next in [ ("DELIM", "."), ("DELIM", "*"), ]: - subselector += typing.cast(str, next.value) + subselector += cast(str, next.value) elif next == ("DELIM", ")"): result = parse(subselector) return combinator, result[0] @@ -774,7 +774,7 @@ def parse_relative_selector(stream: "TokenStream") -> Tuple["Token", Selector]: next = stream.next() -def parse_simple_selector_arguments(stream: "TokenStream") -> List[Tree]: +def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: arguments = [] while 1: result, pseudo_element = parse_simple_selector(stream, True) @@ -796,13 +796,13 @@ def parse_simple_selector_arguments(stream: "TokenStream") -> List[Tree]: return arguments -def parse_attrib(selector: Tree, stream: "TokenStream") -> Attrib: +def parse_attrib(selector: Tree, stream: TokenStream) -> Attrib: stream.skip_whitespace() attrib = stream.next_ident_or_star() if attrib is None and stream.peek() != ("DELIM", "|"): raise SelectorSyntaxError("Expected '|', got %s" % (stream.peek(),)) - namespace: Optional[str] - op: Optional[str] + namespace: str | None + op: str | None if stream.peek() == ("DELIM", "|"): stream.next() if stream.peek() == ("DELIM", "="): @@ -819,13 +819,13 @@ def parse_attrib(selector: Tree, stream: "TokenStream") -> Attrib: stream.skip_whitespace() next = stream.next() if next == ("DELIM", "]"): - return Attrib(selector, namespace, typing.cast(str, attrib), "exists", None) - elif next == ("DELIM", "="): + return Attrib(selector, namespace, cast(str, attrib), "exists", None) + if next == ("DELIM", "="): op = "=" elif next.is_delim("^", "$", "*", "~", "|", "!") and ( stream.peek() == ("DELIM", "=") ): - op = typing.cast(str, next.value) + "=" + op = cast(str, next.value) + "=" stream.next() else: raise SelectorSyntaxError("Operator expected, got %s" % (next,)) @@ -837,10 +837,10 @@ def parse_attrib(selector: Tree, stream: "TokenStream") -> Attrib: next = stream.next() if next != ("DELIM", "]"): raise SelectorSyntaxError("Expected ']', got %s" % (next,)) - return Attrib(selector, namespace, typing.cast(str, attrib), op, value) + return Attrib(selector, namespace, cast(str, attrib), op, value) -def parse_series(tokens: Iterable["Token"]) -> Tuple[int, int]: +def parse_series(tokens: Iterable[Token]) -> tuple[int, int]: """ Parses the arguments for :nth-child() and friends. @@ -851,12 +851,12 @@ def parse_series(tokens: Iterable["Token"]) -> Tuple[int, int]: for token in tokens: if token.type == "STRING": raise ValueError("String tokens not allowed in series.") - s = "".join(typing.cast(str, token.value) for token in tokens).strip() + s = "".join(cast(str, token.value) for token in tokens).strip() if s == "odd": return 2, 1 - elif s == "even": + if s == "even": return 2, 0 - elif s == "n": + if s == "n": return 1, 0 if "n" not in s: # Just b @@ -865,36 +865,30 @@ def parse_series(tokens: Iterable["Token"]) -> Tuple[int, int]: a_as_int: int if not a: a_as_int = 1 - elif a == "-" or a == "+": + elif a in {"-", "+"}: a_as_int = int(a + "1") else: a_as_int = int(a) - b_as_int: int - if not b: - b_as_int = 0 - else: - b_as_int = int(b) + b_as_int = int(b) if b else 0 return a_as_int, b_as_int #### Token objects -class Token(Tuple[str, Optional[str]]): - @typing.overload +class Token(tuple[str, Optional[str]]): # noqa: SLOT001 + @overload def __new__( cls, - type_: 'typing.Literal["IDENT", "HASH", "STRING", "S", "DELIM", "NUMBER"]', + type_: Literal["IDENT", "HASH", "STRING", "S", "DELIM", "NUMBER"], value: str, pos: int, - ) -> "Token": ... + ) -> Self: ... - @typing.overload - def __new__( - cls, type_: 'typing.Literal["EOF"]', value: None, pos: int - ) -> "Token": ... + @overload + def __new__(cls, type_: Literal["EOF"], value: None, pos: int) -> Self: ... - def __new__(cls, type_: str, value: Optional[str], pos: int) -> "Token": + def __new__(cls, type_: str, value: str | None, pos: int) -> Self: obj = tuple.__new__(cls, (type_, value)) obj.pos = pos return obj @@ -912,19 +906,18 @@ def type(self) -> str: return self[0] @property - def value(self) -> Optional[str]: + def value(self) -> str | None: return self[1] def css(self) -> str: if self.type == "STRING": return repr(self.value) - else: - return typing.cast(str, self.value) + return cast(str, self.value) class EOFToken(Token): - def __new__(cls, pos: int) -> "EOFToken": - return typing.cast("EOFToken", Token.__new__(cls, "EOF", None, pos)) + def __new__(cls, pos: int) -> Self: + return Token.__new__(cls, "EOF", None, pos) def __repr__(self) -> str: return "<%s at %i>" % (self.type, self.pos) @@ -942,15 +935,13 @@ class TokenMacros: nmstart = "[_a-z]|%s|%s" % (escape, nonascii) -if typing.TYPE_CHECKING: +class MatchFunc(Protocol): + def __call__( + self, string: str, pos: int = ..., endpos: int = ... + ) -> re.Match[str] | None: ... - class MatchFunc(typing.Protocol): - def __call__( - self, string: str, pos: int = ..., endpos: int = ... - ) -> Optional["re.Match[str]"]: ... - -def _compile(pattern: str) -> "MatchFunc": +def _compile(pattern: str) -> MatchFunc: return re.compile(pattern % vars(TokenMacros), re.IGNORECASE).match @@ -964,14 +955,14 @@ def _compile(pattern: str) -> "MatchFunc": } _sub_simple_escape = re.compile(r"\\(.)").sub -_sub_unicode_escape = re.compile(TokenMacros.unicode_escape, re.I).sub +_sub_unicode_escape = re.compile(TokenMacros.unicode_escape, re.IGNORECASE).sub _sub_newline_escape = re.compile(r"\\(?:\n|\r\n|\r|\f)").sub # Same as r'\1', but faster on CPython _replace_simple = operator.methodcaller("group", 1) -def _replace_unicode(match: "re.Match[str]") -> str: +def _replace_unicode(match: re.Match[str]) -> str: codepoint = int(match.group(1), 16) if codepoint > sys.maxunicode: codepoint = 0xFFFD @@ -980,8 +971,7 @@ def _replace_unicode(match: "re.Match[str]") -> str: def unescape_ident(value: str) -> str: value = _sub_unicode_escape(_replace_unicode, value) - value = _sub_simple_escape(_replace_simple, value) - return value + return _sub_simple_escape(_replace_simple, value) def tokenize(s: str) -> Iterator[Token]: @@ -1056,44 +1046,44 @@ def tokenize(s: str) -> Iterator[Token]: class TokenStream: - def __init__(self, tokens: Iterable[Token], source: Optional[str] = None) -> None: - self.used: List[Token] = [] + def __init__(self, tokens: Iterable[Token], source: str | None = None) -> None: + self.used: list[Token] = [] self.tokens = iter(tokens) self.source = source - self.peeked: Optional[Token] = None + self.peeked: Token | None = None self._peeking = False self.next_token = self.tokens.__next__ def next(self) -> Token: if self._peeking: self._peeking = False - self.used.append(typing.cast(Token, self.peeked)) - return typing.cast(Token, self.peeked) - else: - next = self.next_token() - self.used.append(next) - return next + assert self.peeked is not None + self.used.append(self.peeked) + return self.peeked + next = self.next_token() + self.used.append(next) + return next def peek(self) -> Token: if not self._peeking: self.peeked = self.next_token() self._peeking = True - return typing.cast(Token, self.peeked) + assert self.peeked is not None + return self.peeked def next_ident(self) -> str: next = self.next() if next.type != "IDENT": raise SelectorSyntaxError("Expected ident, got %s" % (next,)) - return typing.cast(str, next.value) + return cast(str, next.value) - def next_ident_or_star(self) -> Optional[str]: + def next_ident_or_star(self) -> str | None: next = self.next() if next.type == "IDENT": return next.value - elif next == ("DELIM", "*"): + if next == ("DELIM", "*"): return None - else: - raise SelectorSyntaxError("Expected ident or '*', got %s" % (next,)) + raise SelectorSyntaxError("Expected ident or '*', got %s" % (next,)) def skip_whitespace(self) -> None: peek = self.peek() diff --git a/cssselect/xpath.py b/cssselect/xpath.py index ee59f89..e9d1065 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -1,20 +1,21 @@ -# -*- coding: utf-8 -*- """ - cssselect.xpath - =============== +cssselect.xpath +=============== - Translation of parsed CSS selectors to XPath expressions. +Translation of parsed CSS selectors to XPath expressions. - :copyright: (c) 2007-2012 Ian Bicking and contributors. - See AUTHORS for more details. - :license: BSD, see LICENSE for more details. +:copyright: (c) 2007-2012 Ian Bicking and contributors. +See AUTHORS for more details. +:license: BSD, see LICENSE for more details. """ +from __future__ import annotations + import re -import typing -from typing import Optional +from collections.abc import Callable +from typing import TYPE_CHECKING, Optional, cast from cssselect.parser import ( Attrib, @@ -36,6 +37,10 @@ parse_series, ) +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + class ExpressionError(SelectorError, RuntimeError): """Unknown or unsupported selector (eg. pseudo-class).""" @@ -65,7 +70,7 @@ def __str__(self) -> str: def __repr__(self) -> str: return "%s[%s]" % (self.__class__.__name__, self) - def add_condition(self, condition: str, conjuction: str = "and") -> "XPathExpr": + def add_condition(self, condition: str, conjuction: str = "and") -> Self: if self.condition: self.condition = "(%s) %s (%s)" % (self.condition, conjuction, condition) else: @@ -91,10 +96,10 @@ def add_star_prefix(self) -> None: def join( self, combiner: str, - other: "XPathExpr", - closing_combiner: Optional[str] = None, + other: XPathExpr, + closing_combiner: str | None = None, has_inner_condition: bool = False, - ) -> "XPathExpr": + ) -> Self: path = str(self) + combiner # Any "star prefix" is redundant when joining. if other.path != "*/": @@ -274,33 +279,35 @@ def xpath_literal(s: str) -> str: elif '"' not in s: s = '"%s"' % s else: - s = "concat(%s)" % ",".join( - [ - (("'" in part) and '"%s"' or "'%s'") % part - for part in split_at_single_quotes(s) - if part - ] - ) + parts_quoted = [ + f'"{part}"' if "'" in part else f"'{part}'" + for part in split_at_single_quotes(s) + if part + ] + s = "concat({})".format(",".join(parts_quoted)) return s def xpath(self, parsed_selector: Tree) -> XPathExpr: """Translate any parsed selector object.""" type_name = type(parsed_selector).__name__ - method = getattr(self, "xpath_%s" % type_name.lower(), None) + method = cast( + Optional[Callable[[Tree], XPathExpr]], + getattr(self, "xpath_%s" % type_name.lower(), None), + ) if method is None: raise ExpressionError("%s is not supported." % type_name) - return typing.cast(XPathExpr, method(parsed_selector)) + return method(parsed_selector) # Dispatched by parsed object type def xpath_combinedselector(self, combined: CombinedSelector) -> XPathExpr: """Translate a combined selector.""" combinator = self.combinator_mapping[combined.combinator] - method = getattr(self, "xpath_%s_combinator" % combinator) - return typing.cast( - XPathExpr, - method(self.xpath(combined.selector), self.xpath(combined.subselector)), + method = cast( + Callable[[XPathExpr, XPathExpr], XPathExpr], + getattr(self, "xpath_%s_combinator" % combinator), ) + return method(self.xpath(combined.selector), self.xpath(combined.subselector)) def xpath_negation(self, negation: Negation) -> XPathExpr: xpath = self.xpath(negation.selector) @@ -308,20 +315,22 @@ def xpath_negation(self, negation: Negation) -> XPathExpr: sub_xpath.add_name_test() if sub_xpath.condition: return xpath.add_condition("not(%s)" % sub_xpath.condition) - else: - return xpath.add_condition("0") + return xpath.add_condition("0") def xpath_relation(self, relation: Relation) -> XPathExpr: xpath = self.xpath(relation.selector) combinator = relation.combinator subselector = relation.subselector right = self.xpath(subselector.parsed_tree) - method = getattr( - self, - "xpath_relation_%s_combinator" - % self.combinator_mapping[typing.cast(str, combinator.value)], + method = cast( + Callable[[XPathExpr, XPathExpr], XPathExpr], + getattr( + self, + "xpath_relation_%s_combinator" + % self.combinator_mapping[cast(str, combinator.value)], + ), ) - return typing.cast(XPathExpr, method(xpath, right)) + return method(xpath, right) def xpath_matching(self, matching: Matching) -> XPathExpr: xpath = self.xpath(matching.selector) @@ -344,24 +353,32 @@ def xpath_specificityadjustment(self, matching: SpecificityAdjustment) -> XPathE def xpath_function(self, function: Function) -> XPathExpr: """Translate a functional pseudo-class.""" method_name = "xpath_%s_function" % function.name.replace("-", "_") - method = getattr(self, method_name, None) + method = cast( + Optional[Callable[[XPathExpr, Function], XPathExpr]], + getattr(self, method_name, None), + ) if not method: raise ExpressionError("The pseudo-class :%s() is unknown" % function.name) - return typing.cast(XPathExpr, method(self.xpath(function.selector), function)) + return method(self.xpath(function.selector), function) def xpath_pseudo(self, pseudo: Pseudo) -> XPathExpr: """Translate a pseudo-class.""" method_name = "xpath_%s_pseudo" % pseudo.ident.replace("-", "_") - method = getattr(self, method_name, None) + method = cast( + Optional[Callable[[XPathExpr], XPathExpr]], getattr(self, method_name, None) + ) if not method: # TODO: better error message for pseudo-elements? raise ExpressionError("The pseudo-class :%s is unknown" % pseudo.ident) - return typing.cast(XPathExpr, method(self.xpath(pseudo.selector))) + return method(self.xpath(pseudo.selector)) def xpath_attrib(self, selector: Attrib) -> XPathExpr: """Translate an attribute selector.""" operator = self.attribute_operator_mapping[selector.operator] - method = getattr(self, "xpath_attrib_%s" % operator) + method = cast( + Callable[[XPathExpr, str, Optional[str]], XPathExpr], + getattr(self, "xpath_attrib_%s" % operator), + ) if self.lower_case_attribute_names: name = selector.attrib.lower() else: @@ -377,12 +394,10 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr: if selector.value is None: value = None elif self.lower_case_attribute_values: - value = typing.cast(str, selector.value.value).lower() + value = cast(str, selector.value.value).lower() else: value = selector.value.value - return typing.cast( - XPathExpr, method(self.xpath(selector.selector), attrib, value) - ) + return method(self.xpath(selector.selector), attrib, value) def xpath_class(self, class_selector: Class) -> XPathExpr: """Translate a class selector.""" @@ -459,12 +474,9 @@ def xpath_relation_direct_adjacent_combinator( self, left: XPathExpr, right: XPathExpr ) -> XPathExpr: """right is a sibling immediately after left; select left""" - xpath = left.add_condition( - "following-sibling::*[(name() = '{}') and (position() = 1)]".format( - right.element - ) + return left.add_condition( + f"following-sibling::*[(name() = '{right.element}') and (position() = 1)]" ) - return xpath def xpath_relation_indirect_adjacent_combinator( self, left: XPathExpr, right: XPathExpr @@ -483,8 +495,8 @@ def xpath_nth_child_function( ) -> XPathExpr: try: a, b = parse_series(function.arguments) - except ValueError: - raise ExpressionError("Invalid series: '%r'" % function.arguments) + except ValueError as ex: + raise ExpressionError("Invalid series: '%r'" % function.arguments) from ex # From https://www.w3.org/TR/css3-selectors/#structural-pseudos: # @@ -546,10 +558,7 @@ def xpath_nth_child_function( # `add_name_test` boolean is inverted and somewhat counter-intuitive: # # nth_of_type() calls nth_child(add_name_test=False) - if add_name_test: - nodetest = "*" - else: - nodetest = "%s" % xpath.element + nodetest = "*" if add_name_test else "%s" % xpath.element # count siblings before or after the element if not last: @@ -604,10 +613,7 @@ def xpath_nth_child_function( expressions.append("%s mod %s = 0" % (left, a)) - if len(expressions) > 1: - template = "(%s)" - else: - template = "%s" + template = "(%s)" if len(expressions) > 1 else "%s" xpath.add_condition( " and ".join(template % expression for expression in expressions) ) @@ -644,7 +650,7 @@ def xpath_contains_function( "Expected a single string or ident for :contains(), got %r" % function.arguments ) - value = typing.cast(str, function.arguments[0].value) + value = cast(str, function.arguments[0].value) return xpath.add_condition("contains(., %s)" % self.xpath_literal(value)) def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: @@ -653,7 +659,7 @@ def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr "Expected a single string or ident for :lang(), got %r" % function.arguments ) - value = typing.cast(str, function.arguments[0].value) + value = cast(str, function.arguments[0].value) return xpath.add_condition("lang(%s)" % (self.xpath_literal(value))) # Pseudo: dispatch by pseudo-class name @@ -713,21 +719,21 @@ def pseudo_never_matches(self, xpath: XPathExpr) -> XPathExpr: # Attrib: dispatch by attribute operator def xpath_attrib_exists( - self, xpath: XPathExpr, name: str, value: Optional[str] + self, xpath: XPathExpr, name: str, value: str | None ) -> XPathExpr: assert not value xpath.add_condition(name) return xpath def xpath_attrib_equals( - self, xpath: XPathExpr, name: str, value: Optional[str] + self, xpath: XPathExpr, name: str, value: str | None ) -> XPathExpr: assert value is not None xpath.add_condition("%s = %s" % (name, self.xpath_literal(value))) return xpath def xpath_attrib_different( - self, xpath: XPathExpr, name: str, value: Optional[str] + self, xpath: XPathExpr, name: str, value: str | None ) -> XPathExpr: assert value is not None # FIXME: this seems like a weird hack... @@ -740,36 +746,31 @@ def xpath_attrib_different( return xpath def xpath_attrib_includes( - self, xpath: XPathExpr, name: str, value: Optional[str] + self, xpath: XPathExpr, name: str, value: str | None ) -> XPathExpr: if value and is_non_whitespace(value): + arg = self.xpath_literal(" " + value + " ") xpath.add_condition( - "%s and contains(concat(' ', normalize-space(%s), ' '), %s)" - % (name, name, self.xpath_literal(" " + value + " ")) + f"{name} and contains(concat(' ', normalize-space({name}), ' '), {arg})" ) else: xpath.add_condition("0") return xpath def xpath_attrib_dashmatch( - self, xpath: XPathExpr, name: str, value: Optional[str] + self, xpath: XPathExpr, name: str, value: str | None ) -> XPathExpr: assert value is not None + arg = self.xpath_literal(value) + arg_dash = self.xpath_literal(value + "-") # Weird, but true... xpath.add_condition( - "%s and (%s = %s or starts-with(%s, %s))" - % ( - name, - name, - self.xpath_literal(value), - name, - self.xpath_literal(value + "-"), - ) + f"{name} and ({name} = {arg} or starts-with({name}, {arg_dash}))" ) return xpath def xpath_attrib_prefixmatch( - self, xpath: XPathExpr, name: str, value: Optional[str] + self, xpath: XPathExpr, name: str, value: str | None ) -> XPathExpr: if value: xpath.add_condition( @@ -780,7 +781,7 @@ def xpath_attrib_prefixmatch( return xpath def xpath_attrib_suffixmatch( - self, xpath: XPathExpr, name: str, value: Optional[str] + self, xpath: XPathExpr, name: str, value: str | None ) -> XPathExpr: if value: # Oddly there is a starts-with in XPath 1.0, but not ends-with @@ -793,7 +794,7 @@ def xpath_attrib_suffixmatch( return xpath def xpath_attrib_substringmatch( - self, xpath: XPathExpr, name: str, value: Optional[str] + self, xpath: XPathExpr, name: str, value: str | None ) -> XPathExpr: if value: # Attribute selectors are case sensitive @@ -831,7 +832,7 @@ def __init__(self, xhtml: bool = False) -> None: self.lower_case_element_names = True self.lower_case_attribute_names = True - def xpath_checked_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore + def xpath_checked_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[override] # FIXME: is this really all the elements? return xpath.add_condition( "(@selected and name(.) = 'option') or " @@ -848,16 +849,16 @@ def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr ) value = function.arguments[0].value assert value + arg = self.xpath_literal(value.lower() + "-") return xpath.add_condition( "ancestor-or-self::*[@lang][1][starts-with(concat(" # XPath 1.0 has no lower-case function... - "translate(@%s, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', " + f"translate(@{self.lang_attribute}, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', " "'abcdefghijklmnopqrstuvwxyz'), " - "'-'), %s)]" - % (self.lang_attribute, self.xpath_literal(value.lower() + "-")) + f"'-'), {arg})]" ) - def xpath_link_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore + def xpath_link_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[override] return xpath.add_condition( "@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')" ) @@ -865,7 +866,7 @@ def xpath_link_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore # Links are never visited, the implementation for :visited is the same # as in GenericTranslator - def xpath_disabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore + def xpath_disabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[override] # http://www.w3.org/TR/html5/section-index.html#attributes-1 return xpath.add_condition( """ @@ -895,7 +896,7 @@ def xpath_disabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore # FIXME: in the second half, add "and is not a descendant of that # fieldset element's first legend element child, if any." - def xpath_enabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore + def xpath_enabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[override] # http://www.w3.org/TR/html5/section-index.html#attributes-1 return xpath.add_condition( """ diff --git a/docs/conf.py b/docs/conf.py index aa5ae22..ceeb2d2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- # # cssselect documentation build configuration file, created by # sphinx-quickstart on Tue Mar 27 14:20:34 2012. @@ -12,9 +11,8 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import os import re -import sys +from pathlib import Path # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -51,10 +49,7 @@ # built documents. # # The full version, including alpha/beta/rc tags. -with open( - os.path.join(os.path.dirname(__file__), "..", "cssselect", "__init__.py") -) as init_file: - init_py = init_file.read() +init_py = (Path(__file__).parent.parent / "cssselect" / "__init__.py").read_text() release = re.search('VERSION = "([^"]+)"', init_py).group(1) # The short X.Y version. version = release.rstrip("dev") @@ -258,5 +253,5 @@ nitpicky = True nitpick_ignore = [ # explicitly not a part of the public API - ("py:class", "cssselect.parser.Token"), + ("py:class", "Token"), ] diff --git a/pylintrc b/pylintrc deleted file mode 100644 index 5a4647b..0000000 --- a/pylintrc +++ /dev/null @@ -1,33 +0,0 @@ -[MASTER] -persistent=no - -[MESSAGES CONTROL] -disable=assignment-from-no-return, - c-extension-no-member, - consider-using-f-string, - consider-using-in, - fixme, - inconsistent-return-statements, - invalid-name, - missing-class-docstring, - missing-function-docstring, - missing-module-docstring, - multiple-imports, - no-else-return, - no-member, - raise-missing-from, - redefined-builtin, - redefined-outer-name, - too-few-public-methods, - too-many-arguments, - too-many-branches, - too-many-function-args, - too-many-lines, - too-many-positional-arguments, - too-many-public-methods, - too-many-statements, - undefined-variable, - unidiomatic-typecheck, - unspecified-encoding, - unused-argument, - unused-import, diff --git a/pyproject.toml b/pyproject.toml index 261fe3e..5ddbeb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,179 @@ -[tool.isort] -profile = "black" -multi_line_output = 3 +[tool.bumpversion] +current_version = "1.2.0" +commit = true +tag = true + +[[tool.bumpversion.files]] +filename = "cssselect/__init__.py" + +[tool.coverage.run] +branch = true +source = ["cssselect"] + +[tool.coverage.report] +exclude_also = [ + "def __repr__", + "if sys.version_info", + "if __name__ == '__main__':", + "if TYPE_CHECKING:", +] [tool.mypy] check_untyped_defs = true ignore_missing_imports = true no_warn_no_return = true -[tool.black] -target-version = ["py38", "py39", "py310", "py311", "py312"] \ No newline at end of file +[tool.pylint.MASTER] +persistent = "no" +extension-pkg-allow-list = ["lxml"] + +[tool.pylint."MESSAGES CONTROL"] +enable = [ + "useless-suppression", +] +disable = [ + "consider-using-f-string", + "fixme", + "invalid-name", + "line-too-long", + "missing-class-docstring", + "missing-function-docstring", + "missing-module-docstring", + "no-member", + "not-callable", + "redefined-builtin", + "redefined-outer-name", + "too-few-public-methods", + "too-many-arguments", + "too-many-branches", + "too-many-function-args", + "too-many-lines", + "too-many-locals", + "too-many-positional-arguments", + "too-many-public-methods", + "too-many-statements", + "unused-argument", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff.lint] +extend-select = [ + # flake8-bugbear + "B", + # flake8-comprehensions + "C4", + # pydocstyle + "D", + # flake8-future-annotations + "FA", + # flynt + "FLY", + # refurb + "FURB", + # isort + "I", + # flake8-implicit-str-concat + "ISC", + # flake8-logging + "LOG", + # Perflint + "PERF", + # pygrep-hooks + "PGH", + # flake8-pie + "PIE", + # pylint + "PL", + # flake8-use-pathlib + "PTH", + # flake8-pyi + "PYI", + # flake8-quotes + "Q", + # flake8-return + "RET", + # flake8-raise + "RSE", + # Ruff-specific rules + "RUF", + # flake8-bandit + "S", + # flake8-simplify + "SIM", + # flake8-slots + "SLOT", + # flake8-debugger + "T10", + # flake8-type-checking + "TC", + # pyupgrade + "UP", + # pycodestyle warnings + "W", + # flake8-2020 + "YTT", +] +ignore = [ + # Missing docstring in public module + "D100", + # Missing docstring in public class + "D101", + # Missing docstring in public method + "D102", + # Missing docstring in public function + "D103", + # Missing docstring in public package + "D104", + # Missing docstring in magic method + "D105", + # Missing docstring in public nested class + "D106", + # Missing docstring in __init__ + "D107", + # One-line docstring should fit on one line with quotes + "D200", + # No blank lines allowed after function docstring + "D202", + # 1 blank line required between summary line and description + "D205", + # Multi-line docstring closing quotes should be on a separate line + "D209", + # First line should end with a period + "D400", + # First line should be in imperative mood; try rephrasing + "D401", + # First line should not be the function's "signature" + "D402", + # First word of the first line should be properly capitalized + "D403", + # Too many return statements + "PLR0911", + # Too many branches + "PLR0912", + # Too many arguments in function definition + "PLR0913", + # Too many statements + "PLR0915", + # Magic value used in comparison + "PLR2004", + # String contains ambiguous {}. + "RUF001", + # Docstring contains ambiguous {}. + "RUF002", + # Comment contains ambiguous {}. + "RUF003", + # Mutable class attributes should be annotated with `typing.ClassVar` + "RUF012", + # Use of `assert` detected + "S101", + # Using lxml to parse untrusted data is known to be vulnerable to XML attacks + "S320", + + # TODO: Use format specifiers instead of percent format + "UP031", +] + +[tool.ruff.lint.pydocstyle] +convention = "pep257" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b8c93b1..0000000 --- a/setup.cfg +++ /dev/null @@ -1,13 +0,0 @@ -[build_sphinx] -source-dir = docs -build-dir = docs/_build -#all_files = 1 - -[upload_sphinx] # Sphinx-PyPI-upload -upload-dir = docs/_build/html - -[tool:pytest] -testpaths = tests - -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py index 4c5d49d..cb870dd 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,11 @@ -# -*- coding: utf-8 -*- - -import os.path import re +from pathlib import Path from setuptools import setup -ROOT = os.path.dirname(__file__) -with open(os.path.join(ROOT, "README.rst")) as readme_file: - README = readme_file.read() -with open(os.path.join(ROOT, "cssselect", "__init__.py")) as init_file: - INIT_PY = init_file.read() +ROOT = Path(__file__).parent +README = (ROOT / "README.rst").read_text(encoding="utf-8") +INIT_PY = (ROOT / "cssselect" / "__init__.py").read_text(encoding="utf-8") VERSION = re.search('VERSION = "([^"]+)"', INIT_PY).group(1) diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 32c1683..0a95f92 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -1,26 +1,27 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ - Tests for cssselect - =================== +Tests for cssselect +=================== - These tests can be run either by py.test or by the standard library's - unittest. They use plain ``assert`` statements and do little reporting - themselves in case of failure. +These tests can be run either by py.test or by the standard library's +unittest. They use plain ``assert`` statements and do little reporting +themselves in case of failure. - Use py.test to get fancy error reporting and assert introspection. +Use py.test to get fancy error reporting and assert introspection. - :copyright: (c) 2007-2012 Ian Bicking and contributors. - See AUTHORS for more details. - :license: BSD, see LICENSE for more details. +:copyright: (c) 2007-2012 Ian Bicking and contributors. +See AUTHORS for more details. +:license: BSD, see LICENSE for more details. """ +from __future__ import annotations + import sys import typing import unittest -from typing import List, Optional, Sequence, Tuple +from typing import TYPE_CHECKING from lxml import etree, html @@ -41,6 +42,9 @@ ) from cssselect.xpath import XPathExpr +if TYPE_CHECKING: + from collections.abc import Sequence + class TestCssselect(unittest.TestCase): def test_tokenizer(self) -> None: @@ -70,16 +74,13 @@ def test_tokenizer(self) -> None: ] def test_parser(self) -> None: - def repr_parse(css: str) -> List[str]: + def repr_parse(css: str) -> list[str]: selectors = parse(css) for selector in selectors: assert selector.pseudo_element is None - return [ - repr(selector.parsed_tree).replace("(u'", "('") - for selector in selectors - ] + return [repr(selector.parsed_tree) for selector in selectors] - def parse_many(first: str, *others: str) -> List[str]: + def parse_many(first: str, *others: str) -> list[str]: result = repr_parse(first) for other in others: assert repr_parse(other) == result @@ -185,18 +186,18 @@ def parse_many(first: str, *others: str) -> List[str]: ] def test_pseudo_elements(self) -> None: - def parse_pseudo(css: str) -> List[Tuple[str, Optional[str]]]: - result: List[Tuple[str, Optional[str]]] = [] + def parse_pseudo(css: str) -> list[tuple[str, str | None]]: + result: list[tuple[str, str | None]] = [] for selector in parse(css): pseudo = selector.pseudo_element pseudo = str(pseudo) if pseudo else pseudo # No Symbol here - assert pseudo is None or type(pseudo) is str - selector_as_str = repr(selector.parsed_tree).replace("(u'", "('") + assert pseudo is None or isinstance(pseudo, str) + selector_as_str = repr(selector.parsed_tree) result.append((selector_as_str, pseudo)) return result - def parse_one(css: str) -> Tuple[str, Optional[str]]: + def parse_one(css: str) -> tuple[str, str | None]: result = parse_pseudo(css) assert len(result) == 1 return result[0] @@ -280,7 +281,7 @@ def test_pseudo_repr(css: str) -> str: assert test_pseudo_repr(":scope") == "Pseudo[Element[*]:scope]" def test_specificity(self) -> None: - def specificity(css: str) -> Tuple[int, int, int]: + def specificity(css: str) -> tuple[int, int, int]: selectors = parse(css) assert len(selectors) == 1 return selectors[0].specificity() @@ -326,7 +327,7 @@ def specificity(css: str) -> Tuple[int, int, int]: ) def test_css_export(self) -> None: - def css2css(css: str, res: Optional[str] = None) -> None: + def css2css(css: str, res: str | None = None) -> None: selectors = parse(css) assert len(selectors) == 1 assert selectors[0].canonical() == (res or css) @@ -365,12 +366,11 @@ def css2css(css: str, res: Optional[str] = None) -> None: css2css("foo > *") def test_parse_errors(self) -> None: - def get_error(css: str) -> Optional[str]: + def get_error(css: str) -> str | None: try: parse(css) except SelectorSyntaxError: - # Py2, Py3, ... - return str(sys.exc_info()[1]).replace("(u'", "('") + return str(sys.exc_info()[1]) return None assert get_error("attributes(href)/html/body/a") == ( @@ -452,7 +452,7 @@ def xpath(css: str) -> str: assert xpath("e[foo|bar]") == "e[@foo:bar]" assert xpath('e[foo="bar"]') == "e[@foo = 'bar']" assert xpath('e[foo~="bar"]') == ( - "e[@foo and contains(" "concat(' ', normalize-space(@foo), ' '), ' bar ')]" + "e[@foo and contains(concat(' ', normalize-space(@foo), ' '), ' bar ')]" ) assert xpath('e[foo^="bar"]') == ("e[@foo and starts-with(@foo, 'bar')]") assert xpath('e[foo$="bar"]') == ( @@ -460,7 +460,7 @@ def xpath(css: str) -> str: ) assert xpath('e[foo*="bar"]') == ("e[@foo and contains(@foo, 'bar')]") assert xpath('e[hreflang|="en"]') == ( - "e[@hreflang and (" "@hreflang = 'en' or starts-with(@hreflang, 'en-'))]" + "e[@hreflang and (@hreflang = 'en' or starts-with(@hreflang, 'en-'))]" ) # --- nth-* and nth-last-* ------------------------------------- @@ -719,19 +719,17 @@ def xpath(css: str) -> str: ) assert xpath(":scope") == "descendant-or-self::*[1]" assert xpath(":first-or-second[href]") == ( - "descendant-or-self::*[(@id = 'first' or @id = 'second') " "and (@href)]" + "descendant-or-self::*[(@id = 'first' or @id = 'second') and (@href)]" ) assert str(XPathExpr("", "", condition="@href")) == "[@href]" document = etree.fromstring(OPERATOR_PRECEDENCE_IDS) - sort_key = dict( - (el, count) for count, el in enumerate(document.iter()) - ).__getitem__ + sort_key = {el: count for count, el in enumerate(document.iter())}.__getitem__ - def operator_id(selector: str) -> List[str]: + def operator_id(selector: str) -> list[str]: xpath = CustomTranslator().css_to_xpath(selector) - items = typing.cast(List["etree._Element"], document.xpath(xpath)) + items = typing.cast(list["etree._Element"], document.xpath(xpath)) items.sort(key=sort_key) return [element.get("id", "nil") for element in items] @@ -740,7 +738,7 @@ def operator_id(selector: str) -> List[str]: assert operator_id("[href]:first-or-second") == ["second"] def test_series(self) -> None: - def series(css: str) -> Optional[Tuple[int, int]]: + def series(css: str) -> tuple[int, int] | None: (selector,) = parse(":nth-child(%s)" % css) args = typing.cast(FunctionalPseudoElement, selector.parsed_tree).arguments try: @@ -769,14 +767,12 @@ def series(css: str) -> Optional[Tuple[int, int]]: def test_lang(self) -> None: document = etree.fromstring(XMLLANG_IDS) - sort_key = dict( - (el, count) for count, el in enumerate(document.iter()) - ).__getitem__ + sort_key = {el: count for count, el in enumerate(document.iter())}.__getitem__ css_to_xpath = GenericTranslator().css_to_xpath - def langid(selector: str) -> List[str]: + def langid(selector: str) -> list[str]: xpath = css_to_xpath(selector) - items = typing.cast(List["etree._Element"], document.xpath(xpath)) + items = typing.cast(list["etree._Element"], document.xpath(xpath)) items.sort(key=sort_key) return [element.get("id", "nil") for element in items] @@ -799,7 +795,7 @@ def langid(selector: str) -> List[str]: def test_argument_types(self) -> None: class CustomTranslator(GenericTranslator): def __init__(self) -> None: - self.argument_types: List[str] = [] + self.argument_types: list[str] = [] def xpath_pseudo_element( self, xpath: XPathExpr, pseudo_element: PseudoElement @@ -809,12 +805,12 @@ def xpath_pseudo_element( ).argument_types() return xpath - def argument_types(css: str) -> List[str]: + def argument_types(css: str) -> list[str]: translator = CustomTranslator() translator.css_to_xpath(css) return translator.argument_types - mappings: List[Tuple[str, List[str]]] = [ + mappings: list[tuple[str, list[str]]] = [ ("", []), ("ident", ["IDENT"]), ('"string"', ["STRING"]), @@ -826,23 +822,21 @@ def argument_types(css: str) -> List[str]: def test_select(self) -> None: document = etree.fromstring(HTML_IDS) - sort_key = dict( - (el, count) for count, el in enumerate(document.iter()) - ).__getitem__ + sort_key = {el: count for count, el in enumerate(document.iter())}.__getitem__ css_to_xpath = GenericTranslator().css_to_xpath html_css_to_xpath = HTMLTranslator().css_to_xpath - def select_ids(selector: str, html_only: bool) -> List[str]: + def select_ids(selector: str, html_only: bool) -> list[str]: xpath = css_to_xpath(selector) - items = typing.cast(List["etree._Element"], document.xpath(xpath)) + items = typing.cast(list["etree._Element"], document.xpath(xpath)) if html_only: assert items == [] xpath = html_css_to_xpath(selector) - items = typing.cast(List["etree._Element"], document.xpath(xpath)) + items = typing.cast(list["etree._Element"], document.xpath(xpath)) items.sort(key=sort_key) return [element.get("id", "nil") for element in items] - def pcss(main: str, *selectors: str, **kwargs: bool) -> List[str]: + def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: html_only = kwargs.pop("html_only", False) result = select_ids(main, html_only) for selector in selectors: @@ -1072,14 +1066,14 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> List[str]: def test_select_shakespeare(self) -> None: document = html.document_fromstring(HTML_SHAKESPEARE) - body = typing.cast(List["etree._Element"], document.xpath("//body"))[0] + body = typing.cast(list["etree._Element"], document.xpath("//body"))[0] css_to_xpath = GenericTranslator().css_to_xpath basestring_ = (str, bytes) def count(selector: str) -> int: xpath = css_to_xpath(selector) - results = typing.cast(List["etree._Element"], body.xpath(xpath)) + results = typing.cast(list["etree._Element"], body.xpath(xpath)) assert not isinstance(results, basestring_) found = set() for item in results: @@ -1527,7 +1521,7 @@ def count(selector: str) -> int: -""" # noqa: W191,E101 +""" if __name__ == "__main__": diff --git a/tox.ini b/tox.ini index 616d223..3585406 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ commands = [testenv:pylint] deps = {[testenv]deps} - pylint==3.3.1 + pylint==3.3.4 commands = pylint {posargs: cssselect setup.py tests docs} @@ -30,8 +30,8 @@ commands = [testenv:typing] deps = {[testenv]deps} - mypy==1.11.2 - types-lxml==2024.9.16 + mypy==1.14.1 + types-lxml==2024.12.13 commands = mypy --strict {posargs: cssselect tests} From c8f18fa7ba39bb9d751ba42dae33d50c3fb2f7aa Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 4 Feb 2025 13:08:27 +0400 Subject: [PATCH 11/34] Upgrade string formatting. (#151) --- cssselect/parser.py | 169 +++++++++++++++------------------------- cssselect/xpath.py | 96 ++++++++++------------- pyproject.toml | 3 - tests/test_cssselect.py | 17 ++-- 4 files changed, 113 insertions(+), 172 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index d16751f..13ae959 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -105,20 +105,20 @@ def __repr__(self) -> str: if isinstance(self.pseudo_element, FunctionalPseudoElement): pseudo_element = repr(self.pseudo_element) elif self.pseudo_element: - pseudo_element = "::%s" % self.pseudo_element + pseudo_element = f"::{self.pseudo_element}" else: pseudo_element = "" - return "%s[%r%s]" % (self.__class__.__name__, self.parsed_tree, pseudo_element) + return f"{self.__class__.__name__}[{self.parsed_tree!r}{pseudo_element}]" def canonical(self) -> str: """Return a CSS representation for this selector (a string)""" if isinstance(self.pseudo_element, FunctionalPseudoElement): - pseudo_element = "::%s" % self.pseudo_element.canonical() + pseudo_element = f"::{self.pseudo_element.canonical()}" elif self.pseudo_element: - pseudo_element = "::%s" % self.pseudo_element + pseudo_element = f"::{self.pseudo_element}" else: pseudo_element = "" - res = "%s%s" % (self.parsed_tree.canonical(), pseudo_element) + res = f"{self.parsed_tree.canonical()}{pseudo_element}" if len(res) > 1: res = res.lstrip("*") return res @@ -145,10 +145,10 @@ def __init__(self, selector: Tree, class_name: str) -> None: self.class_name = class_name def __repr__(self) -> str: - return "%s[%r.%s]" % (self.__class__.__name__, self.selector, self.class_name) + return f"{self.__class__.__name__}[{self.selector!r}.{self.class_name}]" def canonical(self) -> str: - return "%s.%s" % (self.selector.canonical(), self.class_name) + return f"{self.selector.canonical()}.{self.class_name}" def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() @@ -179,18 +179,15 @@ def __init__(self, name: str, arguments: Sequence[Token]): self.arguments = arguments def __repr__(self) -> str: - return "%s[::%s(%r)]" % ( - self.__class__.__name__, - self.name, - [token.value for token in self.arguments], - ) + token_values = [token.value for token in self.arguments] + return f"{self.__class__.__name__}[::{self.name}({token_values!r})]" def argument_types(self) -> list[str]: return [token.type for token in self.arguments] def canonical(self) -> str: args = "".join(token.css() for token in self.arguments) - return "%s(%s)" % (self.name, args) + return f"{self.name}({args})" class Function: @@ -204,19 +201,15 @@ def __init__(self, selector: Tree, name: str, arguments: Sequence[Token]) -> Non self.arguments = arguments def __repr__(self) -> str: - return "%s[%r:%s(%r)]" % ( - self.__class__.__name__, - self.selector, - self.name, - [token.value for token in self.arguments], - ) + token_values = [token.value for token in self.arguments] + return f"{self.__class__.__name__}[{self.selector!r}:{self.name}({token_values!r})]" def argument_types(self) -> list[str]: return [token.type for token in self.arguments] def canonical(self) -> str: args = "".join(token.css() for token in self.arguments) - return "%s:%s(%s)" % (self.selector.canonical(), self.name, args) + return f"{self.selector.canonical()}:{self.name}({args})" def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() @@ -234,10 +227,10 @@ def __init__(self, selector: Tree, ident: str) -> None: self.ident = ascii_lower(ident) def __repr__(self) -> str: - return "%s[%r:%s]" % (self.__class__.__name__, self.selector, self.ident) + return f"{self.__class__.__name__}[{self.selector!r}:{self.ident}]" def canonical(self) -> str: - return "%s:%s" % (self.selector.canonical(), self.ident) + return f"{self.selector.canonical()}:{self.ident}" def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() @@ -255,17 +248,13 @@ def __init__(self, selector: Tree, subselector: Tree) -> None: self.subselector = subselector def __repr__(self) -> str: - return "%s[%r:not(%r)]" % ( - self.__class__.__name__, - self.selector, - self.subselector, - ) + return f"{self.__class__.__name__}[{self.selector!r}:not({self.subselector!r})]" def canonical(self) -> str: subsel = self.subselector.canonical() if len(subsel) > 1: subsel = subsel.lstrip("*") - return "%s:not(%s)" % (self.selector.canonical(), subsel) + return f"{self.selector.canonical()}:not({subsel})" def specificity(self) -> tuple[int, int, int]: a1, b1, c1 = self.selector.specificity() @@ -284,11 +273,7 @@ def __init__(self, selector: Tree, combinator: Token, subselector: Selector): self.subselector = subselector def __repr__(self) -> str: - return "%s[%r:has(%r)]" % ( - self.__class__.__name__, - self.selector, - self.subselector, - ) + return f"{self.__class__.__name__}[{self.selector!r}:has({self.subselector!r})]" def canonical(self) -> str: try: @@ -297,7 +282,7 @@ def canonical(self) -> str: subsel = self.subselector.canonical() if len(subsel) > 1: subsel = subsel.lstrip("*") - return "%s:has(%s)" % (self.selector.canonical(), subsel) + return f"{self.selector.canonical()}:has({subsel})" def specificity(self) -> tuple[int, int, int]: a1, b1, c1 = self.selector.specificity() @@ -318,21 +303,16 @@ def __init__(self, selector: Tree, selector_list: Iterable[Tree]): self.selector_list = selector_list def __repr__(self) -> str: - return "%s[%r:is(%s)]" % ( - self.__class__.__name__, - self.selector, - ", ".join(map(repr, self.selector_list)), - ) + args_str = ", ".join(repr(s) for s in self.selector_list) + return f"{self.__class__.__name__}[{self.selector!r}:is({args_str})]" def canonical(self) -> str: selector_arguments = [] for s in self.selector_list: selarg = s.canonical() selector_arguments.append(selarg.lstrip("*")) - return "%s:is(%s)" % ( - self.selector.canonical(), - ", ".join(map(str, selector_arguments)), - ) + args_str = ", ".join(str(s) for s in selector_arguments) + return f"{self.selector.canonical()}:is({args_str})" def specificity(self) -> tuple[int, int, int]: return max(x.specificity() for x in self.selector_list) @@ -349,21 +329,16 @@ def __init__(self, selector: Tree, selector_list: list[Tree]): self.selector_list = selector_list def __repr__(self) -> str: - return "%s[%r:where(%s)]" % ( - self.__class__.__name__, - self.selector, - ", ".join(map(repr, self.selector_list)), - ) + args_str = ", ".join(repr(s) for s in self.selector_list) + return f"{self.__class__.__name__}[{self.selector!r}:where({args_str})]" def canonical(self) -> str: selector_arguments = [] for s in self.selector_list: selarg = s.canonical() selector_arguments.append(selarg.lstrip("*")) - return "%s:where(%s)" % ( - self.selector.canonical(), - ", ".join(map(str, selector_arguments)), - ) + args_str = ", ".join(str(s) for s in selector_arguments) + return f"{self.selector.canonical()}:where({args_str})" def specificity(self) -> tuple[int, int, int]: return 0, 0, 0 @@ -409,38 +384,22 @@ def __init__( self.value = value def __repr__(self) -> str: - if self.namespace: - attrib = "%s|%s" % (self.namespace, self.attrib) - else: - attrib = self.attrib + attrib = f"{self.namespace}|{self.attrib}" if self.namespace else self.attrib if self.operator == "exists": - return "%s[%r[%s]]" % (self.__class__.__name__, self.selector, attrib) + return f"{self.__class__.__name__}[{self.selector!r}[{attrib}]]" assert self.value is not None - return "%s[%r[%s %s %r]]" % ( - self.__class__.__name__, - self.selector, - attrib, - self.operator, - self.value.value, - ) + return f"{self.__class__.__name__}[{self.selector!r}[{attrib} {self.operator} {self.value.value!r}]]" def canonical(self) -> str: - if self.namespace: - attrib = "%s|%s" % (self.namespace, self.attrib) - else: - attrib = self.attrib + attrib = f"{self.namespace}|{self.attrib}" if self.namespace else self.attrib if self.operator == "exists": op = attrib else: assert self.value is not None - op = "%s%s%s" % ( - attrib, - self.operator, - self.value.css(), - ) + op = f"{attrib}{self.operator}{self.value.css()}" - return "%s[%s]" % (self.selector.canonical(), op) + return f"{self.selector.canonical()}[{op}]" def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() @@ -463,12 +422,12 @@ def __init__( self.element = element def __repr__(self) -> str: - return "%s[%s]" % (self.__class__.__name__, self.canonical()) + return f"{self.__class__.__name__}[{self.canonical()}]" def canonical(self) -> str: element = self.element or "*" if self.namespace: - element = "%s|%s" % (self.namespace, element) + element = f"{self.namespace}|{element}" return element def specificity(self) -> tuple[int, int, int]: @@ -487,10 +446,10 @@ def __init__(self, selector: Tree, id: str) -> None: self.id = id def __repr__(self) -> str: - return "%s[%r#%s]" % (self.__class__.__name__, self.selector, self.id) + return f"{self.__class__.__name__}[{self.selector!r}#{self.id}]" def canonical(self) -> str: - return "%s#%s" % (self.selector.canonical(), self.id) + return f"{self.selector.canonical()}#{self.id}" def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() @@ -507,18 +466,15 @@ def __init__(self, selector: Tree, combinator: str, subselector: Tree) -> None: def __repr__(self) -> str: comb = "" if self.combinator == " " else self.combinator - return "%s[%r %s %r]" % ( - self.__class__.__name__, - self.selector, - comb, - self.subselector, + return ( + f"{self.__class__.__name__}[{self.selector!r} {comb} {self.subselector!r}]" ) def canonical(self) -> str: subsel = self.subselector.canonical() if len(subsel) > 1: subsel = subsel.lstrip("*") - return "%s %s %s" % (self.selector.canonical(), self.combinator, subsel) + return f"{self.selector.canonical()} {self.combinator} {subsel}" def specificity(self) -> tuple[int, int, int]: a1, b1, c1 = self.selector.specificity() @@ -602,7 +558,7 @@ def parse_selector(stream: TokenStream) -> tuple[Tree, PseudoElement | None]: break if pseudo_element: raise SelectorSyntaxError( - "Got pseudo-element ::%s not at the end of a selector" % pseudo_element + f"Got pseudo-element ::{pseudo_element} not at the end of a selector" ) if peek.is_delim("+", ">", "~"): # A combinator @@ -649,7 +605,7 @@ def parse_simple_selector( break if pseudo_element: raise SelectorSyntaxError( - "Got pseudo-element ::%s not at the end of a selector" % pseudo_element + f"Got pseudo-element ::{pseudo_element} not at the end of a selector" ) if peek.type == "HASH": result = Hash(result, cast(str, stream.next().value)) @@ -707,11 +663,10 @@ def parse_simple_selector( next = stream.next() if argument_pseudo_element: raise SelectorSyntaxError( - "Got pseudo-element ::%s inside :not() at %s" - % (argument_pseudo_element, next.pos) + f"Got pseudo-element ::{argument_pseudo_element} inside :not() at {next.pos}" ) if next != ("DELIM", ")"): - raise SelectorSyntaxError("Expected ')', got %s" % (next,)) + raise SelectorSyntaxError(f"Expected ')', got {next}") result = Negation(result, argument) elif ident.lower() == "has": combinator, arguments = parse_relative_selector(stream) @@ -726,9 +681,9 @@ def parse_simple_selector( else: result = Function(result, ident, parse_arguments(stream)) else: - raise SelectorSyntaxError("Expected selector, got %s" % (peek,)) + raise SelectorSyntaxError(f"Expected selector, got {peek}") if len(stream.used) == selector_start: - raise SelectorSyntaxError("Expected selector, got %s" % (stream.peek(),)) + raise SelectorSyntaxError(f"Expected selector, got {stream.peek()}") return result, pseudo_element @@ -745,7 +700,7 @@ def parse_arguments(stream: TokenStream) -> list[Token]: elif next == ("DELIM", ")"): return arguments else: - raise SelectorSyntaxError("Expected an argument, got %s" % (next,)) + raise SelectorSyntaxError(f"Expected an argument, got {next}") def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: @@ -770,7 +725,7 @@ def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: result = parse(subselector) return combinator, result[0] else: - raise SelectorSyntaxError("Expected an argument, got %s" % (next,)) + raise SelectorSyntaxError(f"Expected an argument, got {next}") next = stream.next() @@ -780,7 +735,7 @@ def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: result, pseudo_element = parse_simple_selector(stream, True) if pseudo_element: raise SelectorSyntaxError( - "Got pseudo-element ::%s inside function" % (pseudo_element,) + f"Got pseudo-element ::{pseudo_element} inside function" ) stream.skip_whitespace() next = stream.next() @@ -792,7 +747,7 @@ def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: arguments.append(result) break else: - raise SelectorSyntaxError("Expected an argument, got %s" % (next,)) + raise SelectorSyntaxError(f"Expected an argument, got {next}") return arguments @@ -800,7 +755,7 @@ def parse_attrib(selector: Tree, stream: TokenStream) -> Attrib: stream.skip_whitespace() attrib = stream.next_ident_or_star() if attrib is None and stream.peek() != ("DELIM", "|"): - raise SelectorSyntaxError("Expected '|', got %s" % (stream.peek(),)) + raise SelectorSyntaxError(f"Expected '|', got {stream.peek()}") namespace: str | None op: str | None if stream.peek() == ("DELIM", "|"): @@ -828,15 +783,15 @@ def parse_attrib(selector: Tree, stream: TokenStream) -> Attrib: op = cast(str, next.value) + "=" stream.next() else: - raise SelectorSyntaxError("Operator expected, got %s" % (next,)) + raise SelectorSyntaxError(f"Operator expected, got {next}") stream.skip_whitespace() value = stream.next() if value.type not in ("IDENT", "STRING"): - raise SelectorSyntaxError("Expected string or ident, got %s" % (value,)) + raise SelectorSyntaxError(f"Expected string or ident, got {value}") stream.skip_whitespace() next = stream.next() if next != ("DELIM", "]"): - raise SelectorSyntaxError("Expected ']', got %s" % (next,)) + raise SelectorSyntaxError(f"Expected ']', got {next}") return Attrib(selector, namespace, cast(str, attrib), op, value) @@ -894,7 +849,7 @@ def __new__(cls, type_: str, value: str | None, pos: int) -> Self: return obj def __repr__(self) -> str: - return "<%s '%s' at %i>" % (self.type, self.value, self.pos) + return f"<{self.type} '{self.value}' at {self.pos}>" def is_delim(self, *values: str) -> bool: return self.type == "DELIM" and self.value in values @@ -920,7 +875,7 @@ def __new__(cls, pos: int) -> Self: return Token.__new__(cls, "EOF", None, pos) def __repr__(self) -> str: - return "<%s at %i>" % (self.type, self.pos) + return f"<{self.type} at {self.pos}>" #### Tokenizer @@ -931,8 +886,8 @@ class TokenMacros: escape = unicode_escape + r"|\\[^\n\r\f0-9a-f]" string_escape = r"\\(?:\n|\r\n|\r|\f)|" + escape nonascii = r"[^\0-\177]" - nmchar = "[_a-z0-9-]|%s|%s" % (escape, nonascii) - nmstart = "[_a-z]|%s|%s" % (escape, nonascii) + nmchar = f"[_a-z0-9-]|{escape}|{nonascii}" + nmstart = f"[_a-z]|{escape}|{nonascii}" class MatchFunc(Protocol): @@ -1009,9 +964,9 @@ def tokenize(s: str) -> Iterator[Token]: assert match, "Should have found at least an empty match" end_pos = match.end() if end_pos == len_s: - raise SelectorSyntaxError("Unclosed string at %s" % pos) + raise SelectorSyntaxError(f"Unclosed string at {pos}") if s[end_pos] != quote: - raise SelectorSyntaxError("Invalid string at %s" % pos) + raise SelectorSyntaxError(f"Invalid string at {pos}") value = _sub_simple_escape( _replace_simple, _sub_unicode_escape( @@ -1074,7 +1029,7 @@ def peek(self) -> Token: def next_ident(self) -> str: next = self.next() if next.type != "IDENT": - raise SelectorSyntaxError("Expected ident, got %s" % (next,)) + raise SelectorSyntaxError(f"Expected ident, got {next}") return cast(str, next.value) def next_ident_or_star(self) -> str | None: @@ -1083,7 +1038,7 @@ def next_ident_or_star(self) -> str | None: return next.value if next == ("DELIM", "*"): return None - raise SelectorSyntaxError("Expected ident or '*', got %s" % (next,)) + raise SelectorSyntaxError(f"Expected ident or '*', got {next}") def skip_whitespace(self) -> None: peek = self.peek() diff --git a/cssselect/xpath.py b/cssselect/xpath.py index e9d1065..4018bcf 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -64,15 +64,15 @@ def __init__( def __str__(self) -> str: path = str(self.path) + str(self.element) if self.condition: - path += "[%s]" % self.condition + path += f"[{self.condition}]" return path def __repr__(self) -> str: - return "%s[%s]" % (self.__class__.__name__, self) + return f"{self.__class__.__name__}[{self}]" def add_condition(self, condition: str, conjuction: str = "and") -> Self: if self.condition: - self.condition = "(%s) %s (%s)" % (self.condition, conjuction, condition) + self.condition = f"({self.condition}) {conjuction} ({condition})" else: self.condition = condition return self @@ -81,9 +81,7 @@ def add_name_test(self) -> None: if self.element == "*": # We weren't doing a test anyway return - self.add_condition( - "name() = %s" % GenericTranslator.xpath_literal(self.element) - ) + self.add_condition(f"name() = {GenericTranslator.xpath_literal(self.element)}") self.element = "*" def add_star_prefix(self) -> None: @@ -253,7 +251,7 @@ def selector_to_xpath( """ tree = getattr(selector, "parsed_tree", None) if not tree: - raise TypeError("Expected a parsed selector, got %r" % (selector,)) + raise TypeError(f"Expected a parsed selector, got {selector!r}") xpath = self.xpath(tree) assert isinstance(xpath, self.xpathexpr_cls) # help debug a missing 'return' if translate_pseudo_elements and selector.pseudo_element: @@ -275,9 +273,9 @@ def xpath_pseudo_element( def xpath_literal(s: str) -> str: s = str(s) if "'" not in s: - s = "'%s'" % s + s = f"'{s}'" elif '"' not in s: - s = '"%s"' % s + s = f'"{s}"' else: parts_quoted = [ f'"{part}"' if "'" in part else f"'{part}'" @@ -292,10 +290,10 @@ def xpath(self, parsed_selector: Tree) -> XPathExpr: type_name = type(parsed_selector).__name__ method = cast( Optional[Callable[[Tree], XPathExpr]], - getattr(self, "xpath_%s" % type_name.lower(), None), + getattr(self, f"xpath_{type_name.lower()}", None), ) if method is None: - raise ExpressionError("%s is not supported." % type_name) + raise ExpressionError(f"{type_name} is not supported.") return method(parsed_selector) # Dispatched by parsed object type @@ -305,7 +303,7 @@ def xpath_combinedselector(self, combined: CombinedSelector) -> XPathExpr: combinator = self.combinator_mapping[combined.combinator] method = cast( Callable[[XPathExpr, XPathExpr], XPathExpr], - getattr(self, "xpath_%s_combinator" % combinator), + getattr(self, f"xpath_{combinator}_combinator"), ) return method(self.xpath(combined.selector), self.xpath(combined.subselector)) @@ -314,7 +312,7 @@ def xpath_negation(self, negation: Negation) -> XPathExpr: sub_xpath = self.xpath(negation.subselector) sub_xpath.add_name_test() if sub_xpath.condition: - return xpath.add_condition("not(%s)" % sub_xpath.condition) + return xpath.add_condition(f"not({sub_xpath.condition})") return xpath.add_condition("0") def xpath_relation(self, relation: Relation) -> XPathExpr: @@ -326,8 +324,7 @@ def xpath_relation(self, relation: Relation) -> XPathExpr: Callable[[XPathExpr, XPathExpr], XPathExpr], getattr( self, - "xpath_relation_%s_combinator" - % self.combinator_mapping[cast(str, combinator.value)], + f"xpath_relation_{self.combinator_mapping[cast(str, combinator.value)]}_combinator", ), ) return method(xpath, right) @@ -352,24 +349,24 @@ def xpath_specificityadjustment(self, matching: SpecificityAdjustment) -> XPathE def xpath_function(self, function: Function) -> XPathExpr: """Translate a functional pseudo-class.""" - method_name = "xpath_%s_function" % function.name.replace("-", "_") + method_name = "xpath_{}_function".format(function.name.replace("-", "_")) method = cast( Optional[Callable[[XPathExpr, Function], XPathExpr]], getattr(self, method_name, None), ) if not method: - raise ExpressionError("The pseudo-class :%s() is unknown" % function.name) + raise ExpressionError(f"The pseudo-class :{function.name}() is unknown") return method(self.xpath(function.selector), function) def xpath_pseudo(self, pseudo: Pseudo) -> XPathExpr: """Translate a pseudo-class.""" - method_name = "xpath_%s_pseudo" % pseudo.ident.replace("-", "_") + method_name = "xpath_{}_pseudo".format(pseudo.ident.replace("-", "_")) method = cast( Optional[Callable[[XPathExpr], XPathExpr]], getattr(self, method_name, None) ) if not method: # TODO: better error message for pseudo-elements? - raise ExpressionError("The pseudo-class :%s is unknown" % pseudo.ident) + raise ExpressionError(f"The pseudo-class :{pseudo.ident} is unknown") return method(self.xpath(pseudo.selector)) def xpath_attrib(self, selector: Attrib) -> XPathExpr: @@ -377,7 +374,7 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr: operator = self.attribute_operator_mapping[selector.operator] method = cast( Callable[[XPathExpr, str, Optional[str]], XPathExpr], - getattr(self, "xpath_attrib_%s" % operator), + getattr(self, f"xpath_attrib_{operator}"), ) if self.lower_case_attribute_names: name = selector.attrib.lower() @@ -385,12 +382,12 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr: name = selector.attrib safe = is_safe_name(name) if selector.namespace: - name = "%s:%s" % (selector.namespace, name) + name = f"{selector.namespace}:{name}" safe = safe and is_safe_name(selector.namespace) if safe: attrib = "@" + name else: - attrib = "attribute::*[name() = %s]" % self.xpath_literal(name) + attrib = f"attribute::*[name() = {self.xpath_literal(name)}]" if selector.value is None: value = None elif self.lower_case_attribute_values: @@ -423,7 +420,7 @@ def xpath_element(self, selector: Element) -> XPathExpr: if selector.namespace: # Namespace prefixes are case-sensitive. # http://www.w3.org/TR/css3-namespace/#prefixes - element = "%s:%s" % (selector.namespace, element) + element = f"{selector.namespace}:{element}" safe = safe and bool(is_safe_name(selector.namespace)) xpath = self.xpathexpr_cls(element=element) if not safe: @@ -496,7 +493,7 @@ def xpath_nth_child_function( try: a, b = parse_series(function.arguments) except ValueError as ex: - raise ExpressionError("Invalid series: '%r'" % function.arguments) from ex + raise ExpressionError(f"Invalid series: '{function.arguments!r}'") from ex # From https://www.w3.org/TR/css3-selectors/#structural-pseudos: # @@ -558,20 +555,20 @@ def xpath_nth_child_function( # `add_name_test` boolean is inverted and somewhat counter-intuitive: # # nth_of_type() calls nth_child(add_name_test=False) - nodetest = "*" if add_name_test else "%s" % xpath.element + nodetest = "*" if add_name_test else f"{xpath.element}" # count siblings before or after the element if not last: - siblings_count = "count(preceding-sibling::%s)" % nodetest + siblings_count = f"count(preceding-sibling::{nodetest})" else: - siblings_count = "count(following-sibling::%s)" % nodetest + siblings_count = f"count(following-sibling::{nodetest})" # special case of fixed position: nth-*(0n+b) # if a == 0: # ~~~~~~~~~~ # count(***-sibling::***) = b-1 if a == 0: - return xpath.add_condition("%s = %s" % (siblings_count, b_min_1)) + return xpath.add_condition(f"{siblings_count} = {b_min_1}") expressions = [] @@ -580,12 +577,12 @@ def xpath_nth_child_function( # so if a>0, and (b-1)<=0, an "n" exists to satisfy this, # therefore, the predicate is only interesting if (b-1)>0 if b_min_1 > 0: - expressions.append("%s >= %s" % (siblings_count, b_min_1)) + expressions.append(f"{siblings_count} >= {b_min_1}") else: # if a<0, and (b-1)<0, no "n" satisfies this, # this is tested above as an early exist condition # otherwise, - expressions.append("%s <= %s" % (siblings_count, b_min_1)) + expressions.append(f"{siblings_count} <= {b_min_1}") # operations modulo 1 or -1 are simpler, one only needs to verify: # @@ -608,10 +605,9 @@ def xpath_nth_child_function( b_neg = (-b_min_1) % abs(a) if b_neg != 0: - b_neg_as_str = "+%s" % b_neg - left = "(%s %s)" % (left, b_neg_as_str) + left = f"({left} +{b_neg})" - expressions.append("%s mod %s = 0" % (left, a)) + expressions.append(f"{left} mod {a} = 0") template = "(%s)" if len(expressions) > 1 else "%s" xpath.add_condition( @@ -647,20 +643,18 @@ def xpath_contains_function( # http://www.w3.org/TR/2001/CR-css3-selectors-20011113/#content-selectors if function.argument_types() not in (["STRING"], ["IDENT"]): raise ExpressionError( - "Expected a single string or ident for :contains(), got %r" - % function.arguments + f"Expected a single string or ident for :contains(), got {function.arguments!r}" ) value = cast(str, function.arguments[0].value) - return xpath.add_condition("contains(., %s)" % self.xpath_literal(value)) + return xpath.add_condition(f"contains(., {self.xpath_literal(value)})") def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: if function.argument_types() not in (["STRING"], ["IDENT"]): raise ExpressionError( - "Expected a single string or ident for :lang(), got %r" - % function.arguments + f"Expected a single string or ident for :lang(), got {function.arguments!r}" ) value = cast(str, function.arguments[0].value) - return xpath.add_condition("lang(%s)" % (self.xpath_literal(value))) + return xpath.add_condition(f"lang({self.xpath_literal(value)})") # Pseudo: dispatch by pseudo-class name @@ -684,12 +678,12 @@ def xpath_last_child_pseudo(self, xpath: XPathExpr) -> XPathExpr: def xpath_first_of_type_pseudo(self, xpath: XPathExpr) -> XPathExpr: if xpath.element == "*": raise ExpressionError("*:first-of-type is not implemented") - return xpath.add_condition("count(preceding-sibling::%s) = 0" % xpath.element) + 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") - return xpath.add_condition("count(following-sibling::%s) = 0" % xpath.element) + 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") @@ -697,7 +691,7 @@ def xpath_only_child_pseudo(self, xpath: XPathExpr) -> XPathExpr: 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("count(parent::*/child::%s) = 1" % xpath.element) + return xpath.add_condition(f"count(parent::*/child::{xpath.element}) = 1") def xpath_empty_pseudo(self, xpath: XPathExpr) -> XPathExpr: return xpath.add_condition("not(*) and not(string-length())") @@ -729,7 +723,7 @@ def xpath_attrib_equals( self, xpath: XPathExpr, name: str, value: str | None ) -> XPathExpr: assert value is not None - xpath.add_condition("%s = %s" % (name, self.xpath_literal(value))) + xpath.add_condition(f"{name} = {self.xpath_literal(value)}") return xpath def xpath_attrib_different( @@ -738,11 +732,9 @@ def xpath_attrib_different( assert value is not None # FIXME: this seems like a weird hack... if value: - xpath.add_condition( - "not(%s) or %s != %s" % (name, name, self.xpath_literal(value)) - ) + xpath.add_condition(f"not({name}) or {name} != {self.xpath_literal(value)}") else: - xpath.add_condition("%s != %s" % (name, self.xpath_literal(value))) + xpath.add_condition(f"{name} != {self.xpath_literal(value)}") return xpath def xpath_attrib_includes( @@ -774,7 +766,7 @@ def xpath_attrib_prefixmatch( ) -> XPathExpr: if value: xpath.add_condition( - "%s and starts-with(%s, %s)" % (name, name, self.xpath_literal(value)) + f"{name} and starts-with({name}, {self.xpath_literal(value)})" ) else: xpath.add_condition("0") @@ -786,8 +778,7 @@ def xpath_attrib_suffixmatch( if value: # Oddly there is a starts-with in XPath 1.0, but not ends-with xpath.add_condition( - "%s and substring(%s, string-length(%s)-%s) = %s" - % (name, name, name, len(value) - 1, self.xpath_literal(value)) + f"{name} and substring({name}, string-length({name})-{len(value) - 1}) = {self.xpath_literal(value)}" ) else: xpath.add_condition("0") @@ -799,7 +790,7 @@ def xpath_attrib_substringmatch( if value: # Attribute selectors are case sensitive xpath.add_condition( - "%s and contains(%s, %s)" % (name, name, self.xpath_literal(value)) + f"{name} and contains({name}, {self.xpath_literal(value)})" ) else: xpath.add_condition("0") @@ -844,8 +835,7 @@ def xpath_checked_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[o def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: if function.argument_types() not in (["STRING"], ["IDENT"]): raise ExpressionError( - "Expected a single string or ident for :lang(), got %r" - % function.arguments + f"Expected a single string or ident for :lang(), got {function.arguments!r}" ) value = function.arguments[0].value assert value diff --git a/pyproject.toml b/pyproject.toml index 5ddbeb6..7e43445 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,9 +170,6 @@ ignore = [ "S101", # Using lxml to parse untrusted data is known to be vulnerable to XML attacks "S320", - - # TODO: Use format specifiers instead of percent format - "UP031", ] [tool.ruff.lint.pydocstyle] diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 0a95f92..e2e3ba5 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -258,7 +258,7 @@ def test_pseudo_repr(css: str) -> str: # Special cases for CSS 2.1 pseudo-elements are ignored by default for pseudo in ("after", "before", "first-line", "first-letter"): - (selector,) = parse("e:%s" % pseudo) + (selector,) = parse(f"e:{pseudo}") assert selector.pseudo_element == pseudo assert GenericTranslator().selector_to_xpath(selector, prefix="") == "e" @@ -631,24 +631,23 @@ def xpath_pseudo_element( self, xpath: XPathExpr, pseudo_element: PseudoElement ) -> XPathExpr: if isinstance(pseudo_element, FunctionalPseudoElement): - method_name = "xpath_%s_functional_pseudo_element" % ( + method_name = "xpath_{}_functional_pseudo_element".format( pseudo_element.name.replace("-", "_") ) method = getattr(self, method_name, None) if not method: raise ExpressionError( - "The functional pseudo-element ::%s() is unknown" - % pseudo_element.name + f"The functional pseudo-element ::{pseudo_element.name}() is unknown" ) xpath = method(xpath, pseudo_element.arguments) else: - method_name = "xpath_%s_simple_pseudo_element" % ( + method_name = "xpath_{}_simple_pseudo_element".format( pseudo_element.replace("-", "_") ) method = getattr(self, method_name, None) if not method: raise ExpressionError( - "The pseudo-element ::%s is unknown" % pseudo_element + f"The pseudo-element ::{pseudo_element} is unknown" ) xpath = method(xpath) return xpath @@ -660,7 +659,7 @@ def xpath_nb_attr_function( ) -> XPathExpr: assert function.arguments[0].value nb_attributes = int(function.arguments[0].value) - return xpath.add_condition("count(@*)=%d" % nb_attributes) + return xpath.add_condition(f"count(@*)={nb_attributes}") # pseudo-class: # elements that have 5 attributes @@ -674,7 +673,7 @@ def xpath_attr_functional_pseudo_element( ) -> XPathExpr: attribute_name = arguments[0].value other = XPathExpr( - "@%s" % attribute_name, + f"@{attribute_name}", "", ) return xpath.join("/", other) @@ -739,7 +738,7 @@ def operator_id(selector: str) -> list[str]: def test_series(self) -> None: def series(css: str) -> tuple[int, int] | None: - (selector,) = parse(":nth-child(%s)" % css) + (selector,) = parse(f":nth-child({css})") args = typing.cast(FunctionalPseudoElement, selector.parsed_tree).arguments try: return parse_series(args) From f6ef188e19387a1df53f9870b46ae0743c40d178 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 10 Mar 2025 11:06:39 +0400 Subject: [PATCH 12/34] Update tool versions, setup trusted publishing. (#152) --- .github/workflows/checks.yml | 2 +- .github/workflows/publish.yml | 31 ++++++++++++++++--------------- .pre-commit-config.yaml | 2 +- .readthedocs.yml | 4 ++-- MANIFEST.in | 2 +- docs/requirements.txt | 4 ++-- pyproject.toml | 5 ----- tox.ini | 10 +++++----- 8 files changed, 28 insertions(+), 32 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cf0e689..666aaba 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,7 +11,7 @@ jobs: - python-version: 3.13 env: TOXENV: pylint - - python-version: 3.12 # Keep in sync with .readthedocs.yml + - python-version: 3.13 # Keep in sync with .readthedocs.yml env: TOXENV: docs - python-version: 3.13 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 36f80b5..ad470a8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,31 +1,32 @@ name: Publish -on: [push] +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' jobs: publish: runs-on: ubuntu-latest - if: startsWith(github.event.ref, 'refs/tags/') + + environment: + name: pypi + url: https://pypi.org/p/cssselect + + permissions: + id-token: write steps: - uses: actions/checkout@v4 - - name: Set up Python 3.13 + - name: Set up Python uses: actions/setup-python@v5 with: python-version: 3.13 - - name: Check Tag - id: check-release-tag + - name: Build run: | - if [[ ${{ github.event.ref }} =~ ^refs/tags/v[0-9]+[.][0-9]+[.][0-9]+(rc[0-9]+|[.]dev[0-9]+)?$ ]]; then - echo ::set-output name=release_tag::true - fi + python -m pip install --upgrade build + python -m build - name: Publish to PyPI - if: steps.check-release-tag.outputs.release_tag == 'true' - run: | - pip install --upgrade setuptools wheel twine - python setup.py sdist bdist_wheel - export TWINE_USERNAME=__token__ - export TWINE_PASSWORD=${{ secrets.PYPI_TOKEN }} - twine upload dist/* + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b1829a6..66f262f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.4 + rev: v0.9.10 hooks: - id: ruff args: [ --fix ] diff --git a/.readthedocs.yml b/.readthedocs.yml index 7d13c50..46f5f14 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,11 +4,11 @@ sphinx: configuration: docs/conf.py fail_on_warning: true build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: # For available versions, see: # https://docs.readthedocs.io/en/stable/config-file/v2.html#build-tools-python - python: "3.12" # Keep in sync with .github/workflows/checks.yml + python: "3.13" # Keep in sync with .github/workflows/checks.yml python: install: - requirements: docs/requirements.txt diff --git a/MANIFEST.in b/MANIFEST.in index 5561683..18022a7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include AUTHORS CHANGES LICENSE README.rst tox.ini .coveragerc cssselect/py.typed +include AUTHORS CHANGES LICENSE README.rst tox.ini cssselect/py.typed recursive-include docs * recursive-include tests * prune docs/_build diff --git a/docs/requirements.txt b/docs/requirements.txt index d5476d8..21cb2eb 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -sphinx==7.2.6 -sphinx-rtd-theme==2.0.0 +sphinx==8.2.3 +sphinx-rtd-theme==3.0.2 diff --git a/pyproject.toml b/pyproject.toml index 7e43445..fa1a140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,6 @@ exclude_also = [ "if TYPE_CHECKING:", ] -[tool.mypy] -check_untyped_defs = true -ignore_missing_imports = true -no_warn_no_return = true - [tool.pylint.MASTER] persistent = "no" extension-pkg-allow-list = ["lxml"] diff --git a/tox.ini b/tox.ini index 3585406..7746739 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ commands = [testenv:pylint] deps = {[testenv]deps} - pylint==3.3.4 + pylint==3.3.5 commands = pylint {posargs: cssselect setup.py tests docs} @@ -30,8 +30,8 @@ commands = [testenv:typing] deps = {[testenv]deps} - mypy==1.14.1 - types-lxml==2024.12.13 + mypy==1.15.0 + types-lxml==2025.3.4 commands = mypy --strict {posargs: cssselect tests} @@ -43,8 +43,8 @@ skip_install = true [testenv:twinecheck] basepython = python3 deps = - twine==5.1.1 - build==1.2.2 + twine==6.1.0 + build==1.2.2.post1 commands = python -m build --sdist twine check dist/* From b6ccd9cbb4db30a79d49eb2247a8a5276af922ce Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 10 Mar 2025 13:24:00 +0500 Subject: [PATCH 13/34] Add release notes for 1.3.0. --- CHANGES | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGES b/CHANGES index dc38826..0bf3129 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,21 @@ Changelog ========= +Version 1.3.0 +------------- + +Released on 2025-MM-DD. + +* Dropped support for Python 3.7-3.8, added support for Python 3.12-3.13 and + PyPy 3.10. + +* Removed ``_unicode_safe_getattr()``, deprecated in 1.2.0. + +* Added ``pre-commit`` and formatted the code with ``ruff``. + +* Many CI additions and improvements. + + Version 1.2.0 ------------- From e99c506b8e8be0753250622633df8a68dc76268e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 10 Mar 2025 14:20:12 +0500 Subject: [PATCH 14/34] =?UTF-8?q?Bump=20version:=201.2.0=20=E2=86=92=201.3?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGES | 2 +- cssselect/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 0bf3129..a6d5f41 100644 --- a/CHANGES +++ b/CHANGES @@ -4,7 +4,7 @@ Changelog Version 1.3.0 ------------- -Released on 2025-MM-DD. +Released on 2025-03-10. * Dropped support for Python 3.7-3.8, added support for Python 3.12-3.13 and PyPy 3.10. diff --git a/cssselect/__init__.py b/cssselect/__init__.py index c53b539..67acaaa 100644 --- a/cssselect/__init__.py +++ b/cssselect/__init__.py @@ -32,5 +32,5 @@ "parse", ) -VERSION = "1.2.0" +VERSION = "1.3.0" __version__ = VERSION diff --git a/pyproject.toml b/pyproject.toml index fa1a140..43a0672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "1.2.0" +current_version = "1.3.0" commit = true tag = true From 0c4bce2ed0967792c60db1b026def45203e4aae0 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 24 Mar 2025 13:20:58 +0500 Subject: [PATCH 15/34] Add non-Linux CI jobs. (#154) --- .github/workflows/tests-macos.yml | 27 +++++++++++++++++++ .../workflows/{tests.yml => tests-ubuntu.yml} | 2 +- .github/workflows/tests-windows.yml | 27 +++++++++++++++++++ .pre-commit-config.yaml | 2 +- cssselect/parser.py | 18 ++++++------- cssselect/xpath.py | 26 +++++++++--------- tests/test_cssselect.py | 18 +++++++------ 7 files changed, 89 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/tests-macos.yml rename .github/workflows/{tests.yml => tests-ubuntu.yml} (97%) create mode 100644 .github/workflows/tests-windows.yml diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml new file mode 100644 index 0000000..851a40e --- /dev/null +++ b/.github/workflows/tests-macos.yml @@ -0,0 +1,27 @@ +name: macOS +on: [push, pull_request] + +jobs: + tests: + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Run tests + run: | + pip install -U pip + pip install -U tox + tox -e py + + - name: Upload coverage report + uses: codecov/codecov-action@v5 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests-ubuntu.yml similarity index 97% rename from .github/workflows/tests.yml rename to .github/workflows/tests-ubuntu.yml index 427c4ad..75a06bd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -1,4 +1,4 @@ -name: Tests +name: Ubuntu on: [push, pull_request] jobs: diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml new file mode 100644 index 0000000..e56da2e --- /dev/null +++ b/.github/workflows/tests-windows.yml @@ -0,0 +1,27 @@ +name: Windows +on: [push, pull_request] + +jobs: + tests: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Run tests + run: | + pip install -U pip + pip install -U tox + tox -e py + + - name: Upload coverage report + uses: codecov/codecov-action@v5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66f262f..3c92c4d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.10 + rev: v0.11.2 hooks: - id: ruff args: [ --fix ] diff --git a/cssselect/parser.py b/cssselect/parser.py index 13ae959..e970a1b 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -562,7 +562,7 @@ def parse_selector(stream: TokenStream) -> tuple[Tree, PseudoElement | None]: ) if peek.is_delim("+", ">", "~"): # A combinator - combinator = cast(str, stream.next().value) + combinator = cast("str", stream.next().value) stream.skip_whitespace() else: # By exclusion, the last parse_simple_selector() ended @@ -608,7 +608,7 @@ def parse_simple_selector( f"Got pseudo-element ::{pseudo_element} not at the end of a selector" ) if peek.type == "HASH": - result = Hash(result, cast(str, stream.next().value)) + result = Hash(result, cast("str", stream.next().value)) elif peek == ("DELIM", "."): stream.next() result = Class(result, stream.next_ident()) @@ -720,7 +720,7 @@ def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: ("DELIM", "."), ("DELIM", "*"), ]: - subselector += cast(str, next.value) + subselector += cast("str", next.value) elif next == ("DELIM", ")"): result = parse(subselector) return combinator, result[0] @@ -774,13 +774,13 @@ def parse_attrib(selector: Tree, stream: TokenStream) -> Attrib: stream.skip_whitespace() next = stream.next() if next == ("DELIM", "]"): - return Attrib(selector, namespace, cast(str, attrib), "exists", None) + return Attrib(selector, namespace, cast("str", attrib), "exists", None) if next == ("DELIM", "="): op = "=" elif next.is_delim("^", "$", "*", "~", "|", "!") and ( stream.peek() == ("DELIM", "=") ): - op = cast(str, next.value) + "=" + op = cast("str", next.value) + "=" stream.next() else: raise SelectorSyntaxError(f"Operator expected, got {next}") @@ -792,7 +792,7 @@ def parse_attrib(selector: Tree, stream: TokenStream) -> Attrib: next = stream.next() if next != ("DELIM", "]"): raise SelectorSyntaxError(f"Expected ']', got {next}") - return Attrib(selector, namespace, cast(str, attrib), op, value) + return Attrib(selector, namespace, cast("str", attrib), op, value) def parse_series(tokens: Iterable[Token]) -> tuple[int, int]: @@ -806,7 +806,7 @@ def parse_series(tokens: Iterable[Token]) -> tuple[int, int]: for token in tokens: if token.type == "STRING": raise ValueError("String tokens not allowed in series.") - s = "".join(cast(str, token.value) for token in tokens).strip() + s = "".join(cast("str", token.value) for token in tokens).strip() if s == "odd": return 2, 1 if s == "even": @@ -867,7 +867,7 @@ def value(self) -> str | None: def css(self) -> str: if self.type == "STRING": return repr(self.value) - return cast(str, self.value) + return cast("str", self.value) class EOFToken(Token): @@ -1030,7 +1030,7 @@ def next_ident(self) -> str: next = self.next() if next.type != "IDENT": raise SelectorSyntaxError(f"Expected ident, got {next}") - return cast(str, next.value) + return cast("str", next.value) def next_ident_or_star(self) -> str | None: next = self.next() diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 4018bcf..bc47dea 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -14,8 +14,7 @@ from __future__ import annotations import re -from collections.abc import Callable -from typing import TYPE_CHECKING, Optional, cast +from typing import TYPE_CHECKING, cast from cssselect.parser import ( Attrib, @@ -38,6 +37,8 @@ ) if TYPE_CHECKING: + from collections.abc import Callable + # typing.Self requires Python 3.11 from typing_extensions import Self @@ -289,7 +290,7 @@ def xpath(self, parsed_selector: Tree) -> XPathExpr: """Translate any parsed selector object.""" type_name = type(parsed_selector).__name__ method = cast( - Optional[Callable[[Tree], XPathExpr]], + "Callable[[Tree], XPathExpr] | None", getattr(self, f"xpath_{type_name.lower()}", None), ) if method is None: @@ -302,7 +303,7 @@ def xpath_combinedselector(self, combined: CombinedSelector) -> XPathExpr: """Translate a combined selector.""" combinator = self.combinator_mapping[combined.combinator] method = cast( - Callable[[XPathExpr, XPathExpr], XPathExpr], + "Callable[[XPathExpr, XPathExpr], XPathExpr]", getattr(self, f"xpath_{combinator}_combinator"), ) return method(self.xpath(combined.selector), self.xpath(combined.subselector)) @@ -321,10 +322,10 @@ def xpath_relation(self, relation: Relation) -> XPathExpr: subselector = relation.subselector right = self.xpath(subselector.parsed_tree) method = cast( - Callable[[XPathExpr, XPathExpr], XPathExpr], + "Callable[[XPathExpr, XPathExpr], XPathExpr]", getattr( self, - f"xpath_relation_{self.combinator_mapping[cast(str, combinator.value)]}_combinator", + f"xpath_relation_{self.combinator_mapping[cast('str', combinator.value)]}_combinator", ), ) return method(xpath, right) @@ -351,7 +352,7 @@ def xpath_function(self, function: Function) -> XPathExpr: """Translate a functional pseudo-class.""" method_name = "xpath_{}_function".format(function.name.replace("-", "_")) method = cast( - Optional[Callable[[XPathExpr, Function], XPathExpr]], + "Callable[[XPathExpr, Function], XPathExpr] | None", getattr(self, method_name, None), ) if not method: @@ -362,7 +363,8 @@ def xpath_pseudo(self, pseudo: Pseudo) -> XPathExpr: """Translate a pseudo-class.""" method_name = "xpath_{}_pseudo".format(pseudo.ident.replace("-", "_")) method = cast( - Optional[Callable[[XPathExpr], XPathExpr]], getattr(self, method_name, None) + "Callable[[XPathExpr], XPathExpr] | None", + getattr(self, method_name, None), ) if not method: # TODO: better error message for pseudo-elements? @@ -373,7 +375,7 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr: """Translate an attribute selector.""" operator = self.attribute_operator_mapping[selector.operator] method = cast( - Callable[[XPathExpr, str, Optional[str]], XPathExpr], + "Callable[[XPathExpr, str, str | None], XPathExpr]", getattr(self, f"xpath_attrib_{operator}"), ) if self.lower_case_attribute_names: @@ -391,7 +393,7 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr: if selector.value is None: value = None elif self.lower_case_attribute_values: - value = cast(str, selector.value.value).lower() + value = cast("str", selector.value.value).lower() else: value = selector.value.value return method(self.xpath(selector.selector), attrib, value) @@ -645,7 +647,7 @@ def xpath_contains_function( raise ExpressionError( f"Expected a single string or ident for :contains(), got {function.arguments!r}" ) - value = cast(str, function.arguments[0].value) + value = cast("str", function.arguments[0].value) return xpath.add_condition(f"contains(., {self.xpath_literal(value)})") def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr: @@ -653,7 +655,7 @@ def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr raise ExpressionError( f"Expected a single string or ident for :lang(), got {function.arguments!r}" ) - value = cast(str, function.arguments[0].value) + value = cast("str", function.arguments[0].value) return xpath.add_condition(f"lang({self.xpath_literal(value)})") # Pseudo: dispatch by pseudo-class name diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index e2e3ba5..2b89b6f 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -728,7 +728,7 @@ def xpath(css: str) -> str: def operator_id(selector: str) -> list[str]: xpath = CustomTranslator().css_to_xpath(selector) - items = typing.cast(list["etree._Element"], document.xpath(xpath)) + items = typing.cast("list[etree._Element]", document.xpath(xpath)) items.sort(key=sort_key) return [element.get("id", "nil") for element in items] @@ -739,7 +739,9 @@ def operator_id(selector: str) -> list[str]: def test_series(self) -> None: def series(css: str) -> tuple[int, int] | None: (selector,) = parse(f":nth-child({css})") - args = typing.cast(FunctionalPseudoElement, selector.parsed_tree).arguments + args = typing.cast( + "FunctionalPseudoElement", selector.parsed_tree + ).arguments try: return parse_series(args) except ValueError: @@ -771,7 +773,7 @@ def test_lang(self) -> None: def langid(selector: str) -> list[str]: xpath = css_to_xpath(selector) - items = typing.cast(list["etree._Element"], document.xpath(xpath)) + items = typing.cast("list[etree._Element]", document.xpath(xpath)) items.sort(key=sort_key) return [element.get("id", "nil") for element in items] @@ -800,7 +802,7 @@ def xpath_pseudo_element( self, xpath: XPathExpr, pseudo_element: PseudoElement ) -> XPathExpr: self.argument_types += typing.cast( - FunctionalPseudoElement, pseudo_element + "FunctionalPseudoElement", pseudo_element ).argument_types() return xpath @@ -827,11 +829,11 @@ def test_select(self) -> None: def select_ids(selector: str, html_only: bool) -> list[str]: xpath = css_to_xpath(selector) - items = typing.cast(list["etree._Element"], document.xpath(xpath)) + items = typing.cast("list[etree._Element]", document.xpath(xpath)) if html_only: assert items == [] xpath = html_css_to_xpath(selector) - items = typing.cast(list["etree._Element"], document.xpath(xpath)) + items = typing.cast("list[etree._Element]", document.xpath(xpath)) items.sort(key=sort_key) return [element.get("id", "nil") for element in items] @@ -1065,14 +1067,14 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: def test_select_shakespeare(self) -> None: document = html.document_fromstring(HTML_SHAKESPEARE) - body = typing.cast(list["etree._Element"], document.xpath("//body"))[0] + body = typing.cast("list[etree._Element]", document.xpath("//body"))[0] css_to_xpath = GenericTranslator().css_to_xpath basestring_ = (str, bytes) def count(selector: str) -> int: xpath = css_to_xpath(selector) - results = typing.cast(list["etree._Element"], body.xpath(xpath)) + results = typing.cast("list[etree._Element]", body.xpath(xpath)) assert not isinstance(results, basestring_) found = set() for item in results: From aae4d793810be3a9dd20fb112af8a5933c8570ed Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 24 Mar 2025 14:03:46 +0500 Subject: [PATCH 16/34] Add support for PyPy3.11. --- .github/workflows/tests-ubuntu.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 75a06bd..5500bf5 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10", "pypy3.11"] steps: - uses: actions/checkout@v4 From 93e1277ff43b0d7f3792722dca93aa29db752888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 24 Mar 2025 11:07:06 +0100 Subject: [PATCH 17/34] Fix CI badge --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index d62b320..c055295 100644 --- a/README.rst +++ b/README.rst @@ -11,8 +11,8 @@ cssselect: CSS Selectors for Python :target: https://pypi.python.org/pypi/cssselect :alt: Supported Python Versions -.. image:: https://github.com/scrapy/cssselect/actions/workflows/tests.yml/badge.svg - :target: https://github.com/scrapy/cssselect/actions/workflows/tests.yml +.. image:: https://github.com/scrapy/cssselect/actions/workflows/tests-ubuntu.yml/badge.svg + :target: https://github.com/scrapy/cssselect/actions/workflows/tests-ubuntu.yml :alt: Tests .. image:: https://img.shields.io/codecov/c/github/scrapy/cssselect/master.svg From b478ce96deddd07bd7bd5311d49fd0b5bbf3f54f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 24 Apr 2025 09:23:55 +0200 Subject: [PATCH 18/34] Modernize packaging (#157) --- MANIFEST.in | 4 ---- pyproject.toml | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 43 ------------------------------------------- tox.ini | 2 +- 4 files changed, 50 insertions(+), 48 deletions(-) delete mode 100644 MANIFEST.in delete mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 18022a7..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include AUTHORS CHANGES LICENSE README.rst tox.ini cssselect/py.typed -recursive-include docs * -recursive-include tests * -prune docs/_build diff --git a/pyproject.toml b/pyproject.toml index 43a0672..782657e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,52 @@ +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling>=1.27.0"] + +[project] +name = "cssselect" +license = "BSD-3-Clause" +license-files = ["LICENSE", "AUTHORS"] +description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" +readme = "README.rst" +authors = [{ name = "Ian Bicking", email = "ianb@colorstudy.com" }] +maintainers = [{ name = "Paul Tremberth", email = "paul.tremberth@gmail.com" }] +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[project.urls] +"Homepage" = "https://github.com/scrapy/cssselect" + +[tool.hatch.version] +path = "cssselect/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/cssselect", + "/docs", + "/tests", + "/CHANGES", + "/README.rst", + "/tox.ini", +] +exclude = [ + "/docs/_build", +] + +[tool.hatch.build.targets.wheel] +packages = ["cssselect"] + [tool.bumpversion] current_version = "1.3.0" commit = true diff --git a/setup.py b/setup.py deleted file mode 100644 index cb870dd..0000000 --- a/setup.py +++ /dev/null @@ -1,43 +0,0 @@ -import re -from pathlib import Path - -from setuptools import setup - -ROOT = Path(__file__).parent -README = (ROOT / "README.rst").read_text(encoding="utf-8") -INIT_PY = (ROOT / "cssselect" / "__init__.py").read_text(encoding="utf-8") -VERSION = re.search('VERSION = "([^"]+)"', INIT_PY).group(1) - - -setup( - name="cssselect", - version=VERSION, - author="Ian Bicking", - author_email="ianb@colorstudy.com", - maintainer="Paul Tremberth", - maintainer_email="paul.tremberth@gmail.com", - description="cssselect parses CSS3 Selectors and translates them to XPath 1.0", - long_description=README, - long_description_content_type="text/x-rst", - url="https://github.com/scrapy/cssselect", - license="BSD", - packages=["cssselect"], - package_data={ - "cssselect": ["py.typed"], - }, - include_package_data=True, - python_requires=">=3.9", - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - ], -) diff --git a/tox.ini b/tox.ini index 7746739..026741a 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ deps = {[testenv]deps} pylint==3.3.5 commands = - pylint {posargs: cssselect setup.py tests docs} + pylint {posargs: cssselect tests docs} [testenv:docs] changedir = docs From efcc78fa0ad0cb895094371f88ab9f864ae80b12 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 5 May 2025 23:19:33 +0500 Subject: [PATCH 19/34] Install -dev libs for lxml for PyPY CI jobs. --- .github/workflows/tests-ubuntu.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 5500bf5..013ca97 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -12,6 +12,12 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install system libraries + if: contains(matrix.python-version, 'pypy') + run: | + sudo apt-get update + sudo apt-get install libxml2-dev libxslt-dev + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: From da0329ce8d0094e17481d49d877d90423e8a1033 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 26 Jul 2025 21:12:17 +0500 Subject: [PATCH 20/34] Add Python 3.14 RC1 to CI. --- .github/workflows/tests-macos.yml | 2 +- .github/workflows/tests-ubuntu.yml | 2 +- .github/workflows/tests-windows.yml | 2 +- pyproject.toml | 1 + tox.ini | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 851a40e..7b1bcb7 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.1"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 75a06bd..95b0839 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.1", "pypy3.10"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index e56da2e..11e5917 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.1"] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index 782657e..0dc257c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] diff --git a/tox.ini b/tox.ini index 026741a..a0a9413 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ deps = commands = pytest --cov=cssselect \ --cov-report=term-missing --cov-report=html --cov-report=xml \ - --verbose {posargs: cssselect tests docs} + {posargs: cssselect tests docs} [testenv:pylint] deps = From 6c9d2191048e19fa7a2bb9346647d2096075e523 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 26 Jul 2025 21:22:11 +0500 Subject: [PATCH 21/34] Bump ruff. --- .pre-commit-config.yaml | 4 +- cssselect/parser.py | 98 ++++++++++++++++++++--------------------- docs/conf.py | 2 +- pyproject.toml | 15 ++++++- tests/test_cssselect.py | 51 ++++++++++++--------- 5 files changed, 96 insertions(+), 74 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3c92c4d..a8eebd9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.2 + rev: v0.12.5 hooks: - - id: ruff + - id: ruff-check args: [ --fix ] - id: ruff-format diff --git a/cssselect/parser.py b/cssselect/parser.py index e970a1b..5bca712 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -441,9 +441,9 @@ class Hash: Represents selector#id """ - def __init__(self, selector: Tree, id: str) -> None: + def __init__(self, selector: Tree, id_: str) -> None: self.selector = selector - self.id = id + self.id = id_ def __repr__(self) -> str: return f"{self.__class__.__name__}[{self.selector!r}#{self.id}]" @@ -660,13 +660,13 @@ def parse_simple_selector( argument, argument_pseudo_element = parse_simple_selector( stream, inside_negation=True ) - next = stream.next() + next_ = stream.next() if argument_pseudo_element: raise SelectorSyntaxError( - f"Got pseudo-element ::{argument_pseudo_element} inside :not() at {next.pos}" + f"Got pseudo-element ::{argument_pseudo_element} inside :not() at {next_.pos}" ) - if next != ("DELIM", ")"): - raise SelectorSyntaxError(f"Expected ')', got {next}") + if next_ != ("DELIM", ")"): + raise SelectorSyntaxError(f"Expected ')', got {next_}") result = Negation(result, argument) elif ident.lower() == "has": combinator, arguments = parse_relative_selector(stream) @@ -687,46 +687,46 @@ def parse_simple_selector( return result, pseudo_element -def parse_arguments(stream: TokenStream) -> list[Token]: +def parse_arguments(stream: TokenStream) -> list[Token]: # noqa: RET503 arguments: list[Token] = [] - while 1: # noqa: RET503 + while 1: stream.skip_whitespace() - next = stream.next() - if next.type in ("IDENT", "STRING", "NUMBER") or next in [ + next_ = stream.next() + if next_.type in ("IDENT", "STRING", "NUMBER") or next_ in [ ("DELIM", "+"), ("DELIM", "-"), ]: - arguments.append(next) - elif next == ("DELIM", ")"): + arguments.append(next_) + elif next_ == ("DELIM", ")"): return arguments else: - raise SelectorSyntaxError(f"Expected an argument, got {next}") + raise SelectorSyntaxError(f"Expected an argument, got {next_}") -def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: +def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: # noqa: RET503 stream.skip_whitespace() subselector = "" - next = stream.next() + next_ = stream.next() - if next in [("DELIM", "+"), ("DELIM", "-"), ("DELIM", ">"), ("DELIM", "~")]: - combinator = next + if next_ in [("DELIM", "+"), ("DELIM", "-"), ("DELIM", ">"), ("DELIM", "~")]: + combinator = next_ stream.skip_whitespace() - next = stream.next() + next_ = stream.next() else: combinator = Token("DELIM", " ", pos=0) - while 1: # noqa: RET503 - if next.type in ("IDENT", "STRING", "NUMBER") or next in [ + while 1: + if next_.type in ("IDENT", "STRING", "NUMBER") or next_ in [ ("DELIM", "."), ("DELIM", "*"), ]: - subselector += cast("str", next.value) - elif next == ("DELIM", ")"): + subselector += cast("str", next_.value) + elif next_ == ("DELIM", ")"): result = parse(subselector) return combinator, result[0] else: - raise SelectorSyntaxError(f"Expected an argument, got {next}") - next = stream.next() + raise SelectorSyntaxError(f"Expected an argument, got {next_}") + next_ = stream.next() def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: @@ -738,16 +738,16 @@ def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: f"Got pseudo-element ::{pseudo_element} inside function" ) stream.skip_whitespace() - next = stream.next() - if next in (("EOF", None), ("DELIM", ",")): + next_ = stream.next() + if next_ in (("EOF", None), ("DELIM", ",")): stream.next() stream.skip_whitespace() arguments.append(result) - elif next == ("DELIM", ")"): + elif next_ == ("DELIM", ")"): arguments.append(result) break else: - raise SelectorSyntaxError(f"Expected an argument, got {next}") + raise SelectorSyntaxError(f"Expected an argument, got {next_}") return arguments @@ -772,26 +772,26 @@ def parse_attrib(selector: Tree, stream: TokenStream) -> Attrib: namespace = op = None if op is None: stream.skip_whitespace() - next = stream.next() - if next == ("DELIM", "]"): + next_ = stream.next() + if next_ == ("DELIM", "]"): return Attrib(selector, namespace, cast("str", attrib), "exists", None) - if next == ("DELIM", "="): + if next_ == ("DELIM", "="): op = "=" - elif next.is_delim("^", "$", "*", "~", "|", "!") and ( + elif next_.is_delim("^", "$", "*", "~", "|", "!") and ( stream.peek() == ("DELIM", "=") ): - op = cast("str", next.value) + "=" + op = cast("str", next_.value) + "=" stream.next() else: - raise SelectorSyntaxError(f"Operator expected, got {next}") + raise SelectorSyntaxError(f"Operator expected, got {next_}") stream.skip_whitespace() value = stream.next() if value.type not in ("IDENT", "STRING"): raise SelectorSyntaxError(f"Expected string or ident, got {value}") stream.skip_whitespace() - next = stream.next() - if next != ("DELIM", "]"): - raise SelectorSyntaxError(f"Expected ']', got {next}") + next_ = stream.next() + if next_ != ("DELIM", "]"): + raise SelectorSyntaxError(f"Expected ']', got {next_}") return Attrib(selector, namespace, cast("str", attrib), op, value) @@ -1015,9 +1015,9 @@ def next(self) -> Token: assert self.peeked is not None self.used.append(self.peeked) return self.peeked - next = self.next_token() - self.used.append(next) - return next + next_ = self.next_token() + self.used.append(next_) + return next_ def peek(self) -> Token: if not self._peeking: @@ -1027,18 +1027,18 @@ def peek(self) -> Token: return self.peeked def next_ident(self) -> str: - next = self.next() - if next.type != "IDENT": - raise SelectorSyntaxError(f"Expected ident, got {next}") - return cast("str", next.value) + next_ = self.next() + if next_.type != "IDENT": + raise SelectorSyntaxError(f"Expected ident, got {next_}") + return cast("str", next_.value) def next_ident_or_star(self) -> str | None: - next = self.next() - if next.type == "IDENT": - return next.value - if next == ("DELIM", "*"): + next_ = self.next() + if next_.type == "IDENT": + return next_.value + if next_ == ("DELIM", "*"): return None - raise SelectorSyntaxError(f"Expected ident or '*', got {next}") + raise SelectorSyntaxError(f"Expected ident or '*', got {next_}") def skip_whitespace(self) -> None: peek = self.peek() diff --git a/docs/conf.py b/docs/conf.py index ceeb2d2..5713d17 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,7 +42,7 @@ # General information about the project. project = "cssselect" -copyright = "2012-2017, Simon Sapin, Scrapy developers" +project_copyright = "2012-2017, Simon Sapin, Scrapy developers" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/pyproject.toml b/pyproject.toml index 0dc257c..8506c66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,10 +105,16 @@ testpaths = ["tests"] [tool.ruff.lint] extend-select = [ + # flake8-builtins + "A", + # flake8-async + "ASYNC", # flake8-bugbear "B", # flake8-comprehensions "C4", + # flake8-commas + "COM", # pydocstyle "D", # flake8-future-annotations @@ -131,6 +137,8 @@ extend-select = [ "PIE", # pylint "PL", + # flake8-pytest-style + "PT", # flake8-use-pathlib "PTH", # flake8-pyi @@ -161,6 +169,8 @@ extend-select = [ "YTT", ] ignore = [ + # Trailing comma missing + "COM812", # Missing docstring in public module "D100", # Missing docstring in public class @@ -213,9 +223,10 @@ ignore = [ "RUF012", # Use of `assert` detected "S101", - # Using lxml to parse untrusted data is known to be vulnerable to XML attacks - "S320", ] +[tool.ruff.lint.isort] +split-on-trailing-comma = false + [tool.ruff.lint.pydocstyle] convention = "pep257" diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 2b89b6f..dc67bb7 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -23,6 +23,7 @@ import unittest from typing import TYPE_CHECKING +import pytest from lxml import etree, html from cssselect import ( @@ -268,12 +269,8 @@ def test_pseudo_repr(css: str) -> str: (selector,) = parse("e::foo") assert selector.pseudo_element == "foo" assert tr.selector_to_xpath(selector, prefix="") == "e" - self.assertRaises( - ExpressionError, - tr.selector_to_xpath, - selector, - translate_pseudo_elements=True, - ) + with pytest.raises(ExpressionError): + tr.selector_to_xpath(selector, translate_pseudo_elements=True) # Special test for the unicode symbols and ':scope' element if check # Errors if use repr() instead of __repr__() @@ -567,19 +564,32 @@ def xpath(css: str) -> str: assert xpath(r"[h\a0 ref]") == ("*[attribute::*[name() = 'h ref']]") # h\xa0ref assert xpath(r"[h\]ref]") == ("*[attribute::*[name() = 'h]ref']]") - self.assertRaises(ExpressionError, xpath, ":fİrst-child") - self.assertRaises(ExpressionError, xpath, ":first-of-type") - self.assertRaises(ExpressionError, xpath, ":only-of-type") - self.assertRaises(ExpressionError, xpath, ":last-of-type") - self.assertRaises(ExpressionError, xpath, ":nth-of-type(1)") - self.assertRaises(ExpressionError, xpath, ":nth-last-of-type(1)") - self.assertRaises(ExpressionError, xpath, ":nth-child(n-)") - self.assertRaises(ExpressionError, xpath, ":after") - self.assertRaises(ExpressionError, xpath, ":lorem-ipsum") - self.assertRaises(ExpressionError, xpath, ":lorem(ipsum)") - self.assertRaises(ExpressionError, xpath, "::lorem-ipsum") - self.assertRaises(TypeError, GenericTranslator().css_to_xpath, 4) - self.assertRaises(TypeError, GenericTranslator().selector_to_xpath, "foo") + with pytest.raises(ExpressionError): + xpath(":fİrst-child") + with pytest.raises(ExpressionError): + xpath(":first-of-type") + with pytest.raises(ExpressionError): + xpath(":only-of-type") + with pytest.raises(ExpressionError): + xpath(":last-of-type") + with pytest.raises(ExpressionError): + xpath(":nth-of-type(1)") + with pytest.raises(ExpressionError): + xpath(":nth-last-of-type(1)") + with pytest.raises(ExpressionError): + xpath(":nth-child(n-)") + with pytest.raises(ExpressionError): + xpath(":after") + with pytest.raises(ExpressionError): + xpath(":lorem-ipsum") + with pytest.raises(ExpressionError): + xpath(":lorem(ipsum)") + with pytest.raises(ExpressionError): + xpath("::lorem-ipsum") + with pytest.raises(TypeError): + GenericTranslator().css_to_xpath(4) # type: ignore[arg-type] + with pytest.raises(TypeError): + GenericTranslator().selector_to_xpath("foo") # type: ignore[arg-type] def test_unicode(self) -> None: css = ".a\xc1b" @@ -967,7 +977,8 @@ 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"] - self.assertRaises(ExpressionError, pcss, "p *:only-of-type") + with pytest.raises(ExpressionError): + pcss("p *:only-of-type") assert pcss("p:only-of-type") == ["paragraph"] assert pcss("a:empty", "a:EMpty") == ["name-anchor"] assert pcss("li:empty") == ["third-li", "fourth-li", "fifth-li", "sixth-li"] From 0ee48e6317fa5a39ce1b399b3517d3121e742276 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 26 Jul 2025 21:29:50 +0500 Subject: [PATCH 22/34] Bump mypy and pylint. --- cssselect/xpath.py | 8 ++++---- tox.ini | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index bc47dea..96eac3f 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -825,7 +825,7 @@ def __init__(self, xhtml: bool = False) -> None: self.lower_case_element_names = True self.lower_case_attribute_names = True - def xpath_checked_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[override] + def xpath_checked_pseudo(self, xpath: XPathExpr) -> XPathExpr: # FIXME: is this really all the elements? return xpath.add_condition( "(@selected and name(.) = 'option') or " @@ -850,7 +850,7 @@ def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr f"'-'), {arg})]" ) - def xpath_link_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[override] + def xpath_link_pseudo(self, xpath: XPathExpr) -> XPathExpr: return xpath.add_condition( "@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')" ) @@ -858,7 +858,7 @@ def xpath_link_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[over # Links are never visited, the implementation for :visited is the same # as in GenericTranslator - def xpath_disabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[override] + def xpath_disabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # http://www.w3.org/TR/html5/section-index.html#attributes-1 return xpath.add_condition( """ @@ -888,7 +888,7 @@ def xpath_disabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[ # FIXME: in the second half, add "and is not a descendant of that # fieldset element's first legend element child, if any." - def xpath_enabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # type: ignore[override] + def xpath_enabled_pseudo(self, xpath: XPathExpr) -> XPathExpr: # http://www.w3.org/TR/html5/section-index.html#attributes-1 return xpath.add_condition( """ diff --git a/tox.ini b/tox.ini index a0a9413..01794d6 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ commands = [testenv:pylint] deps = {[testenv]deps} - pylint==3.3.5 + pylint==3.3.7 commands = pylint {posargs: cssselect tests docs} @@ -30,8 +30,8 @@ commands = [testenv:typing] deps = {[testenv]deps} - mypy==1.15.0 - types-lxml==2025.3.4 + mypy==1.17.0 + types-lxml==2025.3.30 commands = mypy --strict {posargs: cssselect tests} From 52aabe2e7f974c8eda16190c62450cd4c32efb1e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 26 Jul 2025 21:30:29 +0500 Subject: [PATCH 23/34] Add more linters. --- .git-blame-ignore-revs | 2 +- .github/workflows/checks.yml | 2 +- .pre-commit-config.yaml | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 9d2c8f6..bb4f6e1 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,2 +1,2 @@ # applying pre-commit hooks to the project -e91101b37f82558db84a6b8ee9a6dba1fd2ae0bb \ No newline at end of file +e91101b37f82558db84a6b8ee9a6dba1fd2ae0bb diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 666aaba..1607756 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -35,7 +35,7 @@ jobs: pip install -U pip pip install -U tox tox - + pre-commit: runs-on: ubuntu-latest steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a8eebd9..119b328 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,3 +5,18 @@ repos: - id: ruff-check args: [ --fix ] - id: ruff-format +- repo: https://github.com/adamchainz/blacken-docs + rev: 1.19.1 + hooks: + - id: blacken-docs + additional_dependencies: + - black==25.1.0 +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/sphinx-contrib/sphinx-lint + rev: v1.0.0 + hooks: + - id: sphinx-lint From db990241a8bea7a7339baf4d007daa1c2fc28812 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 13 Sep 2025 21:57:56 +0500 Subject: [PATCH 24/34] Bump Python 3.14 to RC2. --- .github/workflows/tests-macos.yml | 2 +- .github/workflows/tests-ubuntu.yml | 2 +- .github/workflows/tests-windows.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 7b1bcb7..21af09a 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.1"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 6881cf7..d65eadd 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.1", "pypy3.10", "pypy3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2", "pypy3.10", "pypy3.11"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 11e5917..658a6eb 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.1"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2"] steps: - uses: actions/checkout@v4 From 3a66f9568c977014376242c275357711bb6cb078 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 13 Sep 2025 21:58:16 +0500 Subject: [PATCH 25/34] Drop PyPy 3.10. --- .github/workflows/tests-ubuntu.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index d65eadd..91412b8 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2", "pypy3.10", "pypy3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2", "pypy3.11"] steps: - uses: actions/checkout@v4 From 74ba53e3228dde2b4a0e0115a9754fbafe6b1cbf Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 13 Sep 2025 21:58:55 +0500 Subject: [PATCH 26/34] Bump linters. --- .pre-commit-config.yaml | 6 +++--- tox.ini | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 119b328..461b7cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,18 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.5 + rev: v0.13.0 hooks: - id: ruff-check args: [ --fix ] - id: ruff-format - repo: https://github.com/adamchainz/blacken-docs - rev: 1.19.1 + rev: 1.20.0 hooks: - id: blacken-docs additional_dependencies: - black==25.1.0 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace diff --git a/tox.ini b/tox.ini index 01794d6..46e8579 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ commands = [testenv:pylint] deps = {[testenv]deps} - pylint==3.3.7 + pylint==3.3.8 commands = pylint {posargs: cssselect tests docs} @@ -30,8 +30,8 @@ commands = [testenv:typing] deps = {[testenv]deps} - mypy==1.17.0 - types-lxml==2025.3.30 + mypy==1.18.1 + types-lxml==2025.8.25 commands = mypy --strict {posargs: cssselect tests} @@ -43,8 +43,8 @@ skip_install = true [testenv:twinecheck] basepython = python3 deps = - twine==6.1.0 - build==1.2.2.post1 + twine==6.2.0 + build==1.3.0 commands = python -m build --sdist twine check dist/* From 0f27bb1bd77c0ac6aa6ff7f8c6f5e7773595a0a5 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 13 Sep 2025 22:01:04 +0500 Subject: [PATCH 27/34] Remove setuptools from test deps. --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index 46e8579..452a364 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,6 @@ deps = lxml>=4.4 pytest-cov>=2.8 pytest>=5.4 - setuptools sybil commands = pytest --cov=cssselect \ From 9112730f04457d58712abb8743ddcca3718ffe85 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 13 Sep 2025 22:02:43 +0500 Subject: [PATCH 28/34] Bump CI actions. --- .github/workflows/checks.yml | 6 +++--- .github/workflows/publish.yml | 4 ++-- .github/workflows/tests-macos.yml | 4 ++-- .github/workflows/tests-ubuntu.yml | 4 ++-- .github/workflows/tests-windows.yml | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1607756..be003f5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -22,10 +22,10 @@ jobs: TOXENV: twinecheck steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -39,5 +39,5 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ad470a8..3faf4ab 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,10 +16,10 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.13 diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 21af09a..9d43f12 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -10,10 +10,10 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 91412b8..85db82d 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -10,7 +10,7 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2", "pypy3.11"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install system libraries if: contains(matrix.python-version, 'pypy') || contains(matrix.python-version, '3.14.0-rc') @@ -19,7 +19,7 @@ jobs: sudo apt-get install libxml2-dev libxslt-dev - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 658a6eb..d4139b7 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -10,10 +10,10 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} From 2334984decf6fb6b59bd8bea78e552b7a6da8d7e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 16 Sep 2025 21:36:08 +0500 Subject: [PATCH 29/34] Roll back a backward incompatible fix. --- cssselect/parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index 5bca712..6cbe5d2 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -441,9 +441,9 @@ class Hash: Represents selector#id """ - def __init__(self, selector: Tree, id_: str) -> None: + def __init__(self, selector: Tree, id: str) -> None: # noqa: A002 self.selector = selector - self.id = id_ + self.id = id def __repr__(self) -> str: return f"{self.__class__.__name__}[{self.selector!r}#{self.id}]" From 439ad91e8911e4b366945d1c9eea8db93dd5d368 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 9 Dec 2025 16:10:12 +0500 Subject: [PATCH 30/34] Drop Python 3.9 (#160) --- .github/workflows/checks.yml | 6 +++--- .github/workflows/publish.yml | 2 +- .github/workflows/tests-macos.yml | 2 +- .github/workflows/tests-ubuntu.yml | 4 ++-- .github/workflows/tests-windows.yml | 2 +- .pre-commit-config.yaml | 4 ++-- cssselect/parser.py | 8 ++++---- pyproject.toml | 4 +--- tox.ini | 6 +++--- 9 files changed, 18 insertions(+), 20 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index be003f5..53b1962 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -8,16 +8,16 @@ jobs: fail-fast: false matrix: include: - - python-version: 3.13 + - python-version: 3.14 env: TOXENV: pylint - python-version: 3.13 # Keep in sync with .readthedocs.yml env: TOXENV: docs - - python-version: 3.13 + - python-version: 3.14 env: TOXENV: typing - - python-version: 3.13 + - python-version: 3.14 env: TOXENV: twinecheck diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3faf4ab..4609175 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: 3.13 + python-version: 3.14 - name: Build run: | diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 9d43f12..be68aac 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 85db82d..1ab66c2 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -7,13 +7,13 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2", "pypy3.11"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.11"] steps: - uses: actions/checkout@v5 - name: Install system libraries - if: contains(matrix.python-version, 'pypy') || contains(matrix.python-version, '3.14.0-rc') + if: contains(matrix.python-version, 'pypy') run: | sudo apt-get update sudo apt-get install libxml2-dev libxslt-dev diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index d4139b7..44dc63e 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-rc.2"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 461b7cf..e553d0a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.0 + rev: v0.14.4 hooks: - id: ruff-check args: [ --fix ] @@ -10,7 +10,7 @@ repos: hooks: - id: blacken-docs additional_dependencies: - - black==25.1.0 + - black==25.9.0 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: diff --git a/cssselect/parser.py b/cssselect/parser.py index 6cbe5d2..f969769 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -16,7 +16,7 @@ import operator import re import sys -from typing import TYPE_CHECKING, Literal, Optional, Protocol, Union, cast, overload +from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias, Union, cast, overload if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Sequence @@ -46,7 +46,7 @@ class SelectorSyntaxError(SelectorError, SyntaxError): #### Parsed objects -Tree = Union[ +Tree: TypeAlias = Union[ "Element", "Hash", "Class", @@ -59,7 +59,7 @@ class SelectorSyntaxError(SelectorError, SyntaxError): "SpecificityAdjustment", "CombinedSelector", ] -PseudoElement = Union["FunctionalPseudoElement", str] +PseudoElement: TypeAlias = Union["FunctionalPseudoElement", str] class Selector: @@ -831,7 +831,7 @@ def parse_series(tokens: Iterable[Token]) -> tuple[int, int]: #### Token objects -class Token(tuple[str, Optional[str]]): # noqa: SLOT001 +class Token(tuple[str, str | None]): # noqa: SLOT001 @overload def __new__( cls, diff --git a/pyproject.toml b/pyproject.toml index 8506c66..6b89b39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,11 @@ description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" readme = "README.rst" authors = [{ name = "Ian Bicking", email = "ianb@colorstudy.com" }] maintainers = [{ name = "Paul Tremberth", email = "paul.tremberth@gmail.com" }] -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -65,7 +64,6 @@ exclude_also = [ "def __repr__", "if sys.version_info", "if __name__ == '__main__':", - "if TYPE_CHECKING:", ] [tool.pylint.MASTER] diff --git a/tox.ini b/tox.ini index 452a364..949a297 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ envlist = pre-commit,pylint,py,docs,typing [testenv] deps = lxml>=4.4 - pytest-cov>=2.8 + pytest-cov>=7.0.0 pytest>=5.4 sybil commands = @@ -15,7 +15,7 @@ commands = [testenv:pylint] deps = {[testenv]deps} - pylint==3.3.8 + pylint==4.0.2 commands = pylint {posargs: cssselect tests docs} @@ -29,7 +29,7 @@ commands = [testenv:typing] deps = {[testenv]deps} - mypy==1.18.1 + mypy==1.18.2 types-lxml==2025.8.25 commands = mypy --strict {posargs: cssselect tests} From 988a5f48cc413b55ae1e8cd284aa2f4966617c47 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 26 Jan 2026 13:11:37 +0400 Subject: [PATCH 31/34] Bump tools, add actionlint. (#161) * Bump tools, add actionlint. * Bump one more actions/checkout. --- .github/workflows/checks.yml | 6 +++--- .github/workflows/publish.yml | 2 +- .github/workflows/tests-macos.yml | 2 +- .github/workflows/tests-ubuntu.yml | 2 +- .github/workflows/tests-windows.yml | 2 +- .pre-commit-config.yaml | 6 +++++- .readthedocs.yml | 2 +- pyproject.toml | 3 +++ tox.ini | 10 +++++----- 9 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 53b1962..41ff7e1 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,7 +11,7 @@ jobs: - python-version: 3.14 env: TOXENV: pylint - - python-version: 3.13 # Keep in sync with .readthedocs.yml + - python-version: 3.14 # Keep in sync with .readthedocs.yml env: TOXENV: docs - python-version: 3.14 @@ -22,7 +22,7 @@ jobs: TOXENV: twinecheck steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 @@ -39,5 +39,5 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4609175..526c458 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index be68aac..4947937 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -10,7 +10,7 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 1ab66c2..1ef905b 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -10,7 +10,7 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.11"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install system libraries if: contains(matrix.python-version, 'pypy') diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 44dc63e..24d7ee8 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -10,7 +10,7 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e553d0a..81ca890 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: hooks: - id: blacken-docs additional_dependencies: - - black==25.9.0 + - black==26.1.0 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: @@ -20,3 +20,7 @@ repos: rev: v1.0.0 hooks: - id: sphinx-lint +- repo: https://github.com/rhysd/actionlint + rev: v1.7.10 + hooks: + - id: actionlint diff --git a/.readthedocs.yml b/.readthedocs.yml index 46f5f14..b91642a 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -8,7 +8,7 @@ build: tools: # For available versions, see: # https://docs.readthedocs.io/en/stable/config-file/v2.html#build-tools-python - python: "3.13" # Keep in sync with .github/workflows/checks.yml + python: "3.14" # Keep in sync with .github/workflows/checks.yml python: install: - requirements: docs/requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 6b89b39..e22a153 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,9 @@ exclude_also = [ "if __name__ == '__main__':", ] +[tool.mypy] +strict = true + [tool.pylint.MASTER] persistent = "no" extension-pkg-allow-list = ["lxml"] diff --git a/tox.ini b/tox.ini index 949a297..9ff54cf 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ commands = [testenv:pylint] deps = {[testenv]deps} - pylint==4.0.2 + pylint==4.0.4 commands = pylint {posargs: cssselect tests docs} @@ -29,10 +29,10 @@ commands = [testenv:typing] deps = {[testenv]deps} - mypy==1.18.2 - types-lxml==2025.8.25 + mypy==1.19.1 + types-lxml==2026.1.1 commands = - mypy --strict {posargs: cssselect tests} + mypy {posargs: cssselect tests} [testenv:pre-commit] deps = pre-commit @@ -43,7 +43,7 @@ skip_install = true basepython = python3 deps = twine==6.2.0 - build==1.3.0 + build==1.4.0 commands = python -m build --sdist twine check dist/* From b4dc0ce3fa3f9e68074f4945a43e9762914e88c8 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 28 Jan 2026 19:38:00 +0500 Subject: [PATCH 32/34] Release notes for 1.4.0. --- CHANGES | 13 +++++++++++++ docs/conf.py | 2 +- pyproject.toml | 6 ++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index a6d5f41..ccb5980 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,19 @@ Changelog ========= +Version 1.4.0 +------------- + +Unreleased. + +* Dropped support for Python 3.9 and PyPy 3.10. + +* Added support for Python 3.14 and PyPy 3.11. + +* Switched the build system to ``hatchling``. + +* CI fixes and improvements. + Version 1.3.0 ------------- diff --git a/docs/conf.py b/docs/conf.py index 5713d17..da3f023 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,7 +32,7 @@ templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = ".rst" +source_suffix = {".rst": "restructuredtext"} # The encoding of source files. # source_encoding = 'utf-8-sig' diff --git a/pyproject.toml b/pyproject.toml index e22a153..2b36a9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,12 @@ tag = true [[tool.bumpversion.files]] filename = "cssselect/__init__.py" +[[tool.bumpversion.files]] +filename = "NEWS" +search = "^Unreleased\\.$" +replace = "Released on ({now:%Y-%m-%d})" +regex = true + [tool.coverage.run] branch = true source = ["cssselect"] From cede767169d8d2c1125ad4dd95e139441cb08778 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 28 Jan 2026 20:16:03 +0500 Subject: [PATCH 33/34] Fix bumpversion configuration for the changelog. --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2b36a9e..5ee907a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,9 +56,9 @@ tag = true filename = "cssselect/__init__.py" [[tool.bumpversion.files]] -filename = "NEWS" +filename = "CHANGES" search = "^Unreleased\\.$" -replace = "Released on ({now:%Y-%m-%d})" +replace = "Released on {now:%Y-%m-%d}." regex = true [tool.coverage.run] From 743c6e524ee534ed34fd4d4121ee629fee5cddec Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 29 Jan 2026 11:59:11 +0500 Subject: [PATCH 34/34] =?UTF-8?q?Bump=20version:=201.3.0=20=E2=86=92=201.4?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGES | 2 +- cssselect/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index ccb5980..5ca2959 100644 --- a/CHANGES +++ b/CHANGES @@ -4,7 +4,7 @@ Changelog Version 1.4.0 ------------- -Unreleased. +Released on 2026-01-29. * Dropped support for Python 3.9 and PyPy 3.10. diff --git a/cssselect/__init__.py b/cssselect/__init__.py index 67acaaa..59d62df 100644 --- a/cssselect/__init__.py +++ b/cssselect/__init__.py @@ -32,5 +32,5 @@ "parse", ) -VERSION = "1.3.0" +VERSION = "1.4.0" __version__ = VERSION diff --git a/pyproject.toml b/pyproject.toml index 5ee907a..c7c54a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ exclude = [ packages = ["cssselect"] [tool.bumpversion] -current_version = "1.3.0" +current_version = "1.4.0" commit = true tag = true