diff --git a/src/poetry/core/lock/__init__.py b/src/poetry/core/lock/__init__.py new file mode 100644 index 000000000..15cec4cf2 --- /dev/null +++ b/src/poetry/core/lock/__init__.py @@ -0,0 +1,6 @@ +from poetry.core.lock.locker import Locker + + +__all__ = [ + "Locker", +] diff --git a/src/poetry/core/lock/locker.py b/src/poetry/core/lock/locker.py new file mode 100644 index 000000000..5b50d8e76 --- /dev/null +++ b/src/poetry/core/lock/locker.py @@ -0,0 +1,576 @@ +import json +import logging +import os +import re + +from copy import deepcopy +from hashlib import sha256 +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import Dict +from typing import Iterable +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union + +from tomlkit import array +from tomlkit import document +from tomlkit import inline_table +from tomlkit import item +from tomlkit import table +from tomlkit.exceptions import TOMLKitError + +from poetry.core.packages.dependency import Dependency +from poetry.core.packages.package import Package +from poetry.core.pyproject.toml import PyProjectTOML +from poetry.core.semver.helpers import parse_constraint +from poetry.core.semver.version import Version +from poetry.core.toml.file import TOMLFile +from poetry.core.version.markers import parse_marker +from poetry.core.version.requirements import InvalidRequirement + + +if TYPE_CHECKING: + from tomlkit.container import Container as TOMLContainer + from tomlkit.items import InlineTable + from tomlkit.toml_document import TOMLDocument + + Data = Union[Dict[str, Any], TOMLContainer] + +logger = logging.getLogger(__name__) + + +class Locker: + + _VERSION = "1.1" + + _relevant_keys = ["dependencies", "group", "source", "extras"] + + def __init__( + self, lock: Union[str, Path], local_config: Optional["Data"] = None + ) -> None: + self._lock = TOMLFile(lock) + self._local_config = local_config or {} + self._lock_data = None + self._content_hash = self._get_content_hash() + + @property + def lock(self) -> TOMLFile: + return self._lock + + @property + def lock_data(self) -> "TOMLDocument": + if self._lock_data is None: + self._lock_data = self._get_lock_data() + + return self._lock_data + + def is_locked(self) -> bool: + """ + Checks whether the locker has been locked (lockfile found). + """ + if not self._lock.exists(): + return False + + return "package" in self.lock_data + + def is_fresh(self) -> bool: + """ + Checks whether the lock file is still up to date with the current hash. + """ + lock = self._lock.read() + metadata = lock.get("metadata", {}) + + if "content-hash" in metadata: + return self._content_hash == lock["metadata"]["content-hash"] # type: ignore[no-any-return] + + return False + + @staticmethod + def __get_locked_package( + _dependency: Dependency, packages_by_name: Dict[str, List[Package]] + ) -> Union[Package, None]: + """ + Internal helper to identify corresponding locked package using dependency + version constraints. + """ + for _package in packages_by_name.get(_dependency.name, []): + if _dependency.constraint.allows(_package.version): + return _package + return None + + @classmethod + def __walk_dependency_level( + cls, + dependencies: List[Dependency], + level: int, + pinned_versions: bool, + packages_by_name: Dict[str, List[Package]], + project_level_dependencies: set[str], + nested_dependencies: Dict[Tuple[str, str], Dependency], + ) -> Dict[Tuple[str, str], Dependency]: + if not dependencies: + return nested_dependencies + + next_level_dependencies = [] + + for requirement in dependencies: + key = (requirement.name, requirement.pretty_constraint) + locked_package = cls.__get_locked_package(requirement, packages_by_name) + + if locked_package: + # create dependency from locked package to retain dependency metadata + # if this is not done, we can end-up with incorrect nested dependencies + constraint = requirement.constraint + pretty_constraint = requirement.pretty_constraint + marker = requirement.marker + requirement = locked_package.to_dependency() + requirement.marker = requirement.marker.intersect(marker) + + key = (requirement.name, pretty_constraint) + + if not pinned_versions: + requirement.set_constraint(constraint) + + for require in locked_package.requires: + if require.marker.is_empty(): + require.marker = requirement.marker + else: + require.marker = require.marker.intersect(requirement.marker) + + require.marker = require.marker.intersect(locked_package.marker) + + if key not in nested_dependencies: + next_level_dependencies.append(require) + + if requirement.name in project_level_dependencies and level == 0: + # project level dependencies take precedence + continue + + if not locked_package: + # we make a copy to avoid any side-effects + requirement = deepcopy(requirement) + + if key not in nested_dependencies: + nested_dependencies[key] = requirement + else: + nested_dependencies[key].marker = nested_dependencies[key].marker.union( + requirement.marker + ) + + return cls.__walk_dependency_level( + dependencies=next_level_dependencies, + level=level + 1, + pinned_versions=pinned_versions, + packages_by_name=packages_by_name, + project_level_dependencies=project_level_dependencies, + nested_dependencies=nested_dependencies, + ) + + @classmethod + def get_project_dependencies( + cls, + project_requires: List[Dependency], + locked_packages: List[Package], + pinned_versions: bool = False, + with_nested: bool = False, + ) -> Iterable[Dependency]: + # group packages entries by name, this is required because requirement might use + # different constraints + packages_by_name: Dict[str, List[Package]] = {} + for pkg in locked_packages: + if pkg.name not in packages_by_name: + packages_by_name[pkg.name] = [] + packages_by_name[pkg.name].append(pkg) + + project_level_dependencies = set() + dependencies = [] + + for dependency in project_requires: + dependency = deepcopy(dependency) + locked_package = cls.__get_locked_package(dependency, packages_by_name) + if locked_package: + locked_dependency = locked_package.to_dependency() + locked_dependency.marker = dependency.marker.intersect( + locked_package.marker + ) + + if not pinned_versions: + locked_dependency.set_constraint(dependency.constraint) + + dependency = locked_dependency + + project_level_dependencies.add(dependency.name) + dependencies.append(dependency) + + if not with_nested: + # return only with project level dependencies + return dependencies + + nested_dependencies = cls.__walk_dependency_level( + dependencies=dependencies, + level=0, + pinned_versions=pinned_versions, + packages_by_name=packages_by_name, + project_level_dependencies=project_level_dependencies, + nested_dependencies={}, + ) + + # Merge same dependencies using marker union + for requirement in dependencies: + key = (requirement.name, requirement.pretty_constraint) + if key not in nested_dependencies: + nested_dependencies[key] = requirement + else: + nested_dependencies[key].marker = nested_dependencies[key].marker.union( + requirement.marker + ) + + return sorted(nested_dependencies.values(), key=lambda x: x.name.lower()) + + def _load_package(self, info: "Data") -> Package: + from poetry.core.factory import Factory + + lock_metadata = self.lock_data["metadata"] + source = info.get("source", {}) + source_type = source.get("type") + url = source.get("url") + if source_type in ["directory", "file"]: + url = self._lock.path.parent.joinpath(url).resolve().as_posix() + + package = Package( + info["name"], + info["version"], + info["version"], + source_type=source_type, + source_url=url, + source_reference=source.get("reference"), + source_resolved_reference=source.get("resolved_reference"), + ) + package.description = info.get("description", "") + package.category = info.get("category", "main") + # package.groups = info.get("groups", ["default"]) # noqa: E800 + package.optional = info["optional"] + if "hashes" in lock_metadata: + # Old lock so we create dummy files from the hashes + package.files = [ + {"name": h, "hash": h} for h in lock_metadata["hashes"][info["name"]] + ] + else: + package.files = lock_metadata["files"][info["name"]] + + package.python_versions = info["python-versions"] + extras = info.get("extras", {}) + if extras: + for name, deps in extras.items(): + package.extras[name] = [] + + for dep in deps: + try: + dependency = Dependency.create_from_pep_508(dep) + except InvalidRequirement: + # handle lock files with invalid PEP 508 + m = re.match(r"^(.+?)(?:\[(.+?)])?(?:\s+\((.+)\))?$", dep) + if not m: + raise + dep_name = m.group(1) + extras = m.group(2) or "" + constraint = m.group(3) or "*" + dependency = Dependency( + dep_name, constraint, extras=extras.split(",") + ) + package.extras[name].append(dependency) + + if "marker" in info: + package.marker = parse_marker(info["marker"]) + else: + # Compatibility for old locks + if "requirements" in info: + dep = Dependency("foo", "0.0.0") + for name, value in info["requirements"].items(): + if name == "python": + dep.python_versions = value + elif name == "platform": + dep.platform = value + + split_dep = dep.to_pep_508(False).split(";") + if len(split_dep) > 1: + package.marker = parse_marker(split_dep[1].strip()) + + for dep_name, constraint in info.get("dependencies", {}).items(): + + root_dir = self._lock.path.parent + if package.source_type == "directory" and package.source_url is not None: + # root dir should be the source of the package relative to the lock + # path + root_dir = Path(package.source_url) + + if isinstance(constraint, list): + for c in constraint: + package.add_dependency( + Factory.create_dependency(dep_name, c, root_dir=root_dir) + ) + + continue + + package.add_dependency( + Factory.create_dependency(dep_name, constraint, root_dir=root_dir) + ) + + if "develop" in info: + package.develop = info["develop"] + + return package + + @classmethod + def load( + cls, lock: Path, pyproject_file: Optional[Union[str, Path]] = None + ) -> "Locker": + if pyproject_file and Path(pyproject_file).exists(): + return cls(lock, PyProjectTOML(pyproject_file).poetry_config) + return cls(lock) + + def get_packages( + self, names: Optional[List[str]] = None, categories: Optional[List[str]] = None + ) -> List[Package]: + """ + Get locked packages. Filters by categories if specified. + :param names: Package names to filter on. + :param categories: Package categories to filter on. + """ + packages: List[Package] = [] + + if not self.is_locked(): + return packages + + locked_packages = [ + pkg + for pkg in self.lock_data["package"] + if (names is None or pkg["name"] in names) + and (categories is None or pkg["category"] in categories) + ] + + for info in locked_packages: + packages.append(self._load_package(info)) + + return packages + + def set_lock_data(self, root: Package, packages: List[Package]) -> bool: + files = table() + _packages = self._lock_packages(packages) + # Retrieving hashes + for package in _packages: + if package["name"] not in files: + files[package["name"]] = [] + + for f in package["files"]: + file_metadata = inline_table() + for k, v in sorted(f.items()): + file_metadata[k] = v + + files[package["name"]].append(file_metadata) + + if files[package["name"]]: + files[package["name"]] = item(files[package["name"]]).multiline(True) + + del package["files"] + + lock = document() + lock["package"] = _packages + + if root.extras: + lock["extras"] = { + extra: [dep.pretty_name for dep in deps] + for extra, deps in sorted(root.extras.items()) + } + + lock["metadata"] = { + "lock-version": self._VERSION, + "python-versions": root.python_versions, + "content-hash": self._content_hash, + "files": files, + } + + if not self.is_locked() or lock != self.lock_data: + self._write_lock_data(lock) + + return True + + return False + + def _write_lock_data(self, data: "TOMLDocument") -> None: + self.lock.write(data) + + # Checking lock file data consistency + if data != self.lock.read(): + raise RuntimeError("Inconsistent lock file data.") + + self._lock_data = None + + def _get_content_hash(self) -> str: + """ + Returns the sha256 hash of the sorted content of the pyproject file. + """ + content = self._local_config + + relevant_content = {} + for key in self._relevant_keys: + relevant_content[key] = content.get(key) + + content_hash = sha256( + json.dumps(relevant_content, sort_keys=True).encode() + ).hexdigest() + + return content_hash + + def _get_lock_data(self) -> "TOMLDocument": + if not self._lock.exists(): + raise RuntimeError("No lockfile found. Unable to read locked packages") + + try: + lock_data = self._lock.read() + except TOMLKitError as e: + raise RuntimeError(f"Unable to read the lock file ({e}).") + + lock_version = Version.parse(lock_data["metadata"].get("lock-version", "1.0")) + current_version = Version.parse(self._VERSION) + # We expect the locker to be able to read lock files + # from the same semantic versioning range + accepted_versions = parse_constraint( + f"^{Version.from_parts(current_version.major, 0)}" + ) + lock_version_allowed = accepted_versions.allows(lock_version) + if lock_version_allowed and current_version < lock_version: + logger.warning( + "The lock file might not be compatible with the current version of" + " Poetry.\nUpgrade Poetry to ensure the lock file is read properly or," + " alternatively, regenerate the lock file with the `poetry lock`" + " command." + ) + elif not lock_version_allowed: + raise RuntimeError( + "The lock file is not compatible with the current version of Poetry.\n" + "Upgrade Poetry to be able to read the lock file or, alternatively, " + "regenerate the lock file with the `poetry lock` command." + ) + + return lock_data + + def _lock_packages(self, packages: List[Package]) -> List[Dict[str, Any]]: + locked: List[Data] = [] + + for package in sorted(packages, key=lambda x: x.name): + spec = self._dump_package(package) + + locked.append(spec) + + return locked + + def _dump_package(self, package: Package) -> "Data": + dependencies: Dict[str, List["InlineTable"]] = {} + for require in sorted(package.requires, key=lambda d: d.name): + if require.pretty_name not in dependencies: + dependencies[require.pretty_name] = [] + + constraint = inline_table() + + if require.is_directory() or require.is_file(): + constraint["path"] = require.path.as_posix() # type: ignore[attr-defined] + + if require.is_directory() and require.develop: # type: ignore[attr-defined] + constraint["develop"] = True + elif require.is_url(): + constraint["url"] = require.url # type: ignore[attr-defined] + elif require.is_vcs(): + constraint[require.vcs] = require.source # type: ignore[attr-defined] + + if require.branch: # type: ignore[attr-defined] + constraint["branch"] = require.branch # type: ignore[attr-defined] + elif require.tag: # type: ignore[attr-defined] + constraint["tag"] = require.tag # type: ignore[attr-defined] + elif require.rev: # type: ignore[attr-defined] + constraint["rev"] = require.rev # type: ignore[attr-defined] + else: + constraint["version"] = str(require.pretty_constraint) + + if require.extras: + constraint["extras"] = sorted(require.extras) + + if require.is_optional(): + constraint["optional"] = True + + if not require.marker.is_any(): + constraint["markers"] = str(require.marker) + + dependencies[require.pretty_name].append(constraint) + + # All the constraints should have the same type, + # but we want to simplify them if it's possible + for dependency, constraints in tuple(dependencies.items()): + if all( + len(constraint) == 1 and "version" in constraint + for constraint in constraints + ): + dependencies[dependency] = [ + constraint["version"] for constraint in constraints + ] + + data: "Data" = { + "name": package.pretty_name, + "version": package.pretty_version, + "description": package.description or "", + "category": package.category, + "optional": package.optional, + "python-versions": package.python_versions, + "files": sorted(package.files, key=lambda x: x["file"]), # type: ignore[no-any-return] + } + + if dependencies: + data["dependencies"] = table() + for k, constraints in dependencies.items(): + if len(constraints) == 1: + data["dependencies"][k] = constraints[0] + else: + data["dependencies"][k] = array().multiline(True) + for constraint in constraints: + data["dependencies"][k].append(constraint) + + if package.extras: + extras = {} + for name, deps in package.extras.items(): + extras[name] = [ + dep.to_pep_508() if not dep.constraint.is_any() else dep.name + for dep in deps + ] + + data["extras"] = extras + + if package.source_url: + url = package.source_url + if package.source_type in ["file", "directory"]: + # The lock file should only store paths relative to the root project + url = Path( + os.path.relpath( + Path(url).as_posix(), self._lock.path.parent.as_posix() + ) + ).as_posix() + + data["source"] = {} + + if package.source_type: + data["source"]["type"] = package.source_type + + data["source"]["url"] = url + + if package.source_reference: + data["source"]["reference"] = package.source_reference + + if package.source_resolved_reference: + data["source"]["resolved_reference"] = package.source_resolved_reference + + if package.source_type in ["directory", "git"]: + data["develop"] = package.develop + + return data diff --git a/src/poetry/core/packages/package.py b/src/poetry/core/packages/package.py index 46f49e371..b19f8ab9b 100644 --- a/src/poetry/core/packages/package.py +++ b/src/poetry/core/packages/package.py @@ -107,7 +107,7 @@ def __init__( self._python_marker = AnyMarker() self.platform = None - self.marker = AnyMarker() + self.marker: "BaseMarker" = AnyMarker() self.root_dir: Optional[Path] = None diff --git a/src/poetry/core/poetry.py b/src/poetry/core/poetry.py index fec06a244..537da268e 100644 --- a/src/poetry/core/poetry.py +++ b/src/poetry/core/poetry.py @@ -1,11 +1,13 @@ from typing import TYPE_CHECKING from typing import Any from typing import Dict +from typing import Optional if TYPE_CHECKING: from pathlib import Path + from poetry.core.lock.locker import Locker from poetry.core.packages.project_package import ProjectPackage from poetry.core.pyproject.toml import PyProjectTOML from poetry.core.toml import TOMLFile @@ -23,6 +25,7 @@ def __init__( self._pyproject = PyProjectTOML(file) self._package = package self._local_config = local_config + self._locker: Optional["Locker"] = None @property def pyproject(self) -> "PyProjectTOML": @@ -42,3 +45,13 @@ def local_config(self) -> Dict[str, Any]: def get_project_config(self, config: str, default: Any = None) -> Any: return self._local_config.get("config", {}).get(config, default) + + @property + def locker(self) -> Optional["Locker"]: + if self._locker is None: + from poetry.core.lock.locker import Locker + + self._locker = Locker( + self.pyproject.file / "poetry.lock", self.local_config + ) + return self._locker diff --git a/tests/fixtures/project_with_extras/project_with_extras/__init__.py b/tests/fixtures/project_with_extras/project_with_extras/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/project_with_extras/pyproject.toml b/tests/fixtures/project_with_extras/pyproject.toml new file mode 100644 index 000000000..e2ad76190 --- /dev/null +++ b/tests/fixtures/project_with_extras/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "project-with-extras" +version = "1.2.3" +description = "This is a description" +authors = ["Your Name "] +license = "MIT" + +[tool.poetry.dependencies] +python = "*" +pendulum = { version = ">=1.4.4", optional = true } +cachy = { version = ">=0.2.0", optional = true } + +[tool.poetry.extras] +extras_a = [ "pendulum" ] +extras_b = [ "cachy" ] + +[tool.poetry.dev-dependencies] diff --git a/tests/fixtures/project_with_transitive_directory_dependencies/project_with_transitive_directory_dependencies/__init__.py b/tests/fixtures/project_with_transitive_directory_dependencies/project_with_transitive_directory_dependencies/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/project_with_transitive_directory_dependencies/pyproject.toml b/tests/fixtures/project_with_transitive_directory_dependencies/pyproject.toml new file mode 100644 index 000000000..027d01e67 --- /dev/null +++ b/tests/fixtures/project_with_transitive_directory_dependencies/pyproject.toml @@ -0,0 +1,13 @@ +[tool.poetry] +name = "project-with-transitive-directory-dependencies" +version = "1.2.3" +description = "This is a description" +authors = ["Your Name "] +license = "MIT" + +[tool.poetry.dependencies] +python = "*" +project-with-extras = {path = "../project_with_extras/"} +project-with-transitive-file-dependencies = {path = "../project_with_transitive_file_dependencies/"} + +[tool.poetry.dev-dependencies] diff --git a/tests/fixtures/project_with_transitive_directory_dependencies/setup.py b/tests/fixtures/project_with_transitive_directory_dependencies/setup.py new file mode 100644 index 000000000..f2e23b875 --- /dev/null +++ b/tests/fixtures/project_with_transitive_directory_dependencies/setup.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from distutils.core import setup + + +packages = ["project_with_extras"] + +package_data = {"": ["*"]} + +extras_require = {"extras_a": ["pendulum>=1.4.4"], "extras_b": ["cachy>=0.2.0"]} + +setup_kwargs = { + "name": "project-with-extras", + "version": "1.2.3", + "description": "This is a description", + "long_description": None, + "author": "Your Name", + "author_email": "you@example.com", + "url": None, + "packages": packages, + "package_data": package_data, + "extras_require": extras_require, +} + + +setup(**setup_kwargs) # type: ignore[arg-type] diff --git a/tests/fixtures/project_with_transitive_file_dependencies/inner-directory-project/pyproject.toml b/tests/fixtures/project_with_transitive_file_dependencies/inner-directory-project/pyproject.toml new file mode 100644 index 000000000..a80113675 --- /dev/null +++ b/tests/fixtures/project_with_transitive_file_dependencies/inner-directory-project/pyproject.toml @@ -0,0 +1,11 @@ +[tool.poetry] +name = "inner-directory-project" +version = "1.2.4" +description = "This is a description" +authors = ["Your Name "] +license = "MIT" + +[tool.poetry.dependencies] +python = "*" + +[tool.poetry.dev-dependencies] diff --git a/tests/fixtures/project_with_transitive_file_dependencies/project_with_transitive_file_dependencies/__init__.py b/tests/fixtures/project_with_transitive_file_dependencies/project_with_transitive_file_dependencies/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/project_with_transitive_file_dependencies/pyproject.toml b/tests/fixtures/project_with_transitive_file_dependencies/pyproject.toml new file mode 100644 index 000000000..678e42f2f --- /dev/null +++ b/tests/fixtures/project_with_transitive_file_dependencies/pyproject.toml @@ -0,0 +1,13 @@ +[tool.poetry] +name = "project-with-transitive-file-dependencies" +version = "1.2.3" +description = "This is a description" +authors = ["Your Name "] +license = "MIT" + +[tool.poetry.dependencies] +python = "*" +demo = {path = "../../distributions/demo-0.1.0-py2.py3-none-any.whl"} +inner-directory-project = {path = "./inner-directory-project"} + +[tool.poetry.dev-dependencies] diff --git a/tests/lock/__init__.py b/tests/lock/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lock/test_locker.py b/tests/lock/test_locker.py new file mode 100644 index 000000000..c4fc6b157 --- /dev/null +++ b/tests/lock/test_locker.py @@ -0,0 +1,607 @@ +import logging +import tempfile + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import tomlkit + +from poetry.core.factory import Factory +from poetry.core.lock import Locker +from poetry.core.packages.package import Package +from poetry.core.packages.project_package import ProjectPackage +from poetry.core.semver.version import Version + + +if TYPE_CHECKING: + from _pytest.logging import LogCaptureFixture + + +@pytest.fixture # type: ignore[misc] +def locker() -> Locker: + with tempfile.NamedTemporaryFile() as f: + f.close() + locker = Locker(Path(f.name), {}) + + return locker + + +@pytest.fixture # type: ignore[misc] +def root() -> ProjectPackage: + return ProjectPackage("root", "1.2.3") + + +def test_lock_file_data_is_ordered(locker: Locker, root: ProjectPackage) -> None: + package_a = Package("A", "1.0.0") + package_a.add_dependency(Factory.create_dependency("B", "^1.0")) + package_a.files = [{"file": "foo", "hash": "456"}, {"file": "bar", "hash": "123"}] + package_git = Package( + "git-package", + "1.2.3", + source_type="git", + source_url="https://github.com/python-poetry/poetry.git", + source_reference="develop", + source_resolved_reference="123456", + ) + packages = [package_a, Package("B", "1.2"), package_git] + + locker.set_lock_data(root, packages) + + with locker.lock.open(encoding="utf-8") as f: + content = f.read() + + expected = """[[package]] +name = "A" +version = "1.0.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +B = "^1.0" + +[[package]] +name = "B" +version = "1.2" +description = "" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "git-package" +version = "1.2.3" +description = "" +category = "main" +optional = false +python-versions = "*" +develop = false + +[package.source] +type = "git" +url = "https://github.com/python-poetry/poetry.git" +reference = "develop" +resolved_reference = "123456" + +[metadata] +lock-version = "1.1" +python-versions = "*" +content-hash = "178f2cd01dc40e96be23a4a0ae1094816626346346618335e5ff4f0b2c0c5831" + +[metadata.files] +A = [ + {file = "bar", hash = "123"}, + {file = "foo", hash = "456"}, +] +B = [] +git-package = [] +""" + + assert content == expected + + +def test_locker_properly_loads_extras(locker: Locker) -> None: + content = """\ +[[package]] +name = "cachecontrol" +version = "0.12.5" +description = "httplib2 caching for requests" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +msgpack = "*" +requests = "*" + +[package.dependencies.lockfile] +optional = true +version = ">=0.9" + +[package.extras] +filecache = ["lockfile (>=0.9)"] +redis = ["redis (>=2.10.5)"] + +[metadata] +lock-version = "1.1" +python-versions = "~2.7 || ^3.4" +content-hash = "c3d07fca33fba542ef2b2a4d75bf5b48d892d21a830e2ad9c952ba5123a52f77" + +[metadata.files] +cachecontrol = [] +""" + + locker.lock.write(tomlkit.parse(content)) + + packages = locker.get_packages() + + assert len(packages) == 1 + + package = packages[0] + assert len(package.requires) == 3 + assert len(package.extras) == 2 + + lockfile_dep = package.extras["filecache"][0] + assert lockfile_dep.name == "lockfile" + + +def test_locker_properly_loads_nested_extras(locker: Locker) -> None: + content = """\ +[[package]] +name = "a" +version = "1.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +b = {version = "^1.0", optional = true, extras = "c"} + +[package.extras] +b = ["b[c] (>=1.0,<2.0)"] + +[[package]] +name = "b" +version = "1.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +c = {version = "^1.0", optional = true} + +[package.extras] +c = ["c (>=1.0,<2.0)"] + +[[package]] +name = "c" +version = "1.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[metadata] +python-versions = "*" +lock-version = "1.1" +content-hash = "123456789" + +[metadata.files] +"a" = [] +"b" = [] +"c" = [] +""" + + locker.lock.write(tomlkit.parse(content)) + + packages = locker.get_packages() + assert len(packages) == 3 + + packages = locker.get_packages(["a"]) + assert len(packages) == 1 + + package = packages[0] + assert len(package.requires) == 1 + assert len(package.extras) == 1 + + dependency_b = package.extras["b"][0] + assert dependency_b.name == "b" + assert dependency_b.extras == frozenset({"c"}) + + packages = locker.get_packages([dependency_b.name]) + assert len(packages) == 1 + + package = packages[0] + assert len(package.requires) == 1 + assert len(package.extras) == 1 + + dependency_c = package.extras["c"][0] + assert dependency_c.name == "c" + assert dependency_c.extras == frozenset() + + packages = locker.get_packages([dependency_c.name]) + assert len(packages) == 1 + + +def test_locker_properly_loads_extras_legacy(locker: Locker) -> None: + content = """\ +[[package]] +name = "a" +version = "1.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +b = {version = "^1.0", optional = true} + +[package.extras] +b = ["b (^1.0)"] + +[[package]] +name = "b" +version = "1.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[metadata] +python-versions = "*" +lock-version = "1.1" +content-hash = "123456789" + +[metadata.files] +"a" = [] +"b" = [] +""" + + locker.lock.write(tomlkit.parse(content)) + + packages = locker.get_packages() + assert len(packages) == 2 + + packages = locker.get_packages(["a"]) + assert len(packages) == 1 + + package = packages[0] + assert len(package.requires) == 1 + assert len(package.extras) == 1 + + dependency_b = package.extras["b"][0] + assert dependency_b.name == "b" + + +def test_lock_packages_with_null_description( + locker: Locker, root: ProjectPackage +) -> None: + package_a = Package("A", "1.0.0") + package_a.description = None # type: ignore[assignment] + + locker.set_lock_data(root, [package_a]) + + with locker.lock.open(encoding="utf-8") as f: + content = f.read() + + expected = """[[package]] +name = "A" +version = "1.0.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[metadata] +lock-version = "1.1" +python-versions = "*" +content-hash = "178f2cd01dc40e96be23a4a0ae1094816626346346618335e5ff4f0b2c0c5831" + +[metadata.files] +A = [] +""" + + assert content == expected + + +def test_lock_file_should_not_have_mixed_types( + locker: Locker, root: ProjectPackage +) -> None: + package_a = Package("A", "1.0.0") + package_a.add_dependency(Factory.create_dependency("B", "^1.0.0")) + package_a.add_dependency( + Factory.create_dependency("B", {"version": ">=1.0.0", "optional": True}) + ) + package_a.requires[-1].activate() + package_a.extras["foo"] = [Factory.create_dependency("B", ">=1.0.0")] + + locker.set_lock_data(root, [package_a]) + + expected = """[[package]] +name = "A" +version = "1.0.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +B = [ + {version = "^1.0.0"}, + {version = ">=1.0.0", optional = true}, +] + +[package.extras] +foo = ["B (>=1.0.0)"] + +[metadata] +lock-version = "1.1" +python-versions = "*" +content-hash = "178f2cd01dc40e96be23a4a0ae1094816626346346618335e5ff4f0b2c0c5831" + +[metadata.files] +A = [] +""" + + with locker.lock.open(encoding="utf-8") as f: + content = f.read() + + assert content == expected + + +def test_reading_lock_file_should_raise_an_error_on_invalid_data( + locker: Locker, +) -> None: + content = """[[package]] +name = "A" +version = "1.0.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +foo = ["bar"] + +[package.extras] +foo = ["bar"] + +[metadata] +lock-version = "1.1" +python-versions = "*" +content-hash = "178f2cd01dc40e96be23a4a0ae1094816626346346618335e5ff4f0b2c0c5831" + +[metadata.files] +A = [] +""" + with locker.lock.open("w", encoding="utf-8") as f: + f.write(content) + + with pytest.raises(RuntimeError) as e: + _ = locker.lock_data + + assert "Unable to read the lock file" in str(e.value) + + +def test_locking_legacy_repository_package_should_include_source_section( + root: ProjectPackage, locker: Locker +) -> None: + package_a = Package( + "A", + "1.0.0", + source_type="legacy", + source_url="https://foo.bar", + source_reference="legacy", + ) + packages = [package_a] + + locker.set_lock_data(root, packages) + + with locker.lock.open(encoding="utf-8") as f: + content = f.read() + + expected = """[[package]] +name = "A" +version = "1.0.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[package.source] +type = "legacy" +url = "https://foo.bar" +reference = "legacy" + +[metadata] +lock-version = "1.1" +python-versions = "*" +content-hash = "178f2cd01dc40e96be23a4a0ae1094816626346346618335e5ff4f0b2c0c5831" + +[metadata.files] +A = [] +""" + + assert content == expected + + +def test_locker_should_emit_warnings_if_lock_version_is_newer_but_allowed( + locker: Locker, caplog: "LogCaptureFixture" +) -> None: + version = ".".join(Version.parse(Locker._VERSION).next_minor().text.split(".")[:2]) + content = f"""\ +[metadata] +lock-version = "{version}" +python-versions = "~2.7 || ^3.4" +content-hash = "c3d07fca33fba542ef2b2a4d75bf5b48d892d21a830e2ad9c952ba5123a52f77" + +[metadata.files] +""" + caplog.set_level(logging.WARNING, logger="poetry.packages.locker") + + locker.lock.write(tomlkit.parse(content)) + + _ = locker.lock_data + + assert len(caplog.records) == 1 + + record = caplog.records[0] + assert record.levelname == "WARNING" + + expected = """\ +The lock file might not be compatible with the current version of Poetry. +Upgrade Poetry to ensure the lock file is read properly or, alternatively, \ +regenerate the lock file with the `poetry lock` command.\ +""" + assert record.message == expected + + +def test_locker_should_raise_an_error_if_lock_version_is_newer_and_not_allowed( + locker: Locker, caplog: "LogCaptureFixture" +) -> None: + content = """\ +[metadata] +lock-version = "2.0" +python-versions = "~2.7 || ^3.4" +content-hash = "c3d07fca33fba542ef2b2a4d75bf5b48d892d21a830e2ad9c952ba5123a52f77" + +[metadata.files] +""" + caplog.set_level(logging.WARNING, logger="poetry.packages.locker") + + locker.lock.write(tomlkit.parse(content)) + + with pytest.raises(RuntimeError, match="^The lock file is not compatible"): + _ = locker.lock_data + + +def test_extras_dependencies_are_ordered(locker: Locker, root: ProjectPackage) -> None: + package_a = Package("A", "1.0.0") + package_a.add_dependency( + Factory.create_dependency( + "B", {"version": "^1.0.0", "optional": True, "extras": ["c", "a", "b"]} + ) + ) + package_a.requires[-1].activate() + + locker.set_lock_data(root, [package_a]) + + expected = """[[package]] +name = "A" +version = "1.0.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +B = {version = "^1.0.0", extras = ["a", "b", "c"], optional = true} + +[metadata] +lock-version = "1.1" +python-versions = "*" +content-hash = "178f2cd01dc40e96be23a4a0ae1094816626346346618335e5ff4f0b2c0c5831" + +[metadata.files] +A = [] +""" + + with locker.lock.open(encoding="utf-8") as f: + content = f.read() + + assert content == expected + + +def test_locker_should_neither_emit_warnings_nor_raise_error_for_lower_compatible_versions( + locker: Locker, caplog: "LogCaptureFixture" +) -> None: + current_version = Version.parse(Locker._VERSION) + older_version = ".".join( + [str(current_version.major), str(current_version.minor - 1)] # type: ignore[operator] + ) + content = f"""\ +[metadata] +lock-version = "{older_version}" +python-versions = "~2.7 || ^3.4" +content-hash = "c3d07fca33fba542ef2b2a4d75bf5b48d892d21a830e2ad9c952ba5123a52f77" + +[metadata.files] +""" + caplog.set_level(logging.WARNING, logger="poetry.packages.locker") + + locker.lock.write(tomlkit.parse(content)) + + _ = locker.lock_data + + assert len(caplog.records) == 0 + + +def test_locker_dumps_dependency_information_correctly( + locker: Locker, root: ProjectPackage +) -> None: + root_dir = Path(__file__).parent.parent.joinpath("fixtures") + package_a = Package("A", "1.0.0") + package_a.add_dependency( + Factory.create_dependency( + "B", {"path": "project_with_extras", "develop": True}, root_dir=root_dir + ) + ) + package_a.add_dependency( + Factory.create_dependency( + "C", + {"path": "project_with_transitive_directory_dependencies"}, + root_dir=root_dir, + ) + ) + package_a.add_dependency( + Factory.create_dependency( + "D", {"path": "distributions/demo-0.1.0.tar.gz"}, root_dir=root_dir + ) + ) + package_a.add_dependency( + Factory.create_dependency( + "E", {"url": "https://python-poetry.org/poetry-1.2.0.tar.gz"} + ) + ) + package_a.add_dependency( + Factory.create_dependency( + "F", {"git": "https://github.com/python-poetry/poetry.git", "branch": "foo"} + ) + ) + + packages = [package_a] + + locker.set_lock_data(root, packages) + + with locker.lock.open(encoding="utf-8") as f: + content = f.read() + + expected = """[[package]] +name = "A" +version = "1.0.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +B = {path = "project_with_extras", develop = true} +C = {path = "project_with_transitive_directory_dependencies"} +D = {path = "distributions/demo-0.1.0.tar.gz"} +E = {url = "https://python-poetry.org/poetry-1.2.0.tar.gz"} +F = {git = "https://github.com/python-poetry/poetry.git", branch = "foo"} + +[metadata] +lock-version = "1.1" +python-versions = "*" +content-hash = "178f2cd01dc40e96be23a4a0ae1094816626346346618335e5ff4f0b2c0c5831" + +[metadata.files] +A = [] +""" + + assert content == expected