diff --git a/README.rst b/README.rst index 6cb8913..5cdd359 100644 --- a/README.rst +++ b/README.rst @@ -17,7 +17,7 @@ In order to collect project's dependencies, this checker evaluates Python code f `eval() `_ function. As a fall-back method, this checker also tries to load dependencies from the ``pyproject.toml`` file from the `poetry `_ tool section, or from the ``requirements.txt`` -text file. +text file in the project's root directory. At this point it is very important to be aware of the consequences of the above approach. One might inject malicious code into the ``setup.py`` file, which will be executed by this checker. @@ -68,7 +68,11 @@ disabled by default, but user can enable it with the ``--scan-host-site-packages option. Please note, however, that the location of the site-packages directory will be determined by the Python version used for flake8 execution. -If you use the ``-r`` flag in your ``requirements.txt`` file with more than one level of recursion +In order to read requirements from the text file, user shall provide the location of such a file +with the ``--requirements-file`` option. If the given location is not an absolute path, then it +has to be specified as a path relative to the project's root directory. + +If you use the ``-r`` flag in your requirements text file with more than one level of recursion (in other words, one file includes another, the included file includes yet another, and so on), add the ``--requirements-max-depth`` option to flake8 (for example, ``--requirements-max-depth=3`` to allow three levels of recursion). diff --git a/src/flake8_requirements/checker.py b/src/flake8_requirements/checker.py index 729e64a..7e8c0e1 100644 --- a/src/flake8_requirements/checker.py +++ b/src/flake8_requirements/checker.py @@ -17,7 +17,7 @@ from .modules import STDLIB_PY3 # NOTE: Changing this number will alter package version as well. -__version__ = "1.3.3" +__version__ = "1.4.0" __license__ = "MIT" LOG = getLogger('flake8.plugin.requirements') @@ -295,6 +295,9 @@ class Flake8Checker(object): # User defined project->modules mapping. known_modules = {} + # User provided requirements file. + requirements_file = None + # Max depth to resolve recursive requirements. requirements_max_depth = 1 @@ -324,6 +327,18 @@ def add_options(cls, manager): ), **kw ) + manager.add_option( + "--requirements-file", + action='store', + help=( + "Specify the name (location) of the requirements text file. " + "Unless an absolute path is given, the file will be searched " + "relative to the project's root directory. If this option is " + "given, requirements from setup.py or pyproject.toml will not" + " be taken into account." + ), + **kw + ) manager.add_option( "--requirements-max-depth", type="int", @@ -355,6 +370,7 @@ def parse_options(cls, options): for x in re.split(r"],?", options.known_modules)[:-1] ] } + cls.requirements_file = options.requirements_file cls.requirements_max_depth = options.requirements_max_depth if options.scan_host_site_packages: cls.discover_host_3rd_party_modules() @@ -457,12 +473,14 @@ def get_pyproject_toml_poetry(cls): @memoize def get_requirements_txt(cls): """Try to load requirements from text file.""" + path = cls.requirements_file or "requirements.txt" + if not os.path.isabs(path): + path = os.path.join(cls.root_dir, path) try: - path = os.path.join(cls.root_dir, "requirements.txt") return tuple(parse_requirements(cls.resolve_requirement( "-r {}".format(path), cls.requirements_max_depth + 1))) except IOError as e: - LOG.debug("Couldn't load requirements: %s", e) + LOG.error("Couldn't load requirements: %s", e) return () @classmethod @@ -493,6 +511,10 @@ 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: @@ -513,7 +535,7 @@ def get_mods_3rd_party_requirements(self): )) return requirements - # Get requirements from text file. + # Fall-back to requirements.txt in the root directory. return self.get_requirements_txt() @memoize diff --git a/test/test_checker.py b/test/test_checker.py index 1947134..bf6088c 100644 --- a/test/test_checker.py +++ b/test/test_checker.py @@ -34,6 +34,7 @@ def processing_setup_py(self): class Flake8Options: known_modules = "" + requirements_file = None requirements_max_depth = 0 scan_host_site_packages = False diff --git a/test/test_requirements.py b/test/test_requirements.py index 8d49005..7ee20d4 100644 --- a/test/test_requirements.py +++ b/test/test_requirements.py @@ -136,6 +136,28 @@ def test_init_with_no_requirements(self): checker = Flake8Checker(None, None) self.assertEqual(checker.get_requirements_txt(), ()) + def test_init_with_user_requirements(self): + with mock.patch(builtins_open, mock_open_multiple(files=OrderedDict(( + ("requirements/base.txt", "foo >= 1.0.0\n-r inner.txt\n"), + ("requirements/inner.txt", "bar\n"), + )))) as m: + try: + Flake8Checker.requirements_file = "requirements/base.txt" + checker = Flake8Checker(None, None) + self.assertEqual( + checker.get_requirements_txt(), + tuple(parse_requirements([ + "foo", + "bar", + ])), + ) + m.assert_has_calls([ + mock.call("requirements/base.txt"), + mock.call("requirements/inner.txt"), + ]) + finally: + Flake8Checker.requirements_file = None + def test_init_with_simple_requirements(self): with mock.patch(builtins_open, mock_open_multiple(files=OrderedDict(( ("requirements.txt", "foo >= 1.0.0\nbar <= 1.0.0\n"),