Skip to content

Commit 8a1b011

Browse files
authored
cache: automatic refresh if files of locked package differ from files in cache (#10657)
When working on a project with many developers, unnecessary diffs in the lockfile due to outdated caches of some developers are an annoying issue. Outdated caches are normally the result of postponed artifact uploads. This change detects such diffs and refreshes the cache automatically.
1 parent 364c2c5 commit 8a1b011

File tree

7 files changed

+230
-6
lines changed

7 files changed

+230
-6
lines changed

src/poetry/puzzle/provider.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def __init__(
151151
)
152152

153153
self.get_package_from_pool = functools.cache(self._pool.package)
154+
self._refreshed: set[tuple[str, Version, str | None]] = set()
154155

155156
@property
156157
def pool(self) -> RepositoryPool:
@@ -471,14 +472,34 @@ def complete_package(
471472
elif package.is_direct_origin():
472473
requires = package.requires
473474
else:
474-
dependency_package = DependencyPackage(
475-
dependency,
476-
self.get_package_from_pool(
475+
if (
476+
package.pretty_name,
477+
package.version,
478+
dependency.source_name,
479+
) in self._refreshed:
480+
# circumvent lru_cache to avoid unnecessary refresh
481+
pool_package = self.pool.package(
477482
package.pretty_name,
478483
package.version,
479484
repository_name=dependency.source_name,
480-
),
481-
)
485+
)
486+
else:
487+
pool_package = self.get_package_from_pool(
488+
package.pretty_name,
489+
package.version,
490+
repository_name=dependency.source_name,
491+
)
492+
if package.files and sorted(
493+
package.files, key=lambda f: f["file"]
494+
) != sorted(pool_package.files, key=lambda f: f["file"]):
495+
# This happens if additional artifacts are uploaded later. Either our own cache
496+
# is outdated or the lockfile has been created with an outdated cache.
497+
# Refresh to cover the first case. (It does not hurt much in the second case.)
498+
pool_package = self.pool.refresh(pool_package)
499+
self._refreshed.add(
500+
(package.pretty_name, package.version, dependency.source_name)
501+
)
502+
dependency_package = DependencyPackage(dependency, pool_package)
482503

483504
package = dependency_package.package
484505
dependency = dependency_package.dependency

src/poetry/repositories/cached_repository.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,6 @@ def package(self, name: str, version: Version) -> Package:
7070
return self.get_release_info(canonicalize_name(name), version).to_package(
7171
name=name
7272
)
73+
74+
def forget(self, name: str, version: Version) -> None:
75+
self._release_cache.forget(f"{canonicalize_name(name)}:{version}")

src/poetry/repositories/repository_pool.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from poetry.config.config import Config
1111
from poetry.repositories.abstract_repository import AbstractRepository
12+
from poetry.repositories.cached_repository import CachedRepository
1213
from poetry.repositories.exceptions import PackageNotFoundError
1314
from poetry.repositories.repository import Repository
1415
from poetry.utils.cache import ArtifactCache
@@ -181,3 +182,10 @@ def search(self, query: str | list[str]) -> list[Package]:
181182
for repo in self.repositories:
182183
results += repo.search(query)
183184
return results
185+
186+
def refresh(self, package: Package) -> Package:
187+
repository_name = package.source_reference or "PyPI"
188+
repo = self.repository(repository_name)
189+
if isinstance(repo, CachedRepository):
190+
repo.forget(package.name, package.version)
191+
return repo.package(package.name, package.version)

tests/console/commands/test_add.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,6 +1854,14 @@ def test_add_does_not_update_locked_dependencies(
18541854
for package in docker_locked, docker_new, foo:
18551855
repo.add_package(package)
18561856

1857+
# set correct files to avoid cache refresh
1858+
if locked:
1859+
docker_locked.files = (
1860+
poetry_with_up_to_date_lockfile.locker.locked_repository()
1861+
.package("docker", Version.parse("4.3.1"))
1862+
.files
1863+
)
1864+
18571865
tester.execute(command)
18581866

18591867
lock_data = poetry_with_up_to_date_lockfile.locker.lock_data

tests/console/commands/test_lock.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import pytest
77

8+
from poetry.core.constraints.version import Version
9+
810
from poetry.packages import Locker
911
from tests.helpers import get_package
1012

@@ -92,7 +94,8 @@ def test_lock_does_not_update_if_not_necessary(
9294
poetry_with_old_lockfile: Poetry,
9395
repo: TestRepository,
9496
) -> None:
95-
repo.add_package(get_package("sampleproject", "1.3.1"))
97+
package = get_package("sampleproject", "1.3.1")
98+
repo.add_package(package)
9699
repo.add_package(get_package("sampleproject", "2.0.0"))
97100

98101
locker = Locker(
@@ -107,6 +110,13 @@ def test_lock_does_not_update_if_not_necessary(
107110
== "1.0"
108111
)
109112

113+
# set correct files to avoid cache refresh
114+
package.files = (
115+
locker.locked_repository()
116+
.package("sampleproject", Version.parse("1.3.1"))
117+
.files
118+
)
119+
110120
tester = command_tester_factory("lock", poetry=poetry_with_old_lockfile)
111121
tester.execute()
112122

tests/puzzle/test_provider.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest
1111

1212
from cleo.io.null_io import NullIO
13+
from packaging.utils import NormalizedName
1314
from packaging.utils import canonicalize_name
1415
from poetry.core.packages.dependency import Dependency
1516
from poetry.core.packages.directory_dependency import DirectoryDependency
@@ -24,6 +25,7 @@
2425
from poetry.packages import DependencyPackage
2526
from poetry.puzzle.provider import IncompatibleConstraintsError
2627
from poetry.puzzle.provider import Provider
28+
from poetry.repositories.cached_repository import CachedRepository
2729
from poetry.repositories.exceptions import PackageNotFoundError
2830
from poetry.repositories.repository import Repository
2931
from poetry.repositories.repository_pool import Priority
@@ -36,6 +38,7 @@
3638
if TYPE_CHECKING:
3739
from pathlib import Path
3840

41+
from poetry.core.constraints.version import Version
3942
from pytest_mock import MockerFixture
4043

4144
from tests.types import FixtureDirGetter
@@ -49,6 +52,18 @@ def run(self, bin: str, *args: str, **kwargs: Any) -> str:
4952
raise EnvCommandError(CalledProcessError(1, "python", output=""))
5053

5154

55+
class MockCachedRepository(CachedRepository):
56+
def _get_release_info(
57+
self, name: NormalizedName, version: Version
58+
) -> dict[str, Any]:
59+
raise NotImplementedError
60+
61+
def package(self, name: str, version: Version) -> Package:
62+
package = super().package(name, version)
63+
package._source_reference = self.name
64+
return package
65+
66+
5267
@pytest.fixture
5368
def root() -> ProjectPackage:
5469
return ProjectPackage("root", "1.2.3")
@@ -72,6 +87,46 @@ def provider(root: ProjectPackage, pool: RepositoryPool) -> Provider:
7287
return Provider(root, pool, NullIO())
7388

7489

90+
@pytest.fixture
91+
def release_info() -> PackageInfo:
92+
return PackageInfo(
93+
name="mylib",
94+
version="1.0",
95+
summary="",
96+
requires_dist=[],
97+
requires_python=">=3.9",
98+
files=[
99+
{
100+
"file": "mylib-1.0-py3-none-any.whl",
101+
"hash": "sha256:dummyhashvalue1234567890abcdef",
102+
},
103+
{
104+
"file": "mylib-1.0.tar.gz",
105+
"hash": "sha256:anotherdummyhashvalueabcdef1234567890",
106+
},
107+
],
108+
cache_version=str(CachedRepository.CACHE_VERSION),
109+
)
110+
111+
112+
@pytest.fixture
113+
def outdated_release_info() -> PackageInfo:
114+
return PackageInfo(
115+
name="mylib",
116+
version="1.0",
117+
summary="",
118+
requires_dist=[],
119+
requires_python=">=3.9",
120+
files=[
121+
{
122+
"file": "mylib-1.0-py3-none-any.whl",
123+
"hash": "sha256:dummyhashvalue1234567890abcdef",
124+
}
125+
],
126+
cache_version=str(CachedRepository.CACHE_VERSION),
127+
)
128+
129+
75130
@pytest.mark.parametrize(
76131
"dependency, expected",
77132
[
@@ -856,6 +911,43 @@ def test_complete_package_raises_packagenotfound_if_locked_source_not_available(
856911
provider.complete_package(locked)
857912

858913

914+
def test_complete_package_outdated_cache(
915+
root: ProjectPackage,
916+
release_info: PackageInfo,
917+
outdated_release_info: PackageInfo,
918+
mocker: MockerFixture,
919+
) -> None:
920+
repo = MockCachedRepository("repo")
921+
pool = RepositoryPool()
922+
pool.add_repository(repo)
923+
provider = Provider(root, pool, NullIO())
924+
pool_refresh_spy = mocker.spy(provider.pool, "refresh")
925+
926+
assert release_info.name is not None
927+
assert release_info.version is not None
928+
package = Package(release_info.name, release_info.version)
929+
package.files = release_info.files # up-to-date files from lock
930+
931+
# trigger caching of outdated info
932+
repo._get_release_info = lambda name, version: outdated_release_info.asdict() # type: ignore[method-assign]
933+
assert len(repo.package(package.name, package.version).files) == 1
934+
935+
# additional files uploaded -> refresh needed
936+
repo._get_release_info = lambda name, version: release_info.asdict() # type: ignore[method-assign]
937+
complete_package = provider.complete_package(
938+
DependencyPackage(package.to_dependency(), package)
939+
)
940+
assert len(complete_package.package.files) == 2
941+
assert pool_refresh_spy.call_count == 1
942+
943+
# cache should have been updated -> no additional refresh needed
944+
complete_package = provider.complete_package(
945+
DependencyPackage(package.to_dependency(), package)
946+
)
947+
assert len(complete_package.package.files) == 2
948+
assert pool_refresh_spy.call_count == 1
949+
950+
859951
def test_source_dependency_is_satisfied_by_direct_origin(
860952
provider: Provider, repository: Repository
861953
) -> None:
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
import pytest
6+
7+
from packaging.utils import NormalizedName
8+
from packaging.utils import canonicalize_name
9+
from poetry.core.constraints.version import Version
10+
11+
from poetry.inspection.info import PackageInfo
12+
from poetry.repositories.cached_repository import CachedRepository
13+
14+
15+
class MockCachedRepository(CachedRepository):
16+
def _get_release_info(
17+
self, name: NormalizedName, version: Version
18+
) -> dict[str, Any]:
19+
raise NotImplementedError
20+
21+
22+
@pytest.fixture
23+
def release_info() -> PackageInfo:
24+
return PackageInfo(
25+
name="mylib",
26+
version="1.0",
27+
summary="",
28+
requires_dist=[],
29+
requires_python=">=3.9",
30+
files=[
31+
{
32+
"file": "mylib-1.0-py3-none-any.whl",
33+
"hash": "sha256:dummyhashvalue1234567890abcdef",
34+
},
35+
{
36+
"file": "mylib-1.0.tar.gz",
37+
"hash": "sha256:anotherdummyhashvalueabcdef1234567890",
38+
},
39+
],
40+
cache_version=str(CachedRepository.CACHE_VERSION),
41+
)
42+
43+
44+
@pytest.fixture
45+
def outdated_release_info() -> PackageInfo:
46+
return PackageInfo(
47+
name="mylib",
48+
version="1.0",
49+
summary="",
50+
requires_dist=[],
51+
requires_python=">=3.9",
52+
files=[
53+
{
54+
"file": "mylib-1.0-py3-none-any.whl",
55+
"hash": "sha256:dummyhashvalue1234567890abcdef",
56+
}
57+
],
58+
cache_version=str(CachedRepository.CACHE_VERSION),
59+
)
60+
61+
62+
@pytest.mark.parametrize("disable_cache", [False, True])
63+
def test_get_release_info_cache(
64+
release_info: PackageInfo, outdated_release_info: PackageInfo, disable_cache: bool
65+
) -> None:
66+
repo = MockCachedRepository("mock", disable_cache=disable_cache)
67+
repo._get_release_info = lambda name, version: outdated_release_info.asdict() # type: ignore[method-assign]
68+
69+
name = canonicalize_name("mylib")
70+
version = Version.parse("1.0")
71+
assert len(repo.get_release_info(name, version).files) == 1
72+
73+
# without disable_cache: cached value is returned even if the underlying data has changed
74+
# with disable_cache: cached value is ignored and updated data is returned
75+
repo._get_release_info = lambda name, version: release_info.asdict() # type: ignore[method-assign]
76+
assert len(repo.get_release_info(name, version).files) == (
77+
2 if disable_cache else 1
78+
)
79+
80+
# after clearing the cache entry, updated data is returned
81+
repo.forget(name, version)
82+
assert len(repo.get_release_info(name, version).files) == 2

0 commit comments

Comments
 (0)