from __future__ import annotations

import json
import logging
import os
import sys
import tempfile
import uuid

from hashlib import sha256
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from packaging.utils import canonicalize_name
from poetry.core.constraints.version import Version
from poetry.core.packages.dependency_group import MAIN_GROUP
from poetry.core.packages.package import Package
from poetry.core.packages.project_package import ProjectPackage
from poetry.core.version.markers import AnyMarker
from poetry.core.version.markers import parse_marker

from poetry.__version__ import __version__
from poetry.factory import Factory
from poetry.packages.locker import GENERATED_COMMENT
from poetry.packages.locker import Locker
from poetry.packages.transitive_package_info import TransitivePackageInfo
from tests.helpers import get_dependency
from tests.helpers import get_package


if TYPE_CHECKING:
    from pytest import LogCaptureFixture
    from pytest_mock import MockerFixture


@pytest.fixture
def locker() -> Locker:
    with tempfile.NamedTemporaryFile() as f:
        f.close()
        locker = Locker(Path(f.name), {})

        return locker


@pytest.fixture
def root() -> ProjectPackage:
    return ProjectPackage("root", "1.2.3")


@pytest.fixture
def transitive_info() -> TransitivePackageInfo:
    return TransitivePackageInfo(0, {MAIN_GROUP}, {})


@pytest.mark.parametrize("is_locked", [True, False])
def test_is_locked(locker: Locker, root: ProjectPackage, is_locked: bool) -> None:
    if is_locked:
        locker.set_lock_data(root, {})
    assert locker.is_locked() is is_locked


@pytest.mark.parametrize("is_fresh", [True, False])
def test_is_fresh(
    locker: Locker,
    root: ProjectPackage,
    transitive_info: TransitivePackageInfo,
    is_fresh: bool,
) -> None:
    locker.set_lock_data(root, {})
    if not is_fresh:
        locker.set_pyproject_data(
            {"tool": {"poetry": {"dependencies": {"tomli": "*"}}}}
        )
    assert locker.is_fresh() is is_fresh


@pytest.mark.parametrize("lock_version", [None, "2.0", "2.1"])
def test_is_locked_group_and_markers(
    locker: Locker, root: ProjectPackage, lock_version: str | None
) -> None:
    if lock_version:
        locker.set_lock_data(root, {})
        with locker.lock.open("r", encoding="utf-8") as f:
            content = f.read()
        content = content.replace(locker._VERSION, lock_version)
        with locker.lock.open("w", encoding="utf-8") as f:
            f.write(content)
    assert locker.is_locked_groups_and_markers() is (lock_version == "2.1")


def test_lock_file_data_is_ordered(
    locker: Locker, root: ProjectPackage, transitive_info: TransitivePackageInfo
) -> None:
    package_a = get_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_a2 = get_package("A", "2.0.0")
    package_a2.files = [{"file": "baz", "hash": "345"}]
    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",
    )
    package_git_with_subdirectory = Package(
        "git-package-subdir",
        "1.2.3",
        source_type="git",
        source_url="https://github.com/python-poetry/poetry.git",
        source_reference="develop",
        source_resolved_reference="123456",
        source_subdirectory="subdir",
    )
    package_url_linux = Package(
        "url-package",
        "1.0",
        source_type="url",
        source_url="https://example.org/url-package-1.0-cp39-manylinux_2_17_x86_64.whl",
    )
    package_url_win32 = Package(
        "url-package",
        "1.0",
        source_type="url",
        source_url="https://example.org/url-package-1.0-cp39-win_amd64.whl",
    )
    packages = {
        package_a2: transitive_info,
        package_a: transitive_info,
        get_package("B", "1.2"): transitive_info,
        package_git: transitive_info,
        package_git_with_subdirectory: transitive_info,
        package_url_win32: transitive_info,
        package_url_linux: transitive_info,
    }

    locker.set_lock_data(root, packages)

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = [
    {{file = "bar", hash = "123"}},
    {{file = "foo", hash = "456"}},
]

[package.dependencies]
B = "^1.0"

[[package]]
name = "A"
version = "2.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = [
    {{file = "baz", hash = "345"}},
]

[[package]]
name = "B"
version = "1.2"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[[package]]
name = "git-package"
version = "1.2.3"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []
develop = false

[package.source]
type = "git"
url = "https://github.com/python-poetry/poetry.git"
reference = "develop"
resolved_reference = "123456"

[[package]]
name = "git-package-subdir"
version = "1.2.3"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []
develop = false

[package.source]
type = "git"
url = "https://github.com/python-poetry/poetry.git"
reference = "develop"
resolved_reference = "123456"
subdirectory = "subdir"

[[package]]
name = "url-package"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.source]
type = "url"
url = "https://example.org/url-package-1.0-cp39-manylinux_2_17_x86_64.whl"

[[package]]
name = "url-package"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.source]
type = "url"
url = "https://example.org/url-package-1.0-cp39-win_amd64.whl"

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    assert content == expected


def test_locker_properly_loads_extras(locker: Locker) -> None:
    content = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "cachecontrol"
version = "0.12.5"
description = "httplib2 caching for requests"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
groups = ["main"]
files = []

[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 = "2.1"
python-versions = "~2.7 || ^3.4"
content-hash = "c3d07fca33fba542ef2b2a4d75bf5b48d892d21a830e2ad9c952ba5123a52f77"
"""

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    packages = locker.locked_repository().packages

    assert len(packages) == 1

    package = packages[0]
    assert len(package.requires) == 3
    assert len(package.extras) == 2

    lockfile_dep = package.extras[canonicalize_name("filecache")][0]
    assert lockfile_dep.name == "lockfile"


def test_locker_properly_loads_nested_extras(locker: Locker) -> None:
    content = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "a"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[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 = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.dependencies]
c = {{version = "^1.0", optional = true}}

[package.extras]
c = ["c (>=1.0,<2.0)"]

[[package]]
name = "c"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[metadata]
python-versions = "*"
lock-version = "2.1"
content-hash = "123456789"
"""

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    repository = locker.locked_repository()
    assert len(repository.packages) == 3

    packages = repository.find_packages(get_dependency("a", "1.0"))
    assert len(packages) == 1

    package = packages[0]
    assert len(package.requires) == 1
    assert len(package.extras) == 1

    dependency_b = package.extras[canonicalize_name("b")][0]
    assert dependency_b.name == "b"
    assert dependency_b.extras == frozenset({"c"})

    packages = repository.find_packages(dependency_b)
    assert len(packages) == 1

    package = packages[0]
    assert len(package.requires) == 1
    assert len(package.extras) == 1

    dependency_c = package.extras[canonicalize_name("c")][0]
    assert dependency_c.name == "c"
    assert dependency_c.extras == frozenset()

    packages = repository.find_packages(dependency_c)
    assert len(packages) == 1


def test_locker_properly_loads_extras_legacy(locker: Locker) -> None:
    content = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "a"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.dependencies]
b = {{version = "^1.0", optional = true}}

[package.extras]
b = ["b (^1.0)"]

[[package]]
name = "b"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[metadata]
python-versions = "*"
lock-version = "2.1"
content-hash = "123456789"
"""

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    repository = locker.locked_repository()
    assert len(repository.packages) == 2

    packages = repository.find_packages(get_dependency("a", "1.0"))
    assert len(packages) == 1

    package = packages[0]
    assert len(package.requires) == 1
    assert len(package.extras) == 1

    dependency_b = package.extras[canonicalize_name("b")][0]
    assert dependency_b.name == "b"


def test_locker_properly_loads_subdir(locker: Locker) -> None:
    content = """\
[[package]]
name = "git-package-subdir"
version = "1.2.3"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
develop = false
files = []

[package.source]
type = "git"
url = "https://github.com/python-poetry/poetry.git"
reference = "develop"
resolved_reference = "123456"
subdirectory = "subdir"

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""
    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    repository = locker.locked_repository()
    assert len(repository.packages) == 1

    packages = repository.find_packages(get_dependency("git-package-subdir", "1.2.3"))
    assert len(packages) == 1

    package = packages[0]
    assert package.source_subdirectory == "subdir"


@pytest.mark.parametrize(
    ("groups", "marker", "expected"),
    [
        (["main"], None, {"main": "*"}),
        (
            ["main"],
            repr('python_version == "3.9"'),
            {"main": 'python_version == "3.9"'},
        ),
        (
            ["main", "dev"],
            repr('python_version == "3.9"'),
            {"main": 'python_version == "3.9"', "dev": 'python_version == "3.9"'},
        ),
        (
            ["main", "dev"],
            (
                '{"main" = \'python_version == "3.9"\','
                ' "dev" = \'sys_platform == "linux"\'}'
            ),
            {"main": 'python_version == "3.9"', "dev": 'sys_platform == "linux"'},
        ),
        (
            ["main", "dev"],
            '{"main" = \'python_version == "3.9"\'}',
            {"main": 'python_version == "3.9"', "dev": "*"},
        ),
    ],
)
def test_locker_properly_loads_groups_and_markers(
    locker: Locker, groups: list[str], marker: str, expected: dict[str, str]
) -> None:
    content = rf"""
[[package]]
name = "a"
version = "1.0"
optional = false
python-versions = "*"
groups = {groups}
{"markers = " + marker if marker else ""}
files = []

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""
    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    packages = locker.locked_packages()

    a = get_package("a", "1.0")
    assert len(packages) == 1
    assert packages[a].groups == set(groups)
    assert packages[a].markers == {g: parse_marker(m) for g, m in expected.items()}


def test_locker_properly_assigns_metadata_files(locker: Locker) -> None:
    """
    For multiple constraints dependencies, there is only one common entry in
    metadata.files. However, we must not assign all the files to each of the packages
    because this can result in duplicated and outdated entries when running
    `poetry lock` and hash check failures when running `poetry install`.
    """
    content = """\
[[package]]
name = "demo"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
develop = false

[[package]]
name = "demo"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
develop = false

[package.source]
type = "git"
url = "https://github.com/demo/demo.git"
reference = "main"
resolved_reference = "123456"

[[package]]
name = "demo"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
develop = false

[package.source]
type = "directory"
url = "./folder"

[[package]]
name = "demo"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
develop = false

[package.source]
type = "file"
url = "./demo-1.0-cp39-win_amd64.whl"

[[package]]
name = "demo"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
develop = false

[package.source]
type = "url"
url = "https://example.com/demo-1.0-cp38-win_amd64.whl"

[metadata]
lock-version = "1.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"

[metadata.files]
# metadata.files are only tracked for non-direct origin and file dependencies
demo = [
    {file = "demo-1.0-cp39-win_amd64.whl", hash = "sha256"},
    {file = "demo-1.0.tar.gz", hash = "sha256"},
    {file = "demo-1.0-py3-none-any.whl", hash = "sha256"},
]
"""
    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    repository = locker.locked_repository()
    assert len(repository.packages) == 5
    assert {package.source_type for package in repository.packages} == {
        None,
        "git",
        "directory",
        "file",
        "url",
    }
    for package in repository.packages:
        if package.source_type is None:
            # non-direct origin package contains all files
            # with the current lockfile format we have no chance to determine
            # which files are correct, so we keep all for hash check
            # correct files are set later in Provider.complete_package()
            assert package.files == [
                {"file": "demo-1.0-cp39-win_amd64.whl", "hash": "sha256"},
                {"file": "demo-1.0.tar.gz", "hash": "sha256"},
                {"file": "demo-1.0-py3-none-any.whl", "hash": "sha256"},
            ]
        elif package.source_type == "file":
            assert package.files == [
                {"file": "demo-1.0-cp39-win_amd64.whl", "hash": "sha256"}
            ]
        else:
            package.files = []


def test_lock_packages_with_null_description(
    locker: Locker, root: ProjectPackage, transitive_info: TransitivePackageInfo
) -> None:
    package_a = get_package("A", "1.0.0")
    package_a.description = None  # type: ignore[assignment]

    locker.set_lock_data(root, {package_a: transitive_info})

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    assert content == expected


def test_lock_file_should_not_have_mixed_types(
    locker: Locker, root: ProjectPackage, transitive_info: TransitivePackageInfo
) -> None:
    package_a = get_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 = {canonicalize_name("foo"): [get_dependency("B", ">=1.0.0")]}

    locker.set_lock_data(root, {package_a: transitive_info})

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.dependencies]
B = [
    {{version = "^1.0.0"}},
    {{version = ">=1.0.0", optional = true}},
]

[package.extras]
foo = ["B (>=1.0.0)"]

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    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 = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.extras]
foo = ["bar"]

[package.extras]
foo = ["bar"]

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""
    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_reading_lock_file_should_raise_an_error_on_missing_metadata(
    locker: Locker,
) -> None:
    content = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.source]
type = "legacy"
url = "https://foo.bar"
reference = "legacy"
"""
    with locker.lock.open("w", encoding="utf-8") as f:
        f.write(content)

    with pytest.raises(RuntimeError) as e:
        _ = locker.lock_data

    assert (
        "The lock file does not have a metadata entry.\nRegenerate the lock file with"
        " the `poetry lock` command." in str(e.value)
    )


def test_locking_legacy_repository_package_should_include_source_section(
    root: ProjectPackage, locker: Locker, transitive_info: TransitivePackageInfo
) -> None:
    package_a = Package(
        "A",
        "1.0.0",
        source_type="legacy",
        source_url="https://foo.bar",
        source_reference="legacy",
    )
    packages = {package_a: transitive_info}

    locker.set_lock_data(root, packages)

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.source]
type = "legacy"
url = "https://foo.bar"
reference = "legacy"

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    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"
"""
    caplog.set_level(logging.WARNING, logger="poetry.packages.locker")

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(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 = f"""\
# {GENERATED_COMMENT}

[metadata]
lock-version = "3.0"
python-versions = "~2.7 || ^3.4"
content-hash = "c3d07fca33fba542ef2b2a4d75bf5b48d892d21a830e2ad9c952ba5123a52f77"
"""
    caplog.set_level(logging.WARNING, logger="poetry.packages.locker")

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    with pytest.raises(RuntimeError, match="^The lock file is not compatible"):
        _ = locker.lock_data


def test_locker_should_raise_an_error_if_no_lock_version(
    locker: Locker, caplog: LogCaptureFixture
) -> None:
    """Lock file prior Poetry 1.1 have no lock file version."""
    content = """\
[metadata]
python-versions = "~2.7 || ^3.4"
content-hash = "c3d07fca33fba542ef2b2a4d75bf5b48d892d21a830e2ad9c952ba5123a52f77"
"""
    caplog.set_level(logging.WARNING, logger="poetry.packages.locker")

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    with pytest.raises(RuntimeError, match="^The lock file is not compatible"):
        _ = locker.lock_data


def test_root_extras_dependencies_are_ordered(
    locker: Locker, root: ProjectPackage, fixture_base: Path
) -> None:
    Factory.create_dependency("B", "1.0.0", root_dir=fixture_base)
    Factory.create_dependency("C", "1.0.0", root_dir=fixture_base)
    package_first = Factory.create_dependency("first", "1.0.0", root_dir=fixture_base)
    package_second = Factory.create_dependency("second", "1.0.0", root_dir=fixture_base)
    package_third = Factory.create_dependency("third", "1.0.0", root_dir=fixture_base)

    root.extras = {
        canonicalize_name("C"): [package_third, package_second, package_first],
        canonicalize_name("B"): [package_first, package_second, package_third],
    }
    locker.set_lock_data(root, {})

    expected = f"""\
# {GENERATED_COMMENT}
package = []

[extras]
b = ["first", "second", "third"]
c = ["first", "second", "third"]

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    assert content == expected


def test_extras_dependencies_are_ordered(
    locker: Locker, root: ProjectPackage, transitive_info: TransitivePackageInfo
) -> None:
    package_a = get_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: transitive_info})

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.dependencies]
B = {{version = "^1.0.0", extras = ["a", "b", "c"], optional = true}}

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    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:
    older_version = "1.1"
    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")

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    _ = locker.lock_data

    assert len(caplog.records) == 0


def test_locker_dumps_groups_and_markers(
    locker: Locker,
    root: ProjectPackage,
    fixture_base: Path,
    transitive_info: TransitivePackageInfo,
) -> None:
    packages = {
        get_package("A", "1.0"): TransitivePackageInfo(
            0, {"main"}, {"main": AnyMarker()}
        ),
        get_package("B", "1.0"): TransitivePackageInfo(
            0, {"main"}, {"main": parse_marker('sys_platform == "win32"')}
        ),
        get_package("C", "1.0"): TransitivePackageInfo(
            0, {"main", "dev"}, {"main": AnyMarker(), "dev": AnyMarker()}
        ),
        get_package("D", "1.0"): TransitivePackageInfo(
            0,
            {"main", "dev"},
            {
                "main": parse_marker('sys_platform == "win32"'),
                "dev": parse_marker('sys_platform == "win32"'),
            },
        ),
        get_package("E", "1.0"): TransitivePackageInfo(
            0,
            {"main", "dev"},
            {
                "main": parse_marker('sys_platform == "win32"'),
                "dev": parse_marker('sys_platform == "linux"'),
            },
        ),
        get_package("F", "1.0"): TransitivePackageInfo(
            0,
            {"main", "dev"},
            {
                "main": parse_marker('sys_platform == "win32"'),
                "dev": AnyMarker(),
            },
        ),
    }

    locker.set_lock_data(root, packages)

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[[package]]
name = "B"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
markers = "sys_platform == \\"win32\\""
files = []

[[package]]
name = "C"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main", "dev"]
files = []

[[package]]
name = "D"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main", "dev"]
markers = "sys_platform == \\"win32\\""
files = []

[[package]]
name = "E"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main", "dev"]
files = []
markers = {{main = "sys_platform == \\"win32\\"", dev = "sys_platform == \\"linux\\""}}

[[package]]
name = "F"
version = "1.0"
description = ""
optional = false
python-versions = "*"
groups = ["main", "dev"]
files = []
markers = {{main = "sys_platform == \\"win32\\""}}

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    assert content == expected


def test_locker_dumps_dependency_information_correctly(
    locker: Locker,
    root: ProjectPackage,
    fixture_base: Path,
    transitive_info: TransitivePackageInfo,
) -> None:
    package_a = get_package("A", "1.0.0")
    package_a.add_dependency(
        Factory.create_dependency(
            "B", {"path": "project_with_extras", "develop": True}, root_dir=fixture_base
        )
    )
    package_a.add_dependency(
        Factory.create_dependency(
            "C",
            {"path": "directory/project_with_transitive_directory_dependencies"},
            root_dir=fixture_base,
        )
    )
    package_a.add_dependency(
        Factory.create_dependency(
            "D", {"path": "distributions/demo-0.1.0.tar.gz"}, root_dir=fixture_base
        )
    )
    package_a.add_dependency(
        Factory.create_dependency(
            "E", {"url": "https://files.pythonhosted.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"}
        )
    )
    package_a.add_dependency(
        Factory.create_dependency(
            "G",
            {
                "git": "https://github.com/python-poetry/poetry.git",
                "subdirectory": "bar",
            },
        )
    )
    package_a.add_dependency(
        Factory.create_dependency(
            "H", {"git": "https://github.com/python-poetry/poetry.git", "tag": "baz"}
        )
    )
    package_a.add_dependency(
        Factory.create_dependency(
            "I", {"git": "https://github.com/python-poetry/poetry.git", "rev": "spam"}
        )
    )

    packages = {package_a: transitive_info}

    locker.set_lock_data(root, packages)

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.dependencies]
B = {{path = "project_with_extras", develop = true}}
C = {{path = "directory/project_with_transitive_directory_dependencies"}}
D = {{path = "distributions/demo-0.1.0.tar.gz"}}
E = {{url = "https://files.pythonhosted.org/poetry-1.2.0.tar.gz"}}
F = {{git = "https://github.com/python-poetry/poetry.git", branch = "foo"}}
G = {{git = "https://github.com/python-poetry/poetry.git", subdirectory = "bar"}}
H = {{git = "https://github.com/python-poetry/poetry.git", tag = "baz"}}
I = {{git = "https://github.com/python-poetry/poetry.git", rev = "spam"}}

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    assert content == expected


def test_locker_dumps_subdir(
    locker: Locker, root: ProjectPackage, transitive_info: TransitivePackageInfo
) -> None:
    package_git_with_subdirectory = Package(
        "git-package-subdir",
        "1.2.3",
        source_type="git",
        source_url="https://github.com/python-poetry/poetry.git",
        source_reference="develop",
        source_resolved_reference="123456",
        source_subdirectory="subdir",
    )

    locker.set_lock_data(root, {package_git_with_subdirectory: transitive_info})

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "git-package-subdir"
version = "1.2.3"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []
develop = false

[package.source]
type = "git"
url = "https://github.com/python-poetry/poetry.git"
reference = "develop"
resolved_reference = "123456"
subdirectory = "subdir"

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    assert content == expected


def test_locker_dumps_dependency_extras_in_correct_order(
    locker: Locker,
    root: ProjectPackage,
    fixture_base: Path,
    transitive_info: TransitivePackageInfo,
) -> None:
    package_a = get_package("A", "1.0.0")
    Factory.create_dependency("B", "1.0.0", root_dir=fixture_base)
    Factory.create_dependency("C", "1.0.0", root_dir=fixture_base)
    package_first = Factory.create_dependency("first", "1.0.0", root_dir=fixture_base)
    package_second = Factory.create_dependency("second", "1.0.0", root_dir=fixture_base)
    package_third = Factory.create_dependency("third", "1.0.0", root_dir=fixture_base)

    package_a.extras = {
        canonicalize_name("C"): [package_third, package_second, package_first],
        canonicalize_name("B"): [package_first, package_second, package_third],
    }

    locker.set_lock_data(root, {package_a: transitive_info})

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.extras]
b = ["first (==1.0.0)", "second (==1.0.0)", "third (==1.0.0)"]
c = ["first (==1.0.0)", "second (==1.0.0)", "third (==1.0.0)"]

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    assert content == expected


def test_locker_dumps_extras_with_constraints(
    locker: Locker,
    root: ProjectPackage,
    fixture_base: Path,
    transitive_info: TransitivePackageInfo,
) -> None:
    package_a = get_package("A", "1.0.0")

    package_httpx_new = Factory.create_dependency(
        "httpx",
        {"version": "2.0.0", "python": ">=3.7", "extras": ["brotli"]},
        root_dir=fixture_base,
    )

    package_httpx_old = Factory.create_dependency(
        "httpx", {"version": "1.0.0", "python": "<3.7"}, root_dir=fixture_base
    )

    package_a.extras = {
        canonicalize_name("http"): [package_httpx_new, package_httpx_old],
    }

    locker.set_lock_data(root, {package_a: transitive_info})

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.extras]
http = ["httpx (==1.0.0) ; python_version < \\"3.7\\"", "httpx[brotli] (==2.0.0) ; python_version >= \\"3.7\\""]

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    assert content == expected


def test_locker_properly_loads_extras_with_constraints(locker: Locker) -> None:
    content = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "A"
version = "1.0.0"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.extras]
http = ["httpx (==1.0.0) ; python_version < \\"3.7\\"", "httpx[brotli] (==2.0.0) ; python_version >= \\"3.7\\""]

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    packages = locker.locked_repository().packages

    assert len(packages) == 1

    package = packages[0]
    assert len(package.extras) == 1

    httpx_deps = package.extras[canonicalize_name("http")]
    assert len(httpx_deps) == 2

    assert httpx_deps[0].constraint == Version.parse("1.0.0")
    assert str(httpx_deps[0].python_constraint) == "<3.7"

    assert httpx_deps[1].constraint == Version.parse("2.0.0")
    assert str(httpx_deps[1].python_constraint) == ">=3.7"
    assert httpx_deps[1].extras == {"brotli"}


def test_locked_repository_uses_root_dir_of_package(
    locker: Locker, mocker: MockerFixture
) -> None:
    content = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "lib-a"
version = "0.1.0"
description = ""
optional = false
python-versions = "^2.7.9"
groups = ["main"]
develop = true
file = []

[package.dependencies]
lib-b = {{path = "../libB", develop = true}}

[package.source]
type = "directory"
url = "lib/libA"

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(content)

    create_dependency_patch = mocker.patch(
        "poetry.factory.Factory.create_dependency", autospec=True
    )
    locker.locked_repository()

    create_dependency_patch.assert_called_once_with(
        "lib-b", {"develop": True, "path": "../libB"}, root_dir=mocker.ANY
    )
    call_kwargs = create_dependency_patch.call_args[1]
    root_dir = call_kwargs["root_dir"]
    assert root_dir.match("*/lib/libA")
    # relative_to raises an exception if not relative - is_relative_to comes in py3.9
    assert root_dir.relative_to(locker.lock.parent.resolve()) is not None


@pytest.mark.parametrize(
    ("local_config", "fresh"),
    [
        ({}, True),
        ({"dependencies": [uuid.uuid4().hex]}, True),
        (
            {
                "dependencies": [uuid.uuid4().hex],
                "dev-dependencies": [uuid.uuid4().hex],
            },
            True,
        ),
        (
            {
                "dependencies": [uuid.uuid4().hex],
                "dev-dependencies": None,
            },
            True,
        ),
        ({"dependencies": [uuid.uuid4().hex], "groups": [uuid.uuid4().hex]}, False),
    ],
)
def test_content_hash_with_legacy_is_compatible(
    local_config: dict[str, list[str]], fresh: bool, locker: Locker
) -> None:
    # old hash generation
    relevant_content = {}
    for key in locker._legacy_keys:
        relevant_content[key] = local_config.get(key)

    locker = locker.__class__(
        lock=locker.lock,
        pyproject_data={"tool": {"poetry": local_config}},
    )

    old_content_hash = sha256(
        json.dumps(relevant_content, sort_keys=True).encode()
    ).hexdigest()
    content_hash = locker._get_content_hash()

    assert (content_hash == old_content_hash) or fresh


def test_lock_file_resolves_file_url_symlinks(
    root: ProjectPackage, transitive_info: TransitivePackageInfo
) -> None:
    """
    Create directories and file structure as follows:

    d1/
    d1/testsymlink -> d1/d2/d3
    d1/d2/d3/lock_file
    d1/d4/source_file

    Using the testsymlink as the Locker.lock file path should correctly resolve to
    the real physical path of the source_file when calculating the relative path
    from the lock_file, i.e. "../../d4/source_file" instead of the unresolved path
    from the symlink itself which would have been "../d4/source_file"

    See https://github.com/python-poetry/poetry/issues/5849
    """
    with tempfile.TemporaryDirectory() as d1:
        symlink_path = Path(d1).joinpath("testsymlink")
        with (
            tempfile.TemporaryDirectory(dir=d1) as d2,
            tempfile.TemporaryDirectory(dir=d1) as d4,
            tempfile.TemporaryDirectory(dir=d2) as d3,
            tempfile.NamedTemporaryFile(dir=d4) as source_file,
            tempfile.NamedTemporaryFile(dir=d3) as lock_file,
        ):
            lock_file.close()
            try:
                os.symlink(Path(d3), symlink_path)
            except OSError:
                if sys.platform == "win32":
                    # os.symlink requires either administrative privileges or developer
                    # mode on Win10, throwing an OSError if neither is active.
                    # Test is not possible in that case.
                    return
                raise
            locker = Locker(symlink_path / lock_file.name, {})

            package_local = Package(
                "local-package",
                "1.2.3",
                source_type="file",
                source_url=source_file.name,
                source_reference="develop",
                source_resolved_reference="123456",
            )
            packages = {
                package_local: transitive_info,
            }

            locker.set_lock_data(root, packages)

            with locker.lock.open(encoding="utf-8") as f:
                content = f.read()

            expected = f"""\
# {GENERATED_COMMENT}

[[package]]
name = "local-package"
version = "1.2.3"
description = ""
optional = false
python-versions = "*"
groups = ["main"]
files = []

[package.source]
type = "file"
url = "{
                Path(
                    os.path.relpath(
                        Path(source_file.name).resolve().as_posix(),
                        Path(Path(lock_file.name).parent).resolve().as_posix(),
                    )
                ).as_posix()
            }"
reference = "develop"
resolved_reference = "123456"

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

            assert content == expected


def test_lockfile_is_not_rewritten_if_only_poetry_version_changed(
    locker: Locker, root: ProjectPackage
) -> None:
    generated_comment_old_version = GENERATED_COMMENT.replace(__version__, "1.3.2")
    assert generated_comment_old_version != GENERATED_COMMENT
    old_content = f"""\
# {generated_comment_old_version}
package = []

[metadata]
lock-version = "2.1"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"
"""

    with open(locker.lock, "w", encoding="utf-8") as f:
        f.write(old_content)

    assert not locker.set_lock_data(root, {})

    with locker.lock.open(encoding="utf-8") as f:
        content = f.read()

    assert content == old_content


def test_lockfile_keep_eol(
    locker: Locker, root: ProjectPackage, transitive_info: TransitivePackageInfo
) -> None:
    sep = "\n" if os.linesep == "\r\n" else "\r\n"

    with open(locker.lock, "wb") as f:
        f.write((sep * 10).encode())

    packages = {Package("test", version="0.0.1"): transitive_info}

    assert locker.set_lock_data(root, packages)

    with locker.lock.open(encoding="utf-8", newline="") as f:
        line, *_ = f.read().splitlines(keepends=True)

    if sep == "\r\n":
        assert line.endswith("\r\n")
    else:
        assert not line.endswith("\r\n")
