Skip to content

Commit

Permalink
Parse requirements file with version specifiers
Browse files Browse the repository at this point in the history
This commit also adds support for VCSs and local files. See pip online
documentation for reference:
https://pip.pypa.io/en/stable/reference/pip_install/#requirements-file-format
  • Loading branch information
arkq committed Apr 1, 2021
1 parent 569de73 commit f399728
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 18 deletions.
52 changes: 46 additions & 6 deletions src/flake8_requirements/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,15 +418,32 @@ def discover_project_root_dir(cls):
break
root_dir = os.path.abspath(os.path.join(root_dir, ".."))

_requirement_match_option = re.compile(
r"(-[\w-]+)(.*)").match

_requirement_match_spec = re.compile(
r"(.*?)\s+--(global-option|install-option|hash)").match

_requirement_match_archive = re.compile(
r"(.*)(\.(tar(\.(bz2|gz|lz|lzma|xz))?|tbz|tgz|tlz|txz|whl|zip))",
re.IGNORECASE).match
_requirement_match_archive_spec = re.compile(
r"(\w+)(-[^-]+)?").match

_requirement_match_vcs = re.compile(
r"(git|hg|svn|bzr)\+(.*)").match
_requirement_match_vcs_spec = re.compile(
r".*egg=([\w\-\.]+)").match

@classmethod
def resolve_requirement(cls, requirement, max_depth=0, path=None):
"""Resolves flags like -r in an individual requirement line."""

option = None
option_matcher = re.match(r"(-[\w-]+)(.*)", requirement)
if option_matcher:
option = option_matcher.group(1)
requirement = option_matcher.group(2).lstrip()
option_match = cls._requirement_match_option(requirement)
if option_match is not None:
option = option_match.group(1)
requirement = option_match.group(2).lstrip()

if option in ("-e", "--editable"):
# We do not care about installation mode.
Expand All @@ -449,8 +466,31 @@ def resolve_requirement(cls, requirement, max_depth=0, path=None):
# Skip whole line if option was not processed earlier.
return []

# Extract requirement name (skip comments, options, etc.).
return [re.split(r"[^\w.-]+", requirement, 1)[0]]
# Check for a requirement given as a VSC link.
vcs_match = cls._requirement_match_vcs(requirement)
vcs_spec_match = cls._requirement_match_vcs_spec(
vcs_match.group(2) if vcs_match is not None else "")
if vcs_spec_match is not None:
return [vcs_spec_match.group(1)]

# Check for a requirement given as a local archive file.
archive_ext_match = cls._requirement_match_archive(requirement)
if archive_ext_match is not None:
base = os.path.basename(archive_ext_match.group(1))
archive_spec_match = cls._requirement_match_archive_spec(base)
if archive_spec_match is not None:
name, version = archive_spec_match.groups()
return [
name if not version else
"{} == {}".format(name, version[1:])
]

# Extract requirement specifier (skip in-line options).
spec_match = cls._requirement_match_spec(requirement)
if spec_match is not None:
requirement = spec_match.group(1)

return [requirement.strip()]

@classmethod
@memoize
Expand Down
50 changes: 38 additions & 12 deletions test/test_requirements.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import unittest
from collections import OrderedDict

Expand Down Expand Up @@ -39,13 +40,13 @@ def setUp(self):
def test_resolve_requirement(self):
self.assertEqual(
Flake8Checker.resolve_requirement("foo >= 1.0.0"),
["foo"],
["foo >= 1.0.0"],
)

def test_resolve_requirement_with_option(self):
self.assertEqual(
Flake8Checker.resolve_requirement("foo-bar.v1==1.0 --option"),
["foo-bar.v1"],
Flake8Checker.resolve_requirement("foo-bar.v1==1.0 --hash=md5:."),
["foo-bar.v1==1.0"],
)

def test_resolve_requirement_standalone_option(self):
Expand All @@ -72,7 +73,7 @@ def test_resolve_requirement_with_file_content(self):
)))):
self.assertEqual(
Flake8Checker.resolve_requirement("-r requirements.txt", 1),
["foo", "bar"],
["foo >= 1.0.0", "bar <= 1.0.0"],
)

def test_resolve_requirement_with_file_content_line_continuation(self):
Expand All @@ -81,7 +82,7 @@ def test_resolve_requirement_with_file_content_line_continuation(self):
)))):
self.assertEqual(
Flake8Checker.resolve_requirement("-r requirements.txt", 1),
["foo"],
["foo[bar] >= 1.0.0"],
)

def test_resolve_requirement_with_file_content_line_continuation_2(self):
Expand All @@ -90,7 +91,7 @@ def test_resolve_requirement_with_file_content_line_continuation_2(self):
)))):
self.assertEqual(
Flake8Checker.resolve_requirement("-r requirements.txt", 1),
["foo", "bar"],
["foo >= 1.0.0", "bar"],
)

def test_resolve_requirement_with_file_recursion_beyond_max_depth(self):
Expand All @@ -107,7 +108,7 @@ def test_resolve_requirement_with_file_recursion(self):
)))):
self.assertEqual(
Flake8Checker.resolve_requirement("-r requirements.txt", 2),
["baz", "qux", "bar"],
["baz", "qux", "bar <= 1.0.0"],
)

def test_resolve_requirement_with_relative_include(self):
Expand Down Expand Up @@ -147,7 +148,7 @@ def test_init_with_user_requirements(self):
self.assertEqual(
checker.get_requirements_txt(),
tuple(parse_requirements([
"foo",
"foo >= 1.0.0",
"bar",
])),
)
Expand All @@ -166,8 +167,8 @@ def test_init_with_simple_requirements(self):
self.assertEqual(
checker.get_requirements_txt(),
tuple(parse_requirements([
"foo",
"bar",
"foo >= 1.0.0",
"bar <= 1.0.0",
])),
)

Expand All @@ -193,9 +194,34 @@ def test_init_with_recursive_requirements(self):
self.assertEqual(
checker.get_requirements_txt(),
tuple(parse_requirements([
"foo",
"foo >= 1.0.0",
"baz",
"qux",
"bar",
"bar <= 1.0.0",
])),
)

def test_init_misc(self):
curdir = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(curdir, "test_requirements.txt")) as f:
requirements_content = f.read()
with mock.patch(builtins_open, mock_open_multiple(files=OrderedDict((
("requirements.txt", requirements_content),
)))):
checker = Flake8Checker(None, None)
self.assertEqual(
checker.get_requirements_txt(),
tuple(parse_requirements([
"nose",
"apache == 0.6.9",
"coverage[graph,test] ~= 3.1",
"graph <2.0, >=1.2 ; python_version < '3.8'",
"foo-project >= 1.2",
"bar-project == 8.8",
"configuration",
"blackBox == 1.4.4",
"exPackage_paint == 1.4.8.dev1984+49a8814",
"package-one",
"package-two",
])),
)
27 changes: 27 additions & 0 deletions test/test_requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
####### requirements.txt #######

###### Requirements without Version Specifiers ######
nose

###### Requirements with Version Specifiers ######
apache == 0.6.9 # Version Matching. Must be version 0.6.9
coverage[test, graph] ~= 3.1 # Compatible release. Same as >= 3.1, == 3.*
graph >=1.2, <2.0 ; python_version < '3.8'

###### Global options ######
--find-links http://some.archives.com/archives
--no-index

###### Requirements with in-line options ######
foo-project >= 1.2 --install-option="--prefix=/usr/local --no-compile"
bar-project == 8.8 --hash=sha256:cecb534b7d0022683d030b048a2d679c6ff3df969fd7b847027f1ed8d739ac8c \
--hash=md5:a540092b44178949e8d63ddd7a74f95d

###### Requirements from a particular file ######
/opt/configuration.tar.gz # Local configuration
/opt/blackBox-1.4.4-cp34-none-win32.whl # Local proprietary package
http://example.com/snapshot-builds/exPackage_paint-1.4.8.dev1984+49a8814-cp34-none-win_amd64.whl

###### Requirements from a VCS ######
git+git://github.com/path/to/package-one@releases/tag/v3.1.4#egg=package-one
git+git://github.com/path/to/package-two@master#egg=package-two&subdirectory=src

0 comments on commit f399728

Please sign in to comment.