diff --git a/posit-bakery/posit_bakery/config/dependencies/positron.py b/posit-bakery/posit_bakery/config/dependencies/positron.py index cf3746e2..3f2da6f7 100644 --- a/posit-bakery/posit_bakery/config/dependencies/positron.py +++ b/posit-bakery/posit_bakery/config/dependencies/positron.py @@ -1,4 +1,5 @@ import abc +from functools import cache from typing import Literal, ClassVar from pydantic import ConfigDict @@ -31,18 +32,21 @@ def releases_url(target_arch: str = _DEFAULT_ARCH) -> str: arch = _ARCH_MAP[target_arch] return POSITRON_RELEASES_URL_TEMPLATE.format(arch=arch) - def _fetch_versions(self) -> list[DependencyVersion]: + @staticmethod + @cache + def _fetch_versions() -> list[DependencyVersion]: """Fetch available Positron versions from Posit CDN. Uses the default architecture for version discovery since the version list is identical across architectures. - This method uses caching to avoid repeated network requests. + Memoized so the fetch+parse runs once per bakery invocation regardless + of how many constraint instances ask for it. :return: A sorted list of available Positron versions. """ session = cached_session() - response = session.get(self.releases_url()) + response = session.get(PositronDependency.releases_url()) response.raise_for_status() releases = response.json().get("releases", []) diff --git a/posit-bakery/posit_bakery/config/dependencies/python.py b/posit-bakery/posit_bakery/config/dependencies/python.py index 7240ab22..22003f06 100644 --- a/posit-bakery/posit_bakery/config/dependencies/python.py +++ b/posit-bakery/posit_bakery/config/dependencies/python.py @@ -1,4 +1,5 @@ import abc +from functools import cache from typing import Literal, ClassVar from pydantic import ConfigDict @@ -17,10 +18,13 @@ class PythonDependency(BakeryYAMLModel, abc.ABC): dependency: Literal[SupportedDependencies.PYTHON] = SupportedDependencies.PYTHON - def _fetch_versions(self) -> list[DependencyVersion]: + @staticmethod + @cache + def _fetch_versions() -> list[DependencyVersion]: """Fetch available Python versions from astral-sh/python-build-standalone. - This method uses caching to avoid repeated network requests. + Memoized so the fetch+parse runs once per bakery invocation regardless + of how many constraint instances ask for it. The results only include cpython builds for linux. Prerelease versions are excluded. diff --git a/posit-bakery/posit_bakery/config/dependencies/quarto.py b/posit-bakery/posit_bakery/config/dependencies/quarto.py index 7c93a153..f689e665 100644 --- a/posit-bakery/posit_bakery/config/dependencies/quarto.py +++ b/posit-bakery/posit_bakery/config/dependencies/quarto.py @@ -1,4 +1,5 @@ import abc +from functools import cache from typing import Annotated, Literal, ClassVar from pydantic import ConfigDict, Field, field_validator @@ -28,11 +29,14 @@ class QuartoDependency(BakeryYAMLModel, abc.ABC): ), ] - def _fetch_versions(self) -> list[DependencyVersion]: + @staticmethod + @cache + def _fetch_versions(prerelease: bool = False) -> list[DependencyVersion]: """Fetch available Quarto versions. Only the latest patch version for each minor version is included. - This method uses caching to avoid repeated network requests. + Memoized so the fetch+parse runs once per bakery invocation per + ``prerelease`` value (at most two entries). :return: A sorted list of available Quarto versions. """ @@ -44,7 +48,7 @@ def _fetch_versions(self) -> list[DependencyVersion]: response.raise_for_status() versions.append(DependencyVersion(response.json().get("version"))) - if self.prerelease: + if prerelease: # Fetch prerelease version response = session.get(QUARTO_PRERELEASE_URL) response.raise_for_status() @@ -64,7 +68,7 @@ def available_versions(self) -> list[DependencyVersion]: :return: A sorted list of available Quarto versions. """ - return self._fetch_versions() + return self._fetch_versions(self.prerelease) class QuartoDependencyVersions(DependencyVersions, QuartoDependency): diff --git a/posit-bakery/posit_bakery/config/dependencies/r.py b/posit-bakery/posit_bakery/config/dependencies/r.py index 3cf8551c..c2c58f0b 100644 --- a/posit-bakery/posit_bakery/config/dependencies/r.py +++ b/posit-bakery/posit_bakery/config/dependencies/r.py @@ -1,4 +1,5 @@ import abc +from functools import cache from typing import Literal, ClassVar from pydantic import ConfigDict @@ -17,10 +18,13 @@ class RDependency(BakeryYAMLModel, abc.ABC): dependency: Literal[SupportedDependencies.R] = SupportedDependencies.R - def _fetch_versions(self) -> list[DependencyVersion]: + @staticmethod + @cache + def _fetch_versions() -> list[DependencyVersion]: """Fetch available R versions from Posit. - This method uses caching to avoid repeated network requests. + Memoized so the fetch+parse runs once per bakery invocation regardless + of how many constraint instances ask for it. The results exclude "devel" and "next" versions. diff --git a/posit-bakery/posit_bakery/util.py b/posit-bakery/posit_bakery/util.py index 8c3d0ba2..8011b3aa 100644 --- a/posit-bakery/posit_bakery/util.py +++ b/posit-bakery/posit_bakery/util.py @@ -1,5 +1,6 @@ import logging import os +from functools import cache from pathlib import Path from shutil import which from typing import Union @@ -63,17 +64,19 @@ def auto_path() -> Path: return context -def cached_session(**kwargs) -> CachedSession: - """Create a cached requests session with default settings.""" - session_kwargs = { - "cache_name": "bakery_cache", - "expire_after": 3600, - "backend": "filesystem", - "use_temp": True, - "allowable_methods": ["GET"], - "allowable_codes": [200], - "stale_if_error": True, - } - session_kwargs.update(kwargs) +@cache +def cached_session() -> CachedSession: + """Return a process-wide cached requests session. - return CachedSession(**session_kwargs) + Memoized so backend initialization and the accompanying log chatter only + happen once per bakery invocation. + """ + return CachedSession( + cache_name="bakery_cache", + expire_after=3600, + backend="filesystem", + use_temp=True, + allowable_methods=["GET"], + allowable_codes=[200], + stale_if_error=True, + ) diff --git a/posit-bakery/test/config/conftest.py b/posit-bakery/test/config/conftest.py index 9cb56774..58a23a19 100644 --- a/posit-bakery/test/config/conftest.py +++ b/posit-bakery/test/config/conftest.py @@ -92,6 +92,21 @@ def patch_testdata_response(url: str): @pytest.fixture(scope="function") def disable_requests_caching(mocker): + # The session factory and fetch helpers are decorated with @functools.cache, + # so values returned by an earlier test would otherwise survive into the + # next one. Clear the per-process caches so each test's patches take effect. + from posit_bakery.config.dependencies.positron import PositronDependency + from posit_bakery.config.dependencies.python import PythonDependency + from posit_bakery.config.dependencies.quarto import QuartoDependency + from posit_bakery.config.dependencies.r import RDependency + from posit_bakery.util import cached_session + + cached_session.cache_clear() + PythonDependency._fetch_versions.cache_clear() + RDependency._fetch_versions.cache_clear() + QuartoDependency._fetch_versions.cache_clear() + PositronDependency._fetch_versions.cache_clear() + return mocker.patch("posit_bakery.util.CachedSession", spec=requests.Session)