Commit 6344e7a8 by Sébastien Eustace

Add support for local files as dependencies

parent c2bb760a
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
- Added support for Python 2.7. - Added support for Python 2.7.
- Added a fallback mechanism (opt-in) for missing dependencies. - Added a fallback mechanism (opt-in) for missing dependencies.
- Added `search` command. - Added `search` command.
- Added support for local files as dependencies.
### Changes ### Changes
......
...@@ -59,7 +59,7 @@ class PipInstaller(BaseInstaller): ...@@ -59,7 +59,7 @@ class PipInstaller(BaseInstaller):
return self._venv.run('pip', *args, **kwargs) return self._venv.run('pip', *args, **kwargs)
def requirement(self, package, formatted=False): def requirement(self, package, formatted=False):
if formatted and not package.source_type == 'git': if formatted and not package.source_type:
req = '{}=={}'.format(package.name, package.version) req = '{}=={}'.format(package.name, package.version)
for h in package.hashes: for h in package.hashes:
req += ' --hash sha256:{}'.format(h) req += ' --hash sha256:{}'.format(h)
...@@ -68,6 +68,9 @@ class PipInstaller(BaseInstaller): ...@@ -68,6 +68,9 @@ class PipInstaller(BaseInstaller):
return req return req
if package.source_type == 'file':
return os.path.realpath(package.source_reference)
if package.source_type == 'git': if package.source_type == 'git':
return 'git+{}@{}#egg={}'.format( return 'git+{}@{}#egg={}'.format(
package.source_url, package.source_url,
......
...@@ -75,6 +75,9 @@ ...@@ -75,6 +75,9 @@
}, },
{ {
"$ref": "#/definitions/git-dependency" "$ref": "#/definitions/git-dependency"
},
{
"$ref": "#/definitions/file-dependency"
} }
] ]
} }
...@@ -95,6 +98,9 @@ ...@@ -95,6 +98,9 @@
}, },
{ {
"$ref": "#/definitions/git-dependency" "$ref": "#/definitions/git-dependency"
},
{
"$ref": "#/definitions/file-dependency"
} }
] ]
} }
...@@ -157,7 +163,7 @@ ...@@ -157,7 +163,7 @@
}, },
"python": { "python": {
"type": "string", "type": "string",
"description": "The python versions for c=which the dependency should be installed." "description": "The python versions for which the dependency should be installed."
}, },
"allows_prereleases": { "allows_prereleases": {
"type": "boolean", "type": "boolean",
...@@ -200,7 +206,37 @@ ...@@ -200,7 +206,37 @@
}, },
"python": { "python": {
"type": "string", "type": "string",
"description": "The python versions for c=which the dependency should be installed." "description": "The python versions for which the dependency should be installed."
},
"allows_prereleases": {
"type": "boolean",
"description": "Whether the dependency allows prereleases or not."
},
"optional": {
"type": "boolean",
"description": "Whether the dependency is optional or not."
},
"extras": {
"type": "array",
"description": "The required extras for this dependency.",
"items": {
"type": "string"
}
}
}
},
"file-dependency": {
"type": "object",
"required": ["file"],
"additionalProperties": false,
"properties": {
"file": {
"type": "string",
"description": "The path to the file."
},
"python": {
"type": "string",
"description": "The python versions for which the dependency should be installed."
}, },
"allows_prereleases": { "allows_prereleases": {
"type": "boolean", "type": "boolean",
......
...@@ -43,7 +43,7 @@ setup(**setup_kwargs) ...@@ -43,7 +43,7 @@ setup(**setup_kwargs)
PKG_INFO = """\ PKG_INFO = """\
Metadata-Version: 1.1 Metadata-Version: 2.1
Name: {name} Name: {name}
Version: {version} Version: {version}
Summary: {summary} Summary: {summary}
...@@ -96,14 +96,7 @@ class SdistBuilder(Builder): ...@@ -96,14 +96,7 @@ class SdistBuilder(Builder):
tar_info.size = len(setup) tar_info.size = len(setup)
tar.addfile(tar_info, BytesIO(setup)) tar.addfile(tar_info, BytesIO(setup))
pkg_info = encode(PKG_INFO.format( pkg_info = self.build_pkg_info()
name=self._meta.name,
version=self._meta.version,
summary=self._meta.summary,
home_page=self._meta.home_page,
author=to_str(self._meta.author),
author_email=to_str(self._meta.author_email),
))
tar_info = tarfile.TarInfo(pjoin(tar_dir, 'PKG-INFO')) tar_info = tarfile.TarInfo(pjoin(tar_dir, 'PKG-INFO'))
tar_info.size = len(pkg_info) tar_info.size = len(pkg_info)
...@@ -172,6 +165,35 @@ class SdistBuilder(Builder): ...@@ -172,6 +165,35 @@ class SdistBuilder(Builder):
after='\n'.join(after) after='\n'.join(after)
)) ))
def build_pkg_info(self):
pkg_info = PKG_INFO.format(
name=self._meta.name,
version=self._meta.version,
summary=self._meta.summary,
home_page=self._meta.home_page,
author=to_str(self._meta.author),
author_email=to_str(self._meta.author_email),
)
if self._meta.keywords:
pkg_info += "Keywords: {}\n".format(self._meta.keywords)
if self._meta.requires_python:
pkg_info += 'Requires-Python: {}\n'.format(
self._meta.requires_python
)
for classifier in self._meta.classifiers:
pkg_info += 'Classifier: {}\n'.format(classifier)
for extra in sorted(self._meta.provides_extra):
pkg_info += 'Provides-Extra: {}\n'.format(extra)
for dep in sorted(self._meta.requires_dist):
pkg_info += 'Requires-Dist: {}\n'.format(dep)
return encode(pkg_info)
@classmethod @classmethod
def find_packages(cls, path): def find_packages(cls, path):
""" """
......
...@@ -4,6 +4,7 @@ import re ...@@ -4,6 +4,7 @@ import re
from poetry.version.requirements import Requirement from poetry.version.requirements import Requirement
from .dependency import Dependency from .dependency import Dependency
from .file_dependency import FileDependency
from .locker import Locker from .locker import Locker
from .package import Package from .package import Package
from .utils.link import Link from .utils.link import Link
......
...@@ -106,6 +106,9 @@ class Dependency(object): ...@@ -106,6 +106,9 @@ class Dependency(object):
def is_vcs(self): def is_vcs(self):
return False return False
def is_file(self):
return False
def accepts(self, package): # type: (poetry.packages.Package) -> bool def accepts(self, package): # type: (poetry.packages.Package) -> bool
""" """
Determines if the given package matches this dependency. Determines if the given package matches this dependency.
...@@ -209,4 +212,4 @@ class Dependency(object): ...@@ -209,4 +212,4 @@ class Dependency(object):
) )
def __repr__(self): def __repr__(self):
return '<Dependency {}>'.format(str(self)) return '<{} {}>'.format(self.__class__.__name__, str(self))
import hashlib
import io
import pkginfo
from pkginfo.distribution import HEADER_ATTRS
from pkginfo.distribution import HEADER_ATTRS_2_0
from poetry.utils._compat import Path
from .dependency import Dependency
# Patching pkginfo to support Metadata version 2.1 (PEP 566)
HEADER_ATTRS.update(
{
'2.1': HEADER_ATTRS_2_0 + (
('Provides-Extra', 'provides_extra', True),
)
}
)
class FileDependency(Dependency):
def __init__(self,
path, # type: Path
category='main', # type: str
optional=False, # type: bool
base=None # type: Path
):
self._path = path
self._base = base
self._full_path = path
if self._base and not self._path.is_absolute():
self._full_path = self._base / self._path
if not self._full_path.exists():
raise ValueError('File {} does not exist'.format(self._path))
if self._full_path.is_dir():
raise ValueError(
'{} is a directory, expected a file'.format(self._path)
)
if self._path.suffix == '.whl':
self._meta = pkginfo.Wheel(str(self._full_path))
else:
# Assume sdist
self._meta = pkginfo.SDist(str(self._full_path))
super(FileDependency, self).__init__(
self._meta.name,
self._meta.version,
category=category,
optional=optional,
allows_prereleases=True
)
@property
def path(self):
return self._path
@property
def full_path(self):
return self._full_path.resolve()
@property
def metadata(self):
return self._meta
def is_file(self):
return True
def hash(self):
h = hashlib.sha256()
with self._path.open('rb') as fp:
for content in iter(lambda: fp.read(io.DEFAULT_BUFFER_SIZE), b''):
h.update(content)
return h.hexdigest()
...@@ -10,10 +10,12 @@ from poetry.semver.helpers import parse_stability ...@@ -10,10 +10,12 @@ from poetry.semver.helpers import parse_stability
from poetry.semver.version_parser import VersionParser from poetry.semver.version_parser import VersionParser
from poetry.spdx import license_by_id from poetry.spdx import license_by_id
from poetry.spdx import License from poetry.spdx import License
from poetry.utils._compat import Path
from poetry.version import parse as parse_version from poetry.version import parse as parse_version
from .constraints.generic_constraint import GenericConstraint from .constraints.generic_constraint import GenericConstraint
from .dependency import Dependency from .dependency import Dependency
from .file_dependency import FileDependency
from .vcs_dependency import VCSDependency from .vcs_dependency import VCSDependency
AUTHOR_REGEX = re.compile('(?u)^(?P<name>[- .,\w\d\'’"()]+) <(?P<email>.+?)>$') AUTHOR_REGEX = re.compile('(?u)^(?P<name>[- .,\w\d\'’"()]+) <(?P<email>.+?)>$')
...@@ -104,6 +106,8 @@ class Package(object): ...@@ -104,6 +106,8 @@ class Package(object):
self._platform = '*' self._platform = '*'
self._platform_constraint = EmptyConstraint() self._platform_constraint = EmptyConstraint()
self.cwd = None
@property @property
def name(self): def name(self):
return self._name return self._name
...@@ -253,12 +257,13 @@ class Package(object): ...@@ -253,12 +257,13 @@ class Package(object):
constraint = '*' constraint = '*'
if isinstance(constraint, dict): if isinstance(constraint, dict):
if 'git' in constraint:
# VCS dependency
optional = constraint.get('optional', False) optional = constraint.get('optional', False)
python_versions = constraint.get('python') python_versions = constraint.get('python')
platform = constraint.get('platform') platform = constraint.get('platform')
allows_prereleases = constraint.get('allows_prereleases', False)
if 'git' in constraint:
# VCS dependency
dependency = VCSDependency( dependency = VCSDependency(
name, name,
'git', constraint['git'], 'git', constraint['git'],
...@@ -273,12 +278,12 @@ class Package(object): ...@@ -273,12 +278,12 @@ class Package(object):
if platform: if platform:
dependency.platform = platform dependency.platform = platform
elif 'file' in constraint:
file_path = Path(constraint['file'])
dependency = FileDependency(file_path, base=self.cwd)
else: else:
version = constraint['version'] version = constraint['version']
optional = constraint.get('optional', False)
allows_prereleases = constraint.get('allows_prereleases', False)
python_versions = constraint.get('python')
platform = constraint.get('platform')
dependency = Dependency( dependency = Dependency(
name, version, name, version,
......
...@@ -99,6 +99,7 @@ class Poetry: ...@@ -99,6 +99,7 @@ class Poetry:
name = local_config['name'] name = local_config['name']
version = local_config['version'] version = local_config['version']
package = Package(name, version, version) package = Package(name, version, version)
package.cwd = Path(cwd)
for author in local_config['authors']: for author in local_config['authors']:
package.authors.append(author) package.authors.append(author)
......
...@@ -11,8 +11,10 @@ from poetry.mixology.conflict import Conflict ...@@ -11,8 +11,10 @@ from poetry.mixology.conflict import Conflict
from poetry.mixology.contracts import SpecificationProvider from poetry.mixology.contracts import SpecificationProvider
from poetry.packages import Dependency from poetry.packages import Dependency
from poetry.packages import FileDependency
from poetry.packages import Package from poetry.packages import Package
from poetry.packages import VCSDependency from poetry.packages import VCSDependency
from poetry.packages import dependency_from_pep_508
from poetry.repositories import Pool from poetry.repositories import Pool
...@@ -71,6 +73,8 @@ class Provider(SpecificationProvider): ...@@ -71,6 +73,8 @@ class Provider(SpecificationProvider):
if dependency.is_vcs(): if dependency.is_vcs():
packages = self.search_for_vcs(dependency) packages = self.search_for_vcs(dependency)
elif dependency.is_file():
packages = self.search_for_file(dependency)
else: else:
packages = self._pool.find_packages( packages = self._pool.find_packages(
dependency.name, dependency.name,
...@@ -175,8 +179,28 @@ class Provider(SpecificationProvider): ...@@ -175,8 +179,28 @@ class Provider(SpecificationProvider):
return [package] return [package]
def search_for_file(self, dependency
): # type: (FileDependency) -> List[Package]
package = Package(dependency.name, dependency.pretty_constraint)
package.source_type = 'file'
package.source_reference = str(dependency.path)
package.description = dependency.metadata.summary
for req in dependency.metadata.requires_dist:
package.requires.append(dependency_from_pep_508(req))
if dependency.metadata.requires_python:
package.python_versions = dependency.metadata.requires_python
if dependency.metadata.platforms:
package.platform = ' || '.join(dependency.metadata.platforms)
package.hashes = [dependency.hash()]
return [package]
def dependencies_for(self, package): # type: (Package) -> List[Dependency] def dependencies_for(self, package): # type: (Package) -> List[Dependency]
if package.source_type == 'git': if package.source_type in ['git', 'file']:
# Information should already be set # Information should already be set
pass pass
else: else:
......
...@@ -7,7 +7,7 @@ class WilcardConstraint(Constraint): ...@@ -7,7 +7,7 @@ class WilcardConstraint(Constraint):
def __init__(self, constraint): # type: (str) -> None def __init__(self, constraint): # type: (str) -> None
m = re.match( m = re.match(
'^(!=|==)?v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.[xX*])+$', '^(!= ?|==)?v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.[xX*])+$',
constraint constraint
) )
if not m: if not m:
...@@ -16,7 +16,7 @@ class WilcardConstraint(Constraint): ...@@ -16,7 +16,7 @@ class WilcardConstraint(Constraint):
if not m.group(1): if not m.group(1):
operator = '==' operator = '=='
else: else:
operator = m.group(1) operator = m.group(1).strip()
super(WilcardConstraint, self).__init__( super(WilcardConstraint, self).__init__(
operator, operator,
......
...@@ -185,7 +185,7 @@ class VersionParser: ...@@ -185,7 +185,7 @@ class VersionParser:
# A partial version range is treated as an X-Range, # A partial version range is treated as an X-Range,
# so the special character is in fact optional. # so the special character is in fact optional.
m = re.match( m = re.match(
'^(!=|==)?v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.[xX*])+$', '^(!= ?|==)?v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.[xX*])+$',
constraint constraint
) )
if m: if m:
......
...@@ -30,6 +30,9 @@ pathlib2 = { version = "^2.2", python = "~2.7" } ...@@ -30,6 +30,9 @@ pathlib2 = { version = "^2.2", python = "~2.7" }
orator = { version = "^0.9", optional = true } orator = { version = "^0.9", optional = true }
# File dependency
demo = { file = "../distributions/demo-0.1.0-py2.py3-none-any.whl" }
[tool.poetry.extras] [tool.poetry.extras]
db = [ "orator" ] db = [ "orator" ]
......
...@@ -2,6 +2,10 @@ from poetry.packages import Dependency ...@@ -2,6 +2,10 @@ from poetry.packages import Dependency
from poetry.packages import Package from poetry.packages import Package
from poetry.semver.helpers import normalize_version from poetry.semver.helpers import normalize_version
from poetry.utils._compat import Path
FIXTURE_PATH = Path(__file__).parent / 'fixtures'
def get_package(name, version): def get_package(name, version):
...@@ -20,3 +24,10 @@ def get_dependency(name, ...@@ -20,3 +24,10 @@ def get_dependency(name,
optional=optional, optional=optional,
allows_prereleases=allows_prereleases allows_prereleases=allows_prereleases
) )
def fixture(path=None):
if path:
return FIXTURE_PATH / path
else:
return FIXTURE_PATH
[[package]]
name = "demo"
version = "0.1.0"
description = "Description"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
platform = "*"
[package.source]
type = "file"
reference = "tests/fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl"
url = ""
[package.dependencies]
pendulum = ">= 1.4.0.0, < 2.0.0.0"
[[package]]
name = "pendulum"
version = "1.4.4"
description = ""
category = "main"
optional = false
python-versions = "*"
platform = "*"
[metadata]
python-versions = "*"
platform = "*"
content-hash = "123456789"
[metadata.hashes]
demo = ["5eb592cb6e9c42379ad576b1226ef37cb727365d40222fa9e49ef44e83cf8d1e"]
pendulum = []
...@@ -16,6 +16,7 @@ from poetry.utils._compat import Path ...@@ -16,6 +16,7 @@ from poetry.utils._compat import Path
from poetry.utils._compat import PY2 from poetry.utils._compat import PY2
from poetry.utils.venv import NullVenv from poetry.utils.venv import NullVenv
from tests.helpers import fixture as root_fixture
from tests.helpers import get_dependency from tests.helpers import get_dependency
from tests.helpers import get_package from tests.helpers import get_package
from tests.repositories.test_pypi_repository import MockRepository from tests.repositories.test_pypi_repository import MockRepository
...@@ -529,3 +530,25 @@ def test_installer_with_pypi_repository(package, locker, installed): ...@@ -529,3 +530,25 @@ def test_installer_with_pypi_repository(package, locker, installed):
expected = fixture('with-pypi-repository') expected = fixture('with-pypi-repository')
assert locker.written_data == expected assert locker.written_data == expected
def test_run_installs_with_local_file(installer, locker, repo, package):
file_path = Path(
'tests/fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl'
)
package.add_dependency(
'demo',
{
'file': str(file_path)
}
)
repo.add_package(get_package('pendulum', '1.4.4'))
installer.run()
expected = fixture('with-file-dependency')
assert locker.written_data == expected
assert len(installer.installer.installs) == 2
import pytest
from poetry.packages import FileDependency
from poetry.utils._compat import Path
DIST_PATH = Path(__file__).parent.parent / 'fixtures' / 'distributions'
def test_file_dependency_wheel():
dependency = FileDependency(DIST_PATH / 'demo-0.1.0-py2.py3-none-any.whl')
assert dependency.is_file()
assert dependency.name == 'demo'
assert dependency.pretty_constraint == '0.1.0'
assert dependency.python_versions == '*'
assert dependency.platform == '*'
meta = dependency.metadata
assert meta.requires_dist == [
'pendulum (>=1.4.0.0,<2.0.0.0)'
]
def test_file_dependency_sdist():
dependency = FileDependency(DIST_PATH / 'demo-0.1.0.tar.gz')
assert dependency.is_file()
assert dependency.name == 'demo'
assert dependency.pretty_constraint == '0.1.0'
assert dependency.python_versions == '*'
assert dependency.platform == '*'
meta = dependency.metadata
assert meta.requires_dist == [
'pendulum (>=1.4.0.0,<2.0.0.0)'
]
def test_file_dependency_wrong_path():
with pytest.raises(ValueError):
FileDependency(DIST_PATH / 'demo-0.2.0.tar.gz')
def test_file_dependency_dir():
with pytest.raises(ValueError):
FileDependency(DIST_PATH)
...@@ -57,6 +57,12 @@ def test_poetry(): ...@@ -57,6 +57,12 @@ def test_poetry():
assert pathlib2.python_versions == '~2.7' assert pathlib2.python_versions == '~2.7'
assert not pathlib2.is_optional() assert not pathlib2.is_optional()
demo = dependencies['demo']
assert demo.is_file()
assert not demo.is_vcs()
assert demo.name == 'demo'
assert demo.pretty_constraint == '0.1.0'
assert 'db' in package.extras assert 'db' in package.extras
classifiers = package.classifiers classifiers = package.classifiers
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment