From c29f14c3170c71393cf4ab28d1c504448c11b2bf Mon Sep 17 00:00:00 2001 From: Arkadiusz Bokowy Date: Mon, 21 Jun 2021 00:20:05 +0200 Subject: [PATCH] Load requirements from setup configuration file Closes #39 --- LICENSE.txt | 2 +- setup.cfg | 3 + src/flake8_requirements/checker.py | 90 +++++++++++++++++++++--------- test/test_poetry.py | 2 + test/test_setup.cfg | 23 ++++++++ test/test_setup.py | 28 +++++++++- 6 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 test/test_setup.cfg diff --git a/LICENSE.txt b/LICENSE.txt index 0bca8fc..8c49e7c 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2017-2020 Arkadiusz Bokowy +Copyright (c) 2017-2021 Arkadiusz Bokowy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/setup.cfg b/setup.cfg index 2a9acf1..354bc3d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,5 @@ [bdist_wheel] universal = 1 + +[isort] +force_single_line = true diff --git a/src/flake8_requirements/checker.py b/src/flake8_requirements/checker.py index 5e91526..e874795 100644 --- a/src/flake8_requirements/checker.py +++ b/src/flake8_requirements/checker.py @@ -4,6 +4,7 @@ import site import sys from collections import namedtuple +from configparser import ConfigParser from functools import wraps from logging import getLogger @@ -17,7 +18,7 @@ from .modules import STDLIB_PY3 # NOTE: Changing this number will alter package version as well. -__version__ = "1.4.0" +__version__ = "1.5.0" __license__ = "MIT" LOG = getLogger('flake8.plugin.requirements') @@ -530,6 +531,16 @@ def get_pyproject_toml_poetry(cls): cfg_pep518 = cls.get_pyproject_toml() return cfg_pep518.get('tool', {}).get('poetry', {}) + def get_pyproject_toml_poetry_requirements(self): + """Try to get poetry configuration requirements.""" + poetry = self.get_pyproject_toml_poetry() + requirements = [] + requirements.extend(parse_requirements( + poetry.get('dependencies', ()))) + requirements.extend(parse_requirements( + poetry.get('dev-dependencies', ()))) + return requirements + @classmethod @memoize def get_requirements_txt(cls): @@ -544,6 +555,38 @@ def get_requirements_txt(cls): LOG.error("Couldn't load requirements: %s", e) return () + @classmethod + @memoize + def get_setup_cfg(cls): + """Try to load standard configuration file.""" + config = ConfigParser() + config.read_dict({ + 'metadata': {'name': ""}, + 'options': { + 'install_requires': "", + 'setup_requires': "", + 'tests_require': ""}, + 'options.extras_require': {}, + }) + if not config.read(os.path.join(cls.root_dir, "setup.cfg")): + LOG.debug("Couldn't load setup configuration: setup.cfg") + return config + + def get_setup_cfg_requirements(self): + """Try to load standard configuration file requirements.""" + config = self.get_setup_cfg() + requirements = [] + requirements.extend(parse_requirements( + config.get('options', 'install_requires'))) + requirements.extend(parse_requirements( + config.get('options', 'tests_require'))) + for _, r in config.items('options.extras_require'): + requirements.extend(parse_requirements(r)) + setup_requires = config.get('options', 'setup_requires') + if setup_requires and self.processing_setup_py: + requirements.extend(parse_requirements(setup_requires)) + return requirements + @classmethod @memoize def get_setup_py(cls): @@ -555,6 +598,16 @@ def get_setup_py(cls): LOG.debug("Couldn't load project setup: %s", e) return SetupVisitor(ast.parse(""), cls.root_dir) + def get_setup_py_requirements(self): + """Try to load standard setup file requirements.""" + setup = self.get_setup_py() + if not setup.redirected: + return [] + return setup.get_requirements( + setup=self.processing_setup_py, + tests=True, + ) + @classmethod @memoize def get_mods_1st_party(cls): @@ -562,6 +615,7 @@ def get_mods_1st_party(cls): # Get 1st party modules (used for absolute imports). modules = [project2module( cls.get_setup_py().keywords.get('name') or + cls.get_setup_cfg().get('metadata', 'name') or cls.get_pyproject_toml_poetry().get('name') or "")] if modules[0] in cls.known_modules: @@ -572,33 +626,19 @@ def get_mods_1st_party(cls): def get_mods_3rd_party_requirements(self): """Get list of 3rd party requirements.""" - # Use user provided requirements text file. if self.requirements_file: return self.get_requirements_txt() - - # Use requirements from setup if available. - cfg_setup = self.get_setup_py() - if cfg_setup.redirected: - return cfg_setup.get_requirements( - setup=self.processing_setup_py, - tests=True, - ) - - # Check project configuration for requirements. - cfg_poetry = self.get_pyproject_toml_poetry() - if cfg_poetry: - requirements = [] - requirements.extend(parse_requirements( - cfg_poetry.get('dependencies', ()), - )) - requirements.extend(parse_requirements( - cfg_poetry.get('dev-dependencies', ()), - )) - return requirements - - # Fall-back to requirements.txt in the root directory. - return self.get_requirements_txt() + return ( + # Use requirements from setup if available. + self.get_setup_py_requirements() or + # Check setup configuration file for requirements. + self.get_setup_cfg_requirements() or + # Check project configuration for requirements. + self.get_pyproject_toml_poetry_requirements() or + # Fall-back to requirements.txt in our root directory. + self.get_requirements_txt() + ) @memoize def get_mods_3rd_party(self): diff --git a/test/test_poetry.py b/test/test_poetry.py index 01979e0..b94da42 100644 --- a/test/test_poetry.py +++ b/test/test_poetry.py @@ -29,6 +29,7 @@ def test_1st_party(self): with mock.patch(builtins_open, mock.mock_open()) as m: m.side_effect = ( IOError("No such file or directory: 'setup.py'"), + IOError("No such file or directory: 'setup.cfg'"), mock.mock_open(read_data=content).return_value, ) @@ -43,6 +44,7 @@ def test_3rd_party(self): with mock.patch(builtins_open, mock.mock_open()) as m: m.side_effect = ( IOError("No such file or directory: 'setup.py'"), + IOError("No such file or directory: 'setup.cfg'"), mock.mock_open(read_data=content).return_value, ) diff --git a/test/test_setup.cfg b/test/test_setup.cfg new file mode 100644 index 0000000..b6a76f2 --- /dev/null +++ b/test/test_setup.cfg @@ -0,0 +1,23 @@ +[metadata] +name = my_package +description = My package description +license = BSD 3-Clause License +classifiers = + License :: OSI Approved :: BSD License + Programming Language :: Python :: 3 + +[options] +packages = find: +install_requires = + requests + importlib; python_version == "2.6" +tests_require = + pytest + +[options.package_data] +* = *.txt, *.rst +hello = *.msg + +[options.extras_require] +pdf = ReportLab>=1.2 +rest = docutils>=0.3 diff --git a/test/test_setup.py b/test/test_setup.py index 8390c56..0dbd710 100644 --- a/test/test_setup.py +++ b/test/test_setup.py @@ -1,12 +1,21 @@ import ast +import os import unittest from pkg_resources import parse_requirements +from flake8_requirements.checker import Flake8Checker from flake8_requirements.checker import SetupVisitor +try: + from unittest import mock + builtins_open = 'builtins.open' +except ImportError: + import mock + builtins_open = '__builtin__.open' -class Flake8CheckerTestCase(unittest.TestCase): + +class SetupTestCase(unittest.TestCase): def test_detect_setup(self): code = "setup({})".format(",".join(( @@ -65,3 +74,20 @@ def test_get_requirements(self): "extra < 10", ]), key=lambda x: x.project_name), ) + + def test_get_setup_cfg_requirements(self): + curdir = os.path.abspath(os.path.dirname(__file__)) + with open(os.path.join(curdir, "test_setup.cfg")) as f: + content = f.read() + with mock.patch(builtins_open, mock.mock_open(read_data=content)): + checker = Flake8Checker(None, None) + self.assertEqual( + checker.get_setup_cfg_requirements(), + list(parse_requirements([ + "requests", + "importlib; python_version == \"2.6\"", + "pytest", + "ReportLab>=1.2", + "docutils>=0.3", + ])), + )