diff --git a/src/poetry/console/commands/init.py b/src/poetry/console/commands/init.py index 42f8c705210..d59bfe672f2 100644 --- a/src/poetry/console/commands/init.py +++ b/src/poetry/console/commands/init.py @@ -1,9 +1,6 @@ from __future__ import annotations -import os -import re import sys -import urllib.parse from pathlib import Path from typing import TYPE_CHECKING @@ -15,6 +12,7 @@ from poetry.console.commands.command import Command from poetry.console.commands.env_command import EnvCommand +from poetry.utils.dependency_specification import parse_dependency_specification from poetry.utils.helpers import canonicalize_name @@ -402,137 +400,19 @@ def _find_best_version_for_package( def _parse_requirements(self, requirements: list[str]) -> list[dict[str, Any]]: from poetry.core.pyproject.exceptions import PyProjectException - from poetry.puzzle.provider import Provider - - result = [] - try: cwd = self.poetry.file.parent except (PyProjectException, RuntimeError): cwd = Path.cwd() - for requirement in requirements: - requirement = requirement.strip() - extras = [] - extras_m = re.search(r"\[([\w\d,-_ ]+)\]$", requirement) - if extras_m: - extras = [e.strip() for e in extras_m.group(1).split(",")] - requirement, _ = requirement.split("[") - - url_parsed = urllib.parse.urlparse(requirement) - if url_parsed.scheme and url_parsed.netloc: - # Url - if url_parsed.scheme in ["git+https", "git+ssh"]: - from poetry.core.vcs.git import Git - from poetry.core.vcs.git import ParsedUrl - - parsed = ParsedUrl.parse(requirement) - url = Git.normalize_url(requirement) - - pair = {"name": parsed.name, "git": url.url} - if parsed.rev: - pair["rev"] = url.revision - - if extras: - pair["extras"] = extras - - source_root = ( - self.env.path.joinpath("src") - if isinstance(self, EnvCommand) and self.env - else None - ) - package = Provider.get_package_from_vcs( - "git", url=url.url, rev=pair.get("rev"), source_root=source_root - ) - pair["name"] = package.name - result.append(pair) - - continue - elif url_parsed.scheme in ["http", "https"]: - package = Provider.get_package_from_url(requirement) - - pair = {"name": package.name, "url": package.source_url} - if extras: - pair["extras"] = extras - - result.append(pair) - continue - elif (os.path.sep in requirement or "/" in requirement) and ( - cwd.joinpath(requirement).exists() - or Path(requirement).expanduser().exists() - and Path(requirement).expanduser().is_absolute() - ): - path = Path(requirement).expanduser() - is_absolute = path.is_absolute() - - if not path.is_absolute(): - path = cwd.joinpath(requirement) - - if path.is_file(): - package = Provider.get_package_from_file(path.resolve()) - else: - package = Provider.get_package_from_directory(path.resolve()) - - result.append( - dict( - [ - ("name", package.name), - ( - "path", - path.relative_to(cwd).as_posix() - if not is_absolute - else path.as_posix(), - ), - ] - + ([("extras", extras)] if extras else []) - ) - ) - - continue - - pair = re.sub( - "^([^@=: ]+)(?:@|==|(?~!])=|:| )(.*)$", "\\1 \\2", requirement + return [ + parse_dependency_specification( + requirement=requirement, + env=self.env if isinstance(self, EnvCommand) and self.env else None, + cwd=cwd, ) - pair = pair.strip() - - require: dict[str, str] = {} - if " " in pair: - name, version = pair.split(" ", 2) - extras_m = re.search(r"\[([\w\d,-_]+)\]$", name) - if extras_m: - extras = [e.strip() for e in extras_m.group(1).split(",")] - name, _ = name.split("[") - - require["name"] = name - if version != "latest": - require["version"] = version - else: - m = re.match( - r"^([^><=!: ]+)((?:>=|<=|>|<|!=|~=|~|\^).*)$", requirement.strip() - ) - if m: - name, constraint = m.group(1), m.group(2) - extras_m = re.search(r"\[([\w\d,-_]+)\]$", name) - if extras_m: - extras = [e.strip() for e in extras_m.group(1).split(",")] - name, _ = name.split("[") - - require["name"] = name - require["version"] = constraint - else: - extras_m = re.search(r"\[([\w\d,-_]+)\]$", pair) - if extras_m: - extras = [e.strip() for e in extras_m.group(1).split(",")] - pair, _ = pair.split("[") - - require["name"] = pair - - if extras: - require["extras"] = extras - - result.append(require) - - return result + for requirement in requirements + ] def _format_requirements( self, requirements: list[dict[str, str]] diff --git a/src/poetry/utils/dependency_specification.py b/src/poetry/utils/dependency_specification.py new file mode 100644 index 00000000000..e9bfbe7637d --- /dev/null +++ b/src/poetry/utils/dependency_specification.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import contextlib +import os +import re +import urllib.parse + +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Dict +from typing import List +from typing import Union +from typing import cast + +from poetry.core.packages.dependency import Dependency +from poetry.core.packages.vcs_dependency import VCSDependency + +from poetry.puzzle.provider import Provider + + +if TYPE_CHECKING: + from poetry.utils.env import Env + + +DependencySpec = Dict[str, Union[str, Dict[str, Union[str, bool]], List[str]]] + + +def _parse_dependency_specification_git_url( + requirement: str, env: Env | None = None +) -> DependencySpec | None: + from poetry.core.vcs.git import Git + from poetry.core.vcs.git import ParsedUrl + + parsed = ParsedUrl.parse(requirement) + url = Git.normalize_url(requirement) + + pair = {"name": parsed.name, "git": url.url} + if parsed.rev: + pair["rev"] = url.revision + + source_root = env.path.joinpath("src") if env else None + package = Provider.get_package_from_vcs( + "git", url=url.url, rev=pair.get("rev"), source_root=source_root + ) + pair["name"] = package.name + return pair + + +def _parse_dependency_specification_url( + requirement: str, env: Env | None = None +) -> DependencySpec | None: + url_parsed = urllib.parse.urlparse(requirement) + if not (url_parsed.scheme and url_parsed.netloc): + return None + + if url_parsed.scheme in ["git+https", "git+ssh"]: + return _parse_dependency_specification_git_url(requirement, env) + + if url_parsed.scheme in ["http", "https"]: + package = Provider.get_package_from_url(requirement) + return {"name": package.name, "url": cast(str, package.source_url)} + + return None + + +def _parse_dependency_specification_path( + requirement: str, cwd: Path +) -> DependencySpec | None: + if (os.path.sep in requirement or "/" in requirement) and ( + cwd.joinpath(requirement).exists() + or Path(requirement).expanduser().exists() + and Path(requirement).expanduser().is_absolute() + ): + path = Path(requirement).expanduser() + is_absolute = path.is_absolute() + + if not path.is_absolute(): + path = cwd.joinpath(requirement) + + if path.is_file(): + package = Provider.get_package_from_file(path.resolve()) + else: + package = Provider.get_package_from_directory(path.resolve()) + + return { + "name": package.name, + "path": path.relative_to(cwd).as_posix() + if not is_absolute + else path.as_posix(), + } + + return None + + +def _parse_dependency_specification_simple( + requirement: str, +) -> DependencySpec | None: + extras: list[str] = [] + pair = re.sub("^([^@=: ]+)(?:@|==|(?~!])=|:| )(.*)$", "\\1 \\2", requirement) + pair = pair.strip() + + require: DependencySpec = {} + + if " " in pair: + name, version = pair.split(" ", 2) + extras_m = re.search(r"\[([\w\d,-_]+)\]$", name) + if extras_m: + extras = [e.strip() for e in extras_m.group(1).split(",")] + name, _ = name.split("[") + + require["name"] = name + if version != "latest": + require["version"] = version + else: + m = re.match(r"^([^><=!: ]+)((?:>=|<=|>|<|!=|~=|~|\^).*)$", requirement.strip()) + if m: + name, constraint = m.group(1), m.group(2) + extras_m = re.search(r"\[([\w\d,-_]+)\]$", name) + if extras_m: + extras = [e.strip() for e in extras_m.group(1).split(",")] + name, _ = name.split("[") + + require["name"] = name + require["version"] = constraint + else: + extras_m = re.search(r"\[([\w\d,-_]+)\]$", pair) + if extras_m: + extras = [e.strip() for e in extras_m.group(1).split(",")] + pair, _ = pair.split("[") + + require["name"] = pair + + if extras: + require["extras"] = extras + + return require + + +def dependency_to_specification(dependency: Dependency) -> DependencySpec: + specification: DependencySpec = {} + + if dependency.is_vcs(): + dependency = cast(VCSDependency, dependency) + specification[dependency.vcs] = cast(str, dependency.source_url) + if dependency.reference: + specification["rev"] = dependency.reference + elif dependency.is_file() or dependency.is_directory(): + specification["path"] = cast(str, dependency.source_url) + elif dependency.is_url(): + specification["url"] = cast(str, dependency.source_url) + elif dependency.pretty_constraint != "*" and not dependency.constraint.is_empty(): + specification["version"] = dependency.pretty_constraint + + if not dependency.marker.is_any(): + specification["markers"] = str(dependency.marker) + + if dependency.extras: + specification["extras"] = sorted(dependency.extras) + + return specification + + +def pep508_to_dependency_specification(requirement: str) -> DependencySpec | None: + if " ; " not in requirement and re.search(r"@[\^~!=<>\d]", requirement): + # this is of the form package@, do not attempt to parse it + return None + + with contextlib.suppress(ValueError): + dependency = Dependency.create_from_pep_508(requirement) + specification = dependency_to_specification(dependency) + + if specification: + specification["name"] = dependency.name + return specification + + return None + + +def parse_dependency_specification( + requirement: str, env: Env | None = None, cwd: Path | None = None +) -> DependencySpec: + requirement = requirement.strip() + cwd = cwd or Path.cwd() + + specification = pep508_to_dependency_specification(requirement) + + if specification is not None: + return specification + + extras = [] + extras_m = re.search(r"\[([\w\d,-_ ]+)\]$", requirement) + if extras_m: + extras = [e.strip() for e in extras_m.group(1).split(",")] + requirement, _ = requirement.split("[") + + specification = ( + _parse_dependency_specification_url(requirement, env=env) + or _parse_dependency_specification_path(requirement, cwd=cwd) + or _parse_dependency_specification_simple(requirement) + ) + + if specification: + if extras and "extras" not in specification: + specification["extras"] = extras + return specification + + raise ValueError(f"Invalid dependency specification: {requirement}") diff --git a/tests/utils/test_dependency_specification.py b/tests/utils/test_dependency_specification.py new file mode 100644 index 00000000000..bed571d20d1 --- /dev/null +++ b/tests/utils/test_dependency_specification.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from deepdiff import DeepDiff + +from poetry.utils.dependency_specification import parse_dependency_specification + + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + from poetry.utils.dependency_specification import DependencySpec + + +@pytest.mark.parametrize( + ("requirement", "specification"), + [ + ( + "git+https://github.com/demo/demo.git", + {"git": "https://github.com/demo/demo.git", "name": "demo"}, + ), + ( + "git+ssh://github.com/demo/demo.git", + {"git": "ssh://github.com/demo/demo.git", "name": "demo"}, + ), + ( + "git+https://github.com/demo/demo.git#main", + {"git": "https://github.com/demo/demo.git", "name": "demo", "rev": "main"}, + ), + ( + "git+https://github.com/demo/demo.git@main", + {"git": "https://github.com/demo/demo.git", "name": "demo", "rev": "main"}, + ), + ("demo", {"name": "demo"}), + ("demo@1.0.0", {"name": "demo", "version": "1.0.0"}), + ("demo@^1.0.0", {"name": "demo", "version": "^1.0.0"}), + ("demo@==1.0.0", {"name": "demo", "version": "==1.0.0"}), + ("demo@!=1.0.0", {"name": "demo", "version": "!=1.0.0"}), + ("demo@~1.0.0", {"name": "demo", "version": "~1.0.0"}), + ("demo[a,b]@1.0.0", {"name": "demo", "version": "1.0.0", "extras": ["a", "b"]}), + ("demo[a,b]", {"name": "demo", "extras": ["a", "b"]}), + ("../demo", {"name": "demo", "path": "../demo"}), + ("../demo/demo.whl", {"name": "demo", "path": "../demo/demo.whl"}), + ( + "https://example.com/packages/demo-0.1.0.tar.gz", + {"name": "demo", "url": "https://example.com/packages/demo-0.1.0.tar.gz"}, + ), + # PEP 508 inputs + ( + "poetry-core (>=1.0.7,<1.1.0)", + {"name": "poetry-core", "version": ">=1.0.7,<1.1.0"}, + ), + ( + 'requests [security,tests] >= 2.8.1, == 2.8.* ; python_version < "2.7"', + { + "name": "requests", + "markers": 'python_version < "2.7"', + "version": ">=2.8.1,<2.9.0", + "extras": ["security", "tests"], + }, + ), + ("name (>=3,<4)", {"name": "name", "version": ">=3,<4"}), + ( + "name@http://foo.com", + {"name": "name", "url": "http://foo.com"}, + ), + ( + "name [fred,bar] @ http://foo.com ; python_version=='2.7'", + { + "name": "name", + "markers": 'python_version == "2.7"', + "url": "http://foo.com", + # This is commented out as there is a bug in + # Dependency.create_from_pep_508 that leads to incorrect + # URL Dependency creation. + # should be: "extras": ["fred", "bar"], + }, + ), + ( + 'cachecontrol[filecache] (>=0.12.9,<0.13.0); python_version >= "3.6" and' + ' python_version < "4.0"', + { + "version": ">=0.12.9,<0.13.0", + "markers": 'python_version >= "3.6" and python_version < "4.0"', + "extras": ["filecache"], + "name": "cachecontrol", + }, + ), + ], +) +def test_parse_dependency_specification( + requirement: str, specification: DependencySpec, mocker: MockerFixture +) -> None: + original = Path.exists + + def _mock(self: Path) -> bool: + if "/" in requirement and self == Path.cwd().joinpath(requirement): + return True + return original(self) + + mocker.patch("pathlib.Path.exists", _mock) + + assert not DeepDiff( + parse_dependency_specification(requirement), specification, ignore_order=True + )