From 6ea5e5e014940cb013258372d7f0657617808121 Mon Sep 17 00:00:00 2001 From: Daniel D'Avella Date: Mon, 4 Dec 2023 15:33:09 -0500 Subject: [PATCH 1/2] Implement safer dependency parsing --- .../file_parsers/base_parser.py | 22 +++-- .../file_parsers/package_store.py | 6 +- .../pyproject_toml_file_parser.py | 32 +++----- .../requirements_txt_file_parser.py | 54 ++++++------- .../file_parsers/setup_cfg_file_parser.py | 27 +++---- .../file_parsers/setup_py_file_parser.py | 81 ++++++++----------- .../test_pyproject_toml_file_parser.py | 15 +++- .../test_requirements_txt_file_parser.py | 10 ++- .../test_setup_cfg_file_parser.py | 17 +++- .../file_parsers/test_setup_py_file_parser.py | 17 +++- 10 files changed, 152 insertions(+), 129 deletions(-) diff --git a/src/codemodder/project_analysis/file_parsers/base_parser.py b/src/codemodder/project_analysis/file_parsers/base_parser.py index beb7023d..58190f2f 100644 --- a/src/codemodder/project_analysis/file_parsers/base_parser.py +++ b/src/codemodder/project_analysis/file_parsers/base_parser.py @@ -1,9 +1,10 @@ from abc import ABC, abstractmethod - from pathlib import Path from typing import List -from .package_store import PackageStore -from packaging.requirements import Requirement + +from codemodder.dependency import Requirement +from codemodder.logging import logger +from .package_store import FileType, PackageStore class BaseParser(ABC): @@ -12,8 +13,8 @@ def __init__(self, parent_directory: Path): @property @abstractmethod - def file_type(self): - ... # pragma: no cover + def file_type(self) -> FileType: + pass def _parse_dependencies(self, dependencies: List[str]): return [ @@ -24,8 +25,8 @@ def _parse_dependencies(self, dependencies: List[str]): ] @abstractmethod - def _parse_file(self, file: Path): - ... # pragma: no cover + def _parse_file(self, file: Path) -> PackageStore | None: + pass def find_file_locations(self) -> List[Path]: return list(Path(self.parent_directory).rglob(self.file_type.value)) @@ -37,7 +38,12 @@ def parse(self) -> list[PackageStore]: stores = [] req_files = self.find_file_locations() for file in req_files: - store = self._parse_file(file) + try: + store = self._parse_file(file) + except Exception as e: + logger.debug("Error parsing file: %s", file, exc_info=e) + continue + if store: stores.append(store) return stores diff --git a/src/codemodder/project_analysis/file_parsers/package_store.py b/src/codemodder/project_analysis/file_parsers/package_store.py index 5fc29bee..42048ab9 100644 --- a/src/codemodder/project_analysis/file_parsers/package_store.py +++ b/src/codemodder/project_analysis/file_parsers/package_store.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from enum import Enum -from packaging.requirements import Requirement +from pathlib import Path + +from codemodder.dependency import Requirement class FileType(Enum): @@ -13,6 +15,6 @@ class FileType(Enum): @dataclass class PackageStore: type: FileType - file: str + file: Path dependencies: set[Requirement] py_versions: list[str] diff --git a/src/codemodder/project_analysis/file_parsers/pyproject_toml_file_parser.py b/src/codemodder/project_analysis/file_parsers/pyproject_toml_file_parser.py index fbe13d26..b3e66124 100644 --- a/src/codemodder/project_analysis/file_parsers/pyproject_toml_file_parser.py +++ b/src/codemodder/project_analysis/file_parsers/pyproject_toml_file_parser.py @@ -1,10 +1,11 @@ +from pathlib import Path + +import toml + from codemodder.project_analysis.file_parsers.package_store import ( PackageStore, FileType, ) -from pathlib import Path -import toml - from .base_parser import BaseParser @@ -13,25 +14,18 @@ class PyprojectTomlParser(BaseParser): def file_type(self): return FileType.TOML - def _parse_dependencies_from_toml(self, toml_data: dict): - # todo: handle cases for - # 1. no dependencies - return self._parse_dependencies(toml_data["project"]["dependencies"]) + def _parse_file(self, file: Path) -> PackageStore | None: + data = toml.load(file) - def _parse_py_versions(self, toml_data: dict) -> list: - # todo: handle cases for - # 1. multiple requires-python such as "">3.5.2"", ">=3.11.1,<3.11.2" - maybe_project = toml_data.get("project") - maybe_python = maybe_project.get("requires-python") if maybe_project else None - return [maybe_python] if maybe_python else [] + if not (project := data.get("project")): + return None - def _parse_file(self, file: Path): - data = toml.load(file) - # todo: handle no "project" in data + dependencies = project.get("dependencies", []) + version = project.get("requires-python", None) return PackageStore( type=self.file_type, - file=str(file), - dependencies=set(self._parse_dependencies_from_toml(data)), - py_versions=self._parse_py_versions(data), + file=file, + dependencies=set(dependencies) if dependencies else set(), + py_versions=[version] if version else [], ) diff --git a/src/codemodder/project_analysis/file_parsers/requirements_txt_file_parser.py b/src/codemodder/project_analysis/file_parsers/requirements_txt_file_parser.py index 7d1ba2f9..2ae9d67e 100644 --- a/src/codemodder/project_analysis/file_parsers/requirements_txt_file_parser.py +++ b/src/codemodder/project_analysis/file_parsers/requirements_txt_file_parser.py @@ -1,14 +1,13 @@ -from typing import Optional +from pathlib import Path + +import chardet -from packaging.requirements import InvalidRequirement +from codemodder.logging import logger from codemodder.project_analysis.file_parsers.package_store import ( PackageStore, FileType, ) -from pathlib import Path from .base_parser import BaseParser -import chardet -from codemodder.logging import logger class RequirementsTxtParser(BaseParser): @@ -16,26 +15,25 @@ class RequirementsTxtParser(BaseParser): def file_type(self): return FileType.REQ_TXT - def _parse_file(self, file: Path) -> Optional[PackageStore]: - try: - with open(file, "rb") as f: - whole_file = f.read() - enc = chardet.detect(whole_file) - if enc["confidence"] > 0.9: - encoding = enc.get("encoding") - decoded = whole_file.decode(encoding.lower()) if encoding else "" - lines = decoded.splitlines() if decoded else [] - else: - raise UnicodeError() - return PackageStore( - type=self.file_type, - file=str(file), - dependencies=set(self._parse_dependencies(lines)), - # requirements.txt files do not declare py versions explicitly - # though we could create a heuristic by analyzing each dependency - # and extracting py versions from them. - py_versions=[], - ) - except (UnicodeError, OSError, InvalidRequirement): - logger.debug("Error parsing file: %s", file) - return None + def _parse_file(self, file: Path) -> PackageStore | None: + with open(file, "rb") as f: + whole_file = f.read() + + enc = chardet.detect(whole_file) + if enc["confidence"] > 0.9: + encoding = enc.get("encoding") + decoded = whole_file.decode(encoding.lower()) if encoding else "" + lines = decoded.splitlines() if decoded else [] + else: + logger.debug("Unknown encoding for file: %s", file) + return None + + return PackageStore( + type=self.file_type, + file=file, + dependencies=set(self._parse_dependencies(lines)), + # requirements.txt files do not declare py versions explicitly + # though we could create a heuristic by analyzing each dependency + # and extracting py versions from them. + py_versions=[], + ) diff --git a/src/codemodder/project_analysis/file_parsers/setup_cfg_file_parser.py b/src/codemodder/project_analysis/file_parsers/setup_cfg_file_parser.py index dc8bfad5..9a600acc 100644 --- a/src/codemodder/project_analysis/file_parsers/setup_cfg_file_parser.py +++ b/src/codemodder/project_analysis/file_parsers/setup_cfg_file_parser.py @@ -13,28 +13,19 @@ class SetupCfgParser(BaseParser): def file_type(self): return FileType.SETUP_CFG - def _parse_dependencies_from_cfg(self, config: configparser.ConfigParser): - # todo: handle cases for - # 1. no dependencies, no options dict - # setup_requires, tests_require, extras_require - dependency_lines = config["options"]["install_requires"].split("\n") - return self._parse_dependencies(dependency_lines) - - def _parse_py_versions(self, config: configparser.ConfigParser): - # todo: handle cases for - # 1. no options/ no requires-python - # 2. various requires-python such as "">3.5.2"", ">=3.11.1,<3.11.2" - return [config["options"]["python_requires"]] - - def _parse_file(self, file: Path): + def _parse_file(self, file: Path) -> PackageStore | None: config = configparser.ConfigParser() config.read(file) - # todo: handle no config, no "options" in config + if not (options := config["options"]): + return None + + dependency_lines = options.get("install_requires", "").split("\n") + python_requires = options.get("python_requires", "") return PackageStore( type=self.file_type, - file=str(file), - dependencies=set(self._parse_dependencies_from_cfg(config)), - py_versions=self._parse_py_versions(config), + file=file, + dependencies=set(self._parse_dependencies(dependency_lines)), + py_versions=[python_requires] if python_requires else [], ) diff --git a/src/codemodder/project_analysis/file_parsers/setup_py_file_parser.py b/src/codemodder/project_analysis/file_parsers/setup_py_file_parser.py index 6ad3fde3..3a9d8b83 100644 --- a/src/codemodder/project_analysis/file_parsers/setup_py_file_parser.py +++ b/src/codemodder/project_analysis/file_parsers/setup_py_file_parser.py @@ -5,9 +5,6 @@ from codemodder.utils.utils import clean_simplestring from pathlib import Path import libcst as cst -from libcst import matchers -from packaging.requirements import Requirement -from typing import Optional from .base_parser import BaseParser @@ -17,67 +14,59 @@ class SetupPyParser(BaseParser): def file_type(self): return FileType.SETUP_PY - def _parse_dependencies(self, dependencies): - return [ - Requirement(line) - for x in dependencies - # Skip empty lines and comments - if (line := clean_simplestring(x.value)) and not line.startswith("#") - ] - - def _parse_dependencies_from_cst(self, cst_dependencies: Optional[list]): - return self._parse_dependencies(cst_dependencies) if cst_dependencies else [] - - def _parse_py_versions(self, version_str): - # todo: handle for multiple versions - return [clean_simplestring(version_str)] + def _parse_file(self, file: Path) -> PackageStore | None: + with open(file, "r", encoding="utf8") as f: + module = cst.parse_module(f.read()) - def _parse_file(self, file: Path): visitor = SetupCallVisitor() - with open(str(file), "r", encoding="utf-8") as f: - # todo: handle failure in parsing - module = cst.parse_module(f.read()) module.visit(visitor) # todo: handle no python_requires, install_requires return PackageStore( type=self.file_type, - file=str(file), - dependencies=set( - self._parse_dependencies_from_cst(visitor.install_requires) - ), - py_versions=self._parse_py_versions(visitor.python_requires), + file=file, + dependencies=set(self._parse_dependencies(visitor.install_requires)), + py_versions=visitor.python_requires, ) class SetupCallVisitor(cst.CSTVisitor): + python_requires: list[str] + install_requires: list[str] + def __init__(self): - self.python_requires = None - self.install_requires = None - # todo setup_requires, tests_require, extras_require + self.python_requires = [] + self.install_requires = [] + # TODO: setup_requires, tests_require, extras_require def visit_Call(self, node: cst.Call) -> None: - # todo: only handle setup from setuptools, not others tho unlikely - if matchers.matches(node.func, cst.Name(value="setup")): - visitor = SetupArgVisitor() - node.visit(visitor) - self.python_requires = visitor.python_requires - self.install_requires = visitor.install_requires + # TODO: only handle setup from setuptools, not others tho unlikely + match node.func: + case cst.Name(value="setup"): + visitor = SetupArgVisitor() + node.visit(visitor) + self.python_requires.extend(visitor.python_requires) + self.install_requires.extend(visitor.install_requires) class SetupArgVisitor(cst.CSTVisitor): + python_requires: list[str] + install_requires: list[str] + def __init__(self): - self.python_requires = None - self.install_requires = None + self.python_requires = [] + self.install_requires = [] def visit_Arg(self, node: cst.Arg) -> None: - if matchers.matches(node.keyword, matchers.Name(value="python_requires")): - # todo: this works for `python_requires=">=3.7",` but what about - # a list of versions? - self.python_requires = node.value.value - if matchers.matches( - node.keyword, matchers.Name(value="install_requires") - ) and matchers.matches(node.value, matchers.List()): - # todo: if node.value is Name node, find requirements in the variable at node.value - self.install_requires = node.value.elements + match node.keyword, node.value: + case cst.Name(value="python_requires"), cst.SimpleString() as string_node: + # TODO: this works for `python_requires=">=3.7",` but what about a list of versions? + self.python_requires.append(clean_simplestring(string_node.value)) + case cst.Name(value="install_requires"), cst.List() as list_node: + for elm in list_node.elements: + match elm: + case cst.Element(value=cst.SimpleString() as string_node): + self.install_requires.append( + clean_simplestring(string_node.value) + ) diff --git a/tests/project_analysis/file_parsers/test_pyproject_toml_file_parser.py b/tests/project_analysis/file_parsers/test_pyproject_toml_file_parser.py index 600c46d5..5fd0e423 100644 --- a/tests/project_analysis/file_parsers/test_pyproject_toml_file_parser.py +++ b/tests/project_analysis/file_parsers/test_pyproject_toml_file_parser.py @@ -56,7 +56,7 @@ def test_parse(self, pkg_with_pyproject_toml): assert len(found) == 1 store = found[0] assert store.type.value == "pyproject.toml" - assert store.file == str(pkg_with_pyproject_toml / parser.file_type.value) + assert store.file == pkg_with_pyproject_toml / parser.file_type.value assert store.py_versions == [">=3.10.0"] assert len(store.dependencies) == 6 @@ -66,9 +66,7 @@ def test_parse_no_python(self, pkg_with_pyproject_toml_no_python): assert len(found) == 1 store = found[0] assert store.type.value == "pyproject.toml" - assert store.file == str( - pkg_with_pyproject_toml_no_python / parser.file_type.value - ) + assert store.file == pkg_with_pyproject_toml_no_python / parser.file_type.value assert store.py_versions == [] assert len(store.dependencies) == 1 @@ -76,3 +74,12 @@ def test_parse_no_file(self, pkg_with_pyproject_toml): parser = PyprojectTomlParser(pkg_with_pyproject_toml / "foo") found = parser.parse() assert len(found) == 0 + + def test_parser_error(self, pkg_with_pyproject_toml, mocker): + mocker.patch( + "codemodder.project_analysis.file_parsers.pyproject_toml_file_parser.toml.load", + side_effect=Exception, + ) + parser = PyprojectTomlParser(pkg_with_pyproject_toml) + found = parser.parse() + assert len(found) == 0 diff --git a/tests/project_analysis/file_parsers/test_requirements_txt_file_parser.py b/tests/project_analysis/file_parsers/test_requirements_txt_file_parser.py index eb6625fe..3113d483 100644 --- a/tests/project_analysis/file_parsers/test_requirements_txt_file_parser.py +++ b/tests/project_analysis/file_parsers/test_requirements_txt_file_parser.py @@ -8,7 +8,7 @@ def test_parse(self, pkg_with_reqs_txt): assert len(found) == 1 store = found[0] assert store.type.value == "requirements.txt" - assert store.file == str(pkg_with_reqs_txt / parser.file_type.value) + assert store.file == pkg_with_reqs_txt / parser.file_type.value assert store.py_versions == [] assert len(store.dependencies) == 4 @@ -18,7 +18,7 @@ def test_parse_utf_16(self, pkg_with_reqs_txt_utf_16): assert len(found) == 1 store = found[0] assert store.type.value == "requirements.txt" - assert store.file == str(pkg_with_reqs_txt_utf_16 / parser.file_type.value) + assert store.file == pkg_with_reqs_txt_utf_16 / parser.file_type.value assert store.py_versions == [] assert len(store.dependencies) == 4 @@ -31,3 +31,9 @@ def test_parse_no_file(self, pkg_with_reqs_txt): parser = RequirementsTxtParser(pkg_with_reqs_txt / "foo") found = parser.parse() assert len(found) == 0 + + def test_open_error(self, pkg_with_reqs_txt, mocker): + mocker.patch("builtins.open", side_effect=Exception) + parser = RequirementsTxtParser(pkg_with_reqs_txt) + found = parser.parse() + assert len(found) == 0 diff --git a/tests/project_analysis/file_parsers/test_setup_cfg_file_parser.py b/tests/project_analysis/file_parsers/test_setup_cfg_file_parser.py index 1d285d0f..4d273b7f 100644 --- a/tests/project_analysis/file_parsers/test_setup_cfg_file_parser.py +++ b/tests/project_analysis/file_parsers/test_setup_cfg_file_parser.py @@ -39,7 +39,7 @@ def test_parse(self, pkg_with_setup_cfg): assert len(found) == 1 store = found[0] assert store.type.value == "setup.cfg" - assert store.file == str(pkg_with_setup_cfg / parser.file_type.value) + assert store.file == pkg_with_setup_cfg / parser.file_type.value assert store.py_versions == [">=3.7"] assert len(store.dependencies) == 2 @@ -47,3 +47,18 @@ def test_parse_no_file(self, pkg_with_setup_cfg): parser = SetupCfgParser(pkg_with_setup_cfg / "foo") found = parser.parse() assert len(found) == 0 + + def test_open_error(self, pkg_with_setup_cfg, mocker): + mocker.patch("builtins.open", side_effect=Exception) + parser = SetupCfgParser(pkg_with_setup_cfg) + found = parser.parse() + assert len(found) == 0 + + def test_parser_error(self, pkg_with_setup_cfg, mocker): + mocker.patch( + "codemodder.project_analysis.file_parsers.setup_cfg_file_parser.configparser.ConfigParser.read", + side_effect=Exception, + ) + parser = SetupCfgParser(pkg_with_setup_cfg) + found = parser.parse() + assert len(found) == 0 diff --git a/tests/project_analysis/file_parsers/test_setup_py_file_parser.py b/tests/project_analysis/file_parsers/test_setup_py_file_parser.py index 93244a2c..c62b14e4 100644 --- a/tests/project_analysis/file_parsers/test_setup_py_file_parser.py +++ b/tests/project_analysis/file_parsers/test_setup_py_file_parser.py @@ -46,7 +46,7 @@ def test_parse(self, pkg_with_setup_py): assert len(found) == 1 store = found[0] assert store.type.value == "setup.py" - assert store.file == str(pkg_with_setup_py / parser.file_type.value) + assert store.file == pkg_with_setup_py / parser.file_type.value assert store.py_versions == [">3.6"] assert len(store.dependencies) == 4 @@ -54,3 +54,18 @@ def test_parse_no_file(self, pkg_with_setup_py): parser = SetupPyParser(pkg_with_setup_py / "foo") found = parser.parse() assert len(found) == 0 + + def test_open_error(self, pkg_with_setup_py, mocker): + mocker.patch("builtins.open", side_effect=Exception) + parser = SetupPyParser(pkg_with_setup_py) + found = parser.parse() + assert len(found) == 0 + + def test_parser_error(self, pkg_with_setup_py, mocker): + mocker.patch( + "codemodder.project_analysis.file_parsers.setup_py_file_parser.cst.Module.visit", + side_effect=Exception, + ) + parser = SetupPyParser(pkg_with_setup_py) + found = parser.parse() + assert len(found) == 0 From f3eb073a6b339305be9a659938b718ad12e5a6e5 Mon Sep 17 00:00:00 2001 From: Daniel D'Avella Date: Mon, 4 Dec 2023 16:00:58 -0500 Subject: [PATCH 2/2] Move dependency parsing to PackageStore --- .../file_parsers/base_parser.py | 11 ++--------- .../file_parsers/package_store.py | 17 ++++++++++++++++- .../file_parsers/pyproject_toml_file_parser.py | 2 +- .../requirements_txt_file_parser.py | 4 +++- .../file_parsers/setup_cfg_file_parser.py | 2 +- .../file_parsers/setup_py_file_parser.py | 2 +- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/codemodder/project_analysis/file_parsers/base_parser.py b/src/codemodder/project_analysis/file_parsers/base_parser.py index 58190f2f..014f9b23 100644 --- a/src/codemodder/project_analysis/file_parsers/base_parser.py +++ b/src/codemodder/project_analysis/file_parsers/base_parser.py @@ -2,12 +2,13 @@ from pathlib import Path from typing import List -from codemodder.dependency import Requirement from codemodder.logging import logger from .package_store import FileType, PackageStore class BaseParser(ABC): + parent_directory: Path + def __init__(self, parent_directory: Path): self.parent_directory = parent_directory @@ -16,14 +17,6 @@ def __init__(self, parent_directory: Path): def file_type(self) -> FileType: pass - def _parse_dependencies(self, dependencies: List[str]): - return [ - Requirement(line) - for x in dependencies - # Skip empty lines and comments - if (line := x.strip()) and not line.startswith("#") - ] - @abstractmethod def _parse_file(self, file: Path) -> PackageStore | None: pass diff --git a/src/codemodder/project_analysis/file_parsers/package_store.py b/src/codemodder/project_analysis/file_parsers/package_store.py index 42048ab9..e28090f8 100644 --- a/src/codemodder/project_analysis/file_parsers/package_store.py +++ b/src/codemodder/project_analysis/file_parsers/package_store.py @@ -12,9 +12,24 @@ class FileType(Enum): SETUP_CFG = "setup.cfg" -@dataclass +@dataclass(init=False) class PackageStore: type: FileType file: Path dependencies: set[Requirement] py_versions: list[str] + + def __init__( + self, + type: FileType, # pylint: disable=redefined-builtin + file: Path, + dependencies: set[str | Requirement], + py_versions: list[str], + ): + self.type = type + self.file = file + self.dependencies = { + dep if isinstance(dep, Requirement) else Requirement(dep) + for dep in dependencies + } + self.py_versions = py_versions diff --git a/src/codemodder/project_analysis/file_parsers/pyproject_toml_file_parser.py b/src/codemodder/project_analysis/file_parsers/pyproject_toml_file_parser.py index b3e66124..b0d59b78 100644 --- a/src/codemodder/project_analysis/file_parsers/pyproject_toml_file_parser.py +++ b/src/codemodder/project_analysis/file_parsers/pyproject_toml_file_parser.py @@ -26,6 +26,6 @@ def _parse_file(self, file: Path) -> PackageStore | None: return PackageStore( type=self.file_type, file=file, - dependencies=set(dependencies) if dependencies else set(), + dependencies=set(dependencies), py_versions=[version] if version else [], ) diff --git a/src/codemodder/project_analysis/file_parsers/requirements_txt_file_parser.py b/src/codemodder/project_analysis/file_parsers/requirements_txt_file_parser.py index 2ae9d67e..be30ae11 100644 --- a/src/codemodder/project_analysis/file_parsers/requirements_txt_file_parser.py +++ b/src/codemodder/project_analysis/file_parsers/requirements_txt_file_parser.py @@ -28,10 +28,12 @@ def _parse_file(self, file: Path) -> PackageStore | None: logger.debug("Unknown encoding for file: %s", file) return None + dependencies = set(line.strip() for line in lines if not line.startswith("#")) + return PackageStore( type=self.file_type, file=file, - dependencies=set(self._parse_dependencies(lines)), + dependencies=dependencies, # requirements.txt files do not declare py versions explicitly # though we could create a heuristic by analyzing each dependency # and extracting py versions from them. diff --git a/src/codemodder/project_analysis/file_parsers/setup_cfg_file_parser.py b/src/codemodder/project_analysis/file_parsers/setup_cfg_file_parser.py index 9a600acc..5fe84e75 100644 --- a/src/codemodder/project_analysis/file_parsers/setup_cfg_file_parser.py +++ b/src/codemodder/project_analysis/file_parsers/setup_cfg_file_parser.py @@ -26,6 +26,6 @@ def _parse_file(self, file: Path) -> PackageStore | None: return PackageStore( type=self.file_type, file=file, - dependencies=set(self._parse_dependencies(dependency_lines)), + dependencies=set(line for line in dependency_lines if line), py_versions=[python_requires] if python_requires else [], ) diff --git a/src/codemodder/project_analysis/file_parsers/setup_py_file_parser.py b/src/codemodder/project_analysis/file_parsers/setup_py_file_parser.py index 3a9d8b83..057a7a7d 100644 --- a/src/codemodder/project_analysis/file_parsers/setup_py_file_parser.py +++ b/src/codemodder/project_analysis/file_parsers/setup_py_file_parser.py @@ -26,7 +26,7 @@ def _parse_file(self, file: Path) -> PackageStore | None: return PackageStore( type=self.file_type, file=file, - dependencies=set(self._parse_dependencies(visitor.install_requires)), + dependencies=set(visitor.install_requires), py_versions=visitor.python_requires, )