Permalink
Please sign in to comment.
Showing
with
535 additions
and 0 deletions.
- +2 −0 .coveragerc
- +12 −0 .gitignore
- +42 −0 README.md
- +1 −0 examples/requirements.py
- +1 −0 examples/requirements.txt
- +1 −0 examples/requirements/base.txt
- +3 −0 examples/requirements/tests.txt
- +228 −0 requirements.py
- +228 −0 tests/test_base.py
- +17 −0 tox.ini
2
.coveragerc
@@ -0,0 +1,2 @@ | ||
+[report] | ||
+include = requirements.py |
12
.gitignore
@@ -0,0 +1,12 @@ | ||
+dist | ||
+build | ||
+*.pyc | ||
+*.egg-info | ||
+__pycache__ | ||
+setup.py | ||
+.tox | ||
+htmlcov | ||
+.cache | ||
+.coverage | ||
+examples/dist | ||
+examples/build |
@@ -0,0 +1,42 @@ | ||
+# Requirements | ||
+ | ||
+*☛ Python requirements for Humans ™* | ||
+ | ||
+Write your adorable `requirements.txt` once and forget `setup.py` hassles. | ||
+ | ||
+```python | ||
+from setuptools import setup | ||
+from requirements import r | ||
+ | ||
+setup( | ||
+ name='your-package', | ||
+ version='0.0.1', | ||
+ **r.requirements) | ||
+``` | ||
+ | ||
+### Features | ||
+ | ||
+* Requirements discovery | ||
+* Manage `dependency_links` and `tests_require` | ||
+* Just drop `requirements.py` in your package directory | ||
+* Works well with [pip-tools](https://github.com/nvie/pip-tools) | ||
+* Configurable for different requirements layout | ||
+* Python `2.7`, `3.3`, `3.4`, `3.5` | ||
+* Very light, well tested, no dependencies and more! | ||
+ | ||
+ | ||
+### Usage | ||
+ | ||
+* Download latest `requirements.py` release in your package root directory | ||
+* Import it in your `setup.py`, like in previous example | ||
+ | ||
+Some variables are configurable like that: | ||
+ | ||
+```python | ||
+from requirements import r | ||
+ | ||
+r.requirements_path = 'reqs.txt' | ||
+r.tests_requirements_path = 'reqs-tests.txt' | ||
+``` | ||
+ | ||
+License is `MIT`. |
1
examples/requirements.py
@@ -0,0 +1 @@ | ||
+../requirements.py |
1
examples/requirements.txt
@@ -0,0 +1 @@ | ||
+-r requirements/base.txt |
1
examples/requirements/base.txt
@@ -0,0 +1 @@ | ||
+requests>=2.9.1 |
3
examples/requirements/tests.txt
@@ -0,0 +1,3 @@ | ||
+-r base.txt | ||
+ | ||
+flake8 |
228
requirements.py
@@ -0,0 +1,228 @@ | ||
+import os | ||
+import re | ||
+import logging | ||
+import warnings | ||
+from pkg_resources import Requirement as Req | ||
+ | ||
+try: | ||
+ from urllib.parse import urlparse | ||
+except ImportError: | ||
+ from urlparse import urlparse | ||
+ | ||
+ | ||
+__version__ = '0.1.0' | ||
+ | ||
+logging.basicConfig(level=logging.WARNING) | ||
+ | ||
+VCS = ['git', 'hg', 'svn', 'bzr'] | ||
+ | ||
+ | ||
+class Requirement(object): | ||
+ """ | ||
+ This class is inspired from | ||
+ https://github.com/davidfischer/requirements-parser/blob/master/requirements/requirement.py#L30 | ||
+ License: BSD | ||
+ """ | ||
+ def __init__(self, line): | ||
+ self.line = line | ||
+ self.is_editable = False | ||
+ self.is_local_file = False | ||
+ self.is_specifier = False | ||
+ self.vcs = None | ||
+ self.name = None | ||
+ self.uri = None | ||
+ self.full_uri = None | ||
+ self.path = None | ||
+ self.revision = None | ||
+ self.scheme = None | ||
+ self.login = None | ||
+ self.extras = [] | ||
+ self.specs = [] | ||
+ | ||
+ def __repr__(self): | ||
+ return '<Requirement: "{0}">'.format(self.line) | ||
+ | ||
+ @classmethod | ||
+ def parse(cls, line, editable=False): | ||
+ """ | ||
+ Parses a Requirement from an "editable" requirement which is either | ||
+ a local project path or a VCS project URI. | ||
+ | ||
+ See: pip/req.py:from_editable() | ||
+ | ||
+ :param line: an "editable" requirement | ||
+ :returns: a Requirement instance for the given line | ||
+ :raises: ValueError on an invalid requirement | ||
+ """ | ||
+ if editable: | ||
+ req = cls('-e {0}'.format(line)) | ||
+ req.is_editable = True | ||
+ else: | ||
+ req = cls(line) | ||
+ | ||
+ url = urlparse(line) | ||
+ req.uri = None | ||
+ if url.scheme: | ||
+ req.scheme = url.scheme | ||
+ req.uri = url.scheme + '://' + url.netloc + url.path | ||
+ fragment = url.fragment.split(' ')[0].strip() | ||
+ req.name = fragment.split('egg=')[-1] or None | ||
+ req.path = url.path | ||
+ if fragment: | ||
+ req.uri += '#{}'.format(fragment) | ||
+ if url.username or url.password: | ||
+ username = url.username or '' | ||
+ password = url.password or '' | ||
+ req.login = username + ':' + password | ||
+ if '@' in url.path: | ||
+ req.revision = url.path.split('@')[-1] | ||
+ | ||
+ for vcs in VCS: | ||
+ if req.uri.startswith(vcs): | ||
+ req.vcs = vcs | ||
+ if req.scheme.startswith('file://'): | ||
+ req.is_local_file = True | ||
+ | ||
+ if not req.vcs and not req.is_local_file and 'egg=' not in line: | ||
+ # This is a requirement specifier. | ||
+ # Delegate to pkg_resources and hope for the best | ||
+ req.is_specifier = True | ||
+ pkg_req = Req.parse(line) | ||
+ req.name = pkg_req.unsafe_name | ||
+ req.extras = list(pkg_req.extras) | ||
+ req.specs = pkg_req.specs | ||
+ if req.specs: | ||
+ req.specs = sorted(req.specs) | ||
+ | ||
+ return req | ||
+ | ||
+ | ||
+class Requirements: | ||
+ def __init__( | ||
+ self, | ||
+ requirements="requirements.txt", | ||
+ tests_requirements="requirements/tests.txt"): | ||
+ self.requirements_path = requirements | ||
+ self.tests_requirements_path = tests_requirements | ||
+ | ||
+ def format_specifiers(self, requirement): | ||
+ return ', '.join( | ||
+ ['{} {}'.format(s[0], s[1]) for s in requirement.specs]) | ||
+ | ||
+ @property | ||
+ def install_requires(self): | ||
+ dependencies = [] | ||
+ for requirement in self.parse(self.requirements_path): | ||
+ if not requirement.is_editable and not requirement.uri \ | ||
+ and not requirement.vcs: | ||
+ full_name = requirement.name | ||
+ specifiers = self.format_specifiers(requirement) | ||
+ if specifiers: | ||
+ full_name = "{} {}".format(full_name, specifiers) | ||
+ dependencies.append(full_name) | ||
+ for requirement in self.get_dependency_links(): | ||
+ print(":: (base:install_requires) {}".format(requirement.name)) | ||
+ dependencies.append(requirement.name) | ||
+ return dependencies | ||
+ | ||
+ @property | ||
+ def tests_require(self): | ||
+ dependencies = [] | ||
+ for requirement in self.parse(self.tests_requirements_path): | ||
+ if not requirement.is_editable and not requirement.uri \ | ||
+ and not requirement.vcs: | ||
+ full_name = requirement.name | ||
+ specifiers = self.format_specifiers(requirement) | ||
+ if specifiers: | ||
+ full_name = "{} {}".format(full_name, specifiers) | ||
+ print(":: (tests:tests_require) {}".format(full_name)) | ||
+ dependencies.append(full_name) | ||
+ return dependencies | ||
+ | ||
+ @property | ||
+ def dependency_links(self): | ||
+ dependencies = [] | ||
+ for requirement in self.parse(self.requirements_path): | ||
+ if requirement.uri or requirement.vcs or requirement.path: | ||
+ print(":: (base:dependency_links) {}".format( | ||
+ requirement.uri)) | ||
+ dependencies.append(requirement.uri) | ||
+ return dependencies | ||
+ | ||
+ @property | ||
+ def dependencies(self): | ||
+ install_requires = self.install_requires | ||
+ dependency_links = self.dependency_links | ||
+ tests_require = self.tests_require | ||
+ if dependency_links: | ||
+ print( | ||
+ "\n" | ||
+ "!! Some dependencies are linked to repository or local path.") | ||
+ print( | ||
+ "!! You'll need to run pip with following option: " | ||
+ "`--process-dependency-links`" | ||
+ "\n") | ||
+ return { | ||
+ 'install_requires': install_requires, | ||
+ 'dependency_links': dependency_links, | ||
+ 'tests_require': tests_require} | ||
+ | ||
+ def get_dependency_links(self): | ||
+ dependencies = [] | ||
+ for requirement in self.parse(self.requirements_path): | ||
+ if requirement.uri or requirement.vcs or requirement.path: | ||
+ dependencies.append(requirement) | ||
+ return dependencies | ||
+ | ||
+ def parse(self, path=None): | ||
+ path = path or self.requirements_path | ||
+ path = os.path.abspath(path) | ||
+ base_directory = os.path.dirname(path) | ||
+ | ||
+ if not os.path.exists(path): | ||
+ warnings.warn( | ||
+ 'Requirements file: {} does not exists.'.format(path)) | ||
+ return | ||
+ | ||
+ with open(path) as requirements: | ||
+ for index, line in enumerate(requirements.readlines()): | ||
+ index += 1 | ||
+ line = line.strip() | ||
+ if not line: | ||
+ logging.debug('Empty line (line {} from {})'.format( | ||
+ index, path)) | ||
+ continue | ||
+ elif line.startswith('#'): | ||
+ logging.debug( | ||
+ 'Comments line (line {} from {})'.format(index, path)) | ||
+ elif line.startswith('-f') or \ | ||
+ line.startswith('--find-links') or \ | ||
+ line.startswith('-i') or \ | ||
+ line.startswith('--index-url') or \ | ||
+ line.startswith('--extra-index-url') or \ | ||
+ line.startswith('--no-index'): | ||
+ warnings.warn('Private repos not supported. Skipping.') | ||
+ continue | ||
+ elif line.startswith('-Z') or line.startswith( | ||
+ '--always-unzip'): | ||
+ warnings.warn('Unused option --always-unzip. Skipping.') | ||
+ continue | ||
+ elif line.startswith('-r') or line.startswith('--requirement'): | ||
+ logging.debug( | ||
+ 'Pining to another requirements file ' | ||
+ '(line {} from {})'.format(index, path)) | ||
+ for _line in self.parse(path=os.path.join( | ||
+ base_directory, line.split()[1])): | ||
+ yield _line | ||
+ elif line.startswith('-e') or line.startswith('--editable'): | ||
+ # Editable installs are either a local project path | ||
+ # or a VCS project URI | ||
+ yield Requirement.parse( | ||
+ re.sub(r'^(-e|--editable=?)\s*', '', line), | ||
+ editable=True) | ||
+ else: | ||
+ logging.debug('Found "{}" (line {} from {})'.format( | ||
+ line, index, path)) | ||
+ yield Requirement.parse(line, editable=False) | ||
+ | ||
+r = Requirements() |
228
tests/test_base.py
@@ -0,0 +1,228 @@ | ||
+import os | ||
+import sys | ||
+import shutil | ||
+import tempfile | ||
+from unittest import TestCase | ||
+ | ||
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) | ||
+ | ||
+from requirements import Requirement | ||
+from requirements import Requirements | ||
+ | ||
+ORIGINAL_DIRECTORY = os.getcwd() | ||
+ | ||
+ | ||
+class RequirementsTestCase(TestCase): | ||
+ def setUp(self): | ||
+ self.root_directory = tempfile.mkdtemp() | ||
+ os.chdir(self.root_directory) | ||
+ | ||
+ self.r = Requirements() | ||
+ | ||
+ def tearDown(self): | ||
+ os.chdir(ORIGINAL_DIRECTORY) | ||
+ shutil.rmtree(self.root_directory) | ||
+ | ||
+ def test_requirement_repr(self): | ||
+ r = Requirement.parse('requests==2.9.1') | ||
+ self.assertEqual(r.__repr__(), '<Requirement: "requests==2.9.1">') | ||
+ | ||
+ def test_requirement_parsing(self): | ||
+ line = ' requests==2.9.1,>=2.8.1 # jambon' | ||
+ r = Requirement.parse(line) | ||
+ self.assertEqual(r.line, line) | ||
+ self.assertEqual(r.name, 'requests') | ||
+ self.assertEqual(r.specs, [('==', '2.9.1'), ('>=', '2.8.1')]) | ||
+ | ||
+ def test_detect_files(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements.txt') | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('requests==2.9.1\n') | ||
+ | ||
+ os.mkdir(os.path.join(self.root_directory, 'requirements')) | ||
+ tests_requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements', 'tests.txt') | ||
+ | ||
+ with open(tests_requirements_path, 'w') as f: | ||
+ f.write('flake8==2.5.4\n') | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual(dependencies['tests_require'], ['flake8 == 2.5.4']) | ||
+ self.assertEqual( | ||
+ dependencies['install_requires'], ['requests == 2.9.1']) | ||
+ self.assertEqual(dependencies['dependency_links'], []) | ||
+ | ||
+ def test_different_paths(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'foo.txt') | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('requests==2.9.1\n') | ||
+ | ||
+ os.mkdir(os.path.join(self.root_directory, 'moar-requirements')) | ||
+ tests_requirements_path = os.path.join( | ||
+ self.root_directory, 'moar-requirements', 'bar.txt') | ||
+ | ||
+ with open(tests_requirements_path, 'w') as f: | ||
+ f.write('flake8==2.5.4\n') | ||
+ | ||
+ self.r.requirements_path = requirements_path | ||
+ self.r.tests_requirements_path = tests_requirements_path | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual(dependencies['tests_require'], ['flake8 == 2.5.4']) | ||
+ self.assertEqual( | ||
+ dependencies['install_requires'], ['requests == 2.9.1']) | ||
+ self.assertEqual(dependencies['dependency_links'], []) | ||
+ | ||
+ def test_empty_lines(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements.txt') | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('\n\n') | ||
+ f.write('requests==2.9.1 #I like ham\n') | ||
+ f.write('\n') | ||
+ f.write('boto') | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual( | ||
+ sorted(dependencies['install_requires']), | ||
+ sorted(['requests == 2.9.1', 'boto'])) | ||
+ | ||
+ def test_comments_line_ignored(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements.txt') | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('# boto==python3-lol\n') | ||
+ f.write('requests==2.9.1 #I like ham\n') | ||
+ | ||
+ os.mkdir(os.path.join(self.root_directory, 'requirements')) | ||
+ tests_requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements', 'tests.txt') | ||
+ | ||
+ with open(tests_requirements_path, 'w') as f: | ||
+ f.write('flake8==2.5.4\n') | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual(dependencies['tests_require'], ['flake8 == 2.5.4']) | ||
+ self.assertEqual(dependencies['install_requires'], ['requests == 2.9.1']) | ||
+ self.assertEqual(dependencies['dependency_links'], []) | ||
+ | ||
+ def test_multi_specifiers(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements.txt') | ||
+ tests_requirements_path = os.path.join( | ||
+ self.root_directory, 'tests-requirements.txt') | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('requests<=2.9.1,>=2.8.5') | ||
+ | ||
+ with open(tests_requirements_path, 'w') as f: | ||
+ f.write('requests <= 2.9.1 , >=2.8.5') | ||
+ | ||
+ self.r.tests_requirements_path = 'tests-requirements.txt' | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual( | ||
+ dependencies['install_requires'], ['requests <= 2.9.1, >= 2.8.5']) | ||
+ self.assertEqual( | ||
+ dependencies['tests_require'], ['requests <= 2.9.1, >= 2.8.5']) | ||
+ | ||
+ def test_ignore_every_private_links(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements.txt') | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('--no-index --find-links=/tmp/wheelhouse SomePackage\n') | ||
+ f.write('--find-links=/tmp/wheelhouse SomePackage\n') | ||
+ f.write('-f /tmp/wheelhouse SomePackage\n') | ||
+ f.write('--extra-index-url http://foo.bar SomePackage\n') | ||
+ f.write('-i http://foo.bar SomePackage\n') | ||
+ f.write('requests\n') | ||
+ f.write('--index-url http://foo.bar SomePackage\n') | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual(dependencies['install_requires'], ['requests']) | ||
+ | ||
+ def test_ignore_arguments(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements.txt') | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('requests\n') | ||
+ f.write('--always-unzip SomePackage\n') | ||
+ f.write('-Z SomePackage\n') | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual(dependencies['install_requires'], ['requests']) | ||
+ | ||
+ def test_requirements_inception(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements.txt') | ||
+ requirements_path_02 = os.path.join( | ||
+ self.root_directory, 'requirements-02.txt') | ||
+ requirements_path_03 = os.path.join( | ||
+ self.root_directory, 'requirements', 'requirements-03.txt') | ||
+ | ||
+ os.mkdir(os.path.join(self.root_directory, 'requirements')) | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('requests\n') | ||
+ f.write('-r requirements-02.txt\n') | ||
+ | ||
+ with open(requirements_path_02, 'w') as f: | ||
+ f.write('boto\n') | ||
+ f.write('--requirement requirements/requirements-03.txt\n') | ||
+ | ||
+ with open(requirements_path_03, 'w') as f: | ||
+ f.write('isit==0.1.0\n') | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual( | ||
+ sorted(dependencies['install_requires']), | ||
+ sorted(['requests', 'boto', 'isit == 0.1.0'])) | ||
+ | ||
+ def test_dependency_links(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements.txt') | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('boto\n') | ||
+ f.write('-e git+https://github.com/kennethreitz/requests.git@master#egg=requests\n') | ||
+ f.write('-e svn+http://foo:bar@svn.myproject.org/svn/MyProject/trunk@2019#egg=foo01 # COMMENT\n') | ||
+ f.write('-e git+ssh://git@myproject.org/MyProject/#egg=foo02\n') | ||
+ f.write('-e hg+http://hg.myproject.org/MyProject/@da39a3ee5e6b#egg=foo03\n') | ||
+ f.write('-e bzr+https://bzr.myproject.org/MyProject/trunk/@2019#egg=foo04\n') | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual( | ||
+ sorted(dependencies['install_requires']), | ||
+ sorted(['requests', 'boto', 'foo01', 'foo02', 'foo03', 'foo04'])) | ||
+ self.assertEqual( | ||
+ sorted(dependencies['dependency_links']), | ||
+ sorted([ | ||
+ 'git+https://github.com/kennethreitz/requests.git@master#egg=requests', | ||
+ 'svn+http://foo:bar@svn.myproject.org/svn/MyProject/trunk@2019#egg=foo01', | ||
+ 'git+ssh://git@myproject.org/MyProject/#egg=foo02', | ||
+ 'hg+http://hg.myproject.org/MyProject/@da39a3ee5e6b#egg=foo03', | ||
+ 'bzr+https://bzr.myproject.org/MyProject/trunk/@2019#egg=foo04'])) | ||
+ | ||
+ def test_http_link(self): | ||
+ requirements_path = os.path.join( | ||
+ self.root_directory, 'requirements.txt') | ||
+ | ||
+ with open(requirements_path, 'w') as f: | ||
+ f.write('boto\n') | ||
+ f.write('http://someserver.org/packages/MyPackage-3.0.tar.gz#egg=foo\n') | ||
+ | ||
+ dependencies = self.r.dependencies | ||
+ self.assertEqual( | ||
+ sorted(dependencies['install_requires']), sorted(['boto', 'foo'])) | ||
+ self.assertEqual( | ||
+ sorted(dependencies['dependency_links']), | ||
+ sorted([ | ||
+ 'http://someserver.org/packages/MyPackage-3.0.tar.gz#egg=foo'])) |
17
tox.ini
@@ -0,0 +1,17 @@ | ||
+[tox] | ||
+envlist = py27,py33,py34,py35 | ||
+skipsdist = True | ||
+ | ||
+[testenv] | ||
+deps = | ||
+ pytest | ||
+ flake8 | ||
+commands = | ||
+ py.test tests | ||
+ flake8 requirements.py | ||
+ | ||
+[flake8] | ||
+exclude = tests/* | ||
+ | ||
+[pytest] | ||
+testpaths = tests |
0 comments on commit
097d52e