diff --git a/docs/source/changelog.md b/docs/source/changelog.md index 58cef1cf..29fa7c93 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased breaking changes + +`RBuildPack.runtime` previously returned the contents of `runtime.txt` as a string. +It has been replaced by `BuildPack.runtime` which returns a tuple `(name, version, date)`. + ## 2024.07.0 ([full changelog](https://github.com/jupyterhub/repo2docker/compare/2024.03.0...2024.07.0)) diff --git a/repo2docker/buildpacks/base.py b/repo2docker/buildpacks/base.py index f433029e..74bfa331 100644 --- a/repo2docker/buildpacks/base.py +++ b/repo2docker/buildpacks/base.py @@ -1,3 +1,4 @@ +import datetime import hashlib import io import logging @@ -750,3 +751,50 @@ def get_start_script(self): # the only path evaluated at container start time rather than build time return os.path.join("${REPO_DIR}", start) return None + + @property + def runtime(self): + """ + Return parsed contents of runtime.txt + + Returns (runtime, version, date), tuple components may be None. + Returns (None, None, None) if runtime.txt not found. + + Supported formats: + name-version + name-version-yyyy-mm-dd + name-yyyy-mm-dd + """ + if hasattr(self, "_runtime"): + return self._runtime + + self._runtime = (None, None, None) + + runtime_path = self.binder_path("runtime.txt") + try: + with open(runtime_path) as f: + runtime_txt = f.read().strip() + except FileNotFoundError: + return self._runtime + + name = None + version = None + date = None + + parts = runtime_txt.split("-") + if len(parts) not in (2, 4, 5) or any(not (p) for p in parts): + raise ValueError(f"Invalid runtime.txt: {runtime_txt}") + + name = parts[0] + + if len(parts) in (2, 5): + version = parts[1] + + if len(parts) in (4, 5): + date = "-".join(parts[-3:]) + if not re.match(r"\d\d\d\d-\d\d-\d\d", date): + raise ValueError(f"Invalid runtime.txt date: {date}") + date = datetime.datetime.fromisoformat(date).date() + + self._runtime = (name, version, date) + return self._runtime diff --git a/repo2docker/buildpacks/pipfile/__init__.py b/repo2docker/buildpacks/pipfile/__init__.py index 2e24d614..da3f1fd3 100644 --- a/repo2docker/buildpacks/pipfile/__init__.py +++ b/repo2docker/buildpacks/pipfile/__init__.py @@ -187,12 +187,9 @@ def get_assemble_scripts(self): def detect(self): """Check if current repo should be built with the Pipfile buildpack.""" # first make sure python is not explicitly unwanted - runtime_txt = self.binder_path("runtime.txt") - if os.path.exists(runtime_txt): - with open(runtime_txt) as f: - runtime = f.read().strip() - if not runtime.startswith("python-"): - return False + name = self.runtime[0] + if name and name != "python": + return False pipfile = self.binder_path("Pipfile") pipfile_lock = self.binder_path("Pipfile.lock") diff --git a/repo2docker/buildpacks/python/__init__.py b/repo2docker/buildpacks/python/__init__.py index 1530e347..6c9a6d6e 100644 --- a/repo2docker/buildpacks/python/__init__.py +++ b/repo2docker/buildpacks/python/__init__.py @@ -15,14 +15,10 @@ def python_version(self): if hasattr(self, "_python_version"): return self._python_version - try: - with open(self.binder_path("runtime.txt")) as f: - runtime = f.read().strip() - except FileNotFoundError: - runtime = "" - - if not runtime.startswith("python-"): - # not a Python runtime (e.g. R, which subclasses this) + name, version, _ = self.runtime + + if name != "python" or not version: + # Either not specified, or not a Python runtime (e.g. R, which subclasses this) # use the default Python self._python_version = self.major_pythons["3"] self.log.warning( @@ -30,7 +26,7 @@ def python_version(self): ) return self._python_version - py_version_info = runtime.split("-", 1)[1].split(".") + py_version_info = version.split(".") py_version = "" if len(py_version_info) == 1: py_version = self.major_pythons[py_version_info[0]] @@ -138,16 +134,11 @@ def get_assemble_scripts(self): def detect(self): """Check if current repo should be built with the Python buildpack.""" requirements_txt = self.binder_path("requirements.txt") - runtime_txt = self.binder_path("runtime.txt") setup_py = "setup.py" - if os.path.exists(runtime_txt): - with open(runtime_txt) as f: - runtime = f.read().strip() - if runtime.startswith("python-"): - return True - else: - return False + name = self.runtime[0] + if name: + return name == "python" if not self.binder_dir and os.path.exists(setup_py): return True return os.path.exists(requirements_txt) diff --git a/repo2docker/buildpacks/r.py b/repo2docker/buildpacks/r.py index 93148a94..91c5183b 100644 --- a/repo2docker/buildpacks/r.py +++ b/repo2docker/buildpacks/r.py @@ -1,6 +1,6 @@ import datetime import os -import re +import warnings from functools import lru_cache import requests @@ -45,21 +45,6 @@ class RBuildPack(PythonBuildPack): R is installed from https://docs.rstudio.com/resources/install-r/ """ - @property - def runtime(self): - """ - Return contents of runtime.txt if it exists, '' otherwise - """ - if not hasattr(self, "_runtime"): - runtime_path = self.binder_path("runtime.txt") - try: - with open(runtime_path) as f: - self._runtime = f.read().strip() - except FileNotFoundError: - self._runtime = "" - - return self._runtime - @property def r_version(self): """Detect the R version for a given `runtime.txt` @@ -90,11 +75,11 @@ def r_version(self): r_version = version_map["4.2"] if not hasattr(self, "_r_version"): - parts = self.runtime.split("-") + _, version, date = self.runtime # If runtime.txt is not set, or if it isn't of the form r----
, # we don't use any of it in determining r version and just use the default - if len(parts) == 5: - r_version = parts[1] + if version and date: + r_version = version # For versions of form x.y, we want to explicitly provide x.y.z - latest patchlevel # available. Users can however explicitly specify the full version to get something specific if r_version in version_map: @@ -116,15 +101,11 @@ def checkpoint_date(self): Returns '' if no date is specified """ if not hasattr(self, "_checkpoint_date"): - match = re.match(r"r-(\d.\d(.\d)?-)?(\d\d\d\d)-(\d\d)-(\d\d)", self.runtime) - if not match: - self._checkpoint_date = False + name, version, date = self.runtime + if name == "r" and date: + self._checkpoint_date = date else: - # turn the last three groups of the match into a date - self._checkpoint_date = datetime.date( - *[int(s) for s in match.groups()[-3:]] - ) - + self._checkpoint_date = False return self._checkpoint_date def detect(self): @@ -142,13 +123,9 @@ def detect(self): description_R = "DESCRIPTION" if not self.binder_dir and os.path.exists(description_R): - if not self.checkpoint_date: - # no R snapshot date set through runtime.txt - # Set it to two days ago from today - self._checkpoint_date = datetime.date.today() - datetime.timedelta( - days=2 - ) - self._runtime = f"r-{str(self._checkpoint_date)}" + # no R snapshot date set through runtime.txt + # Set it to two days ago from today + self._checkpoint_date = datetime.date.today() - datetime.timedelta(days=2) return True @lru_cache diff --git a/tests/unit/test_buildpack.py b/tests/unit/test_buildpack.py index 38fc5c23..ce84df2f 100644 --- a/tests/unit/test_buildpack.py +++ b/tests/unit/test_buildpack.py @@ -1,9 +1,14 @@ +from datetime import date from os.path import join as pjoin from tempfile import TemporaryDirectory import pytest -from repo2docker.buildpacks import LegacyBinderDockerBuildPack, PythonBuildPack +from repo2docker.buildpacks import ( + BaseImage, + LegacyBinderDockerBuildPack, + PythonBuildPack, +) from repo2docker.utils import chdir @@ -46,3 +51,45 @@ def test_unsupported_python(tmpdir, python_version, base_image): assert bp.python_version == python_version with pytest.raises(ValueError): bp.render() + + +@pytest.mark.parametrize( + "runtime_txt, expected", + [ + (None, (None, None, None)), + ("abc-001", ("abc", "001", None)), + ("abc-001-2025-06-22", ("abc", "001", date(2025, 6, 22))), + ("abc-2025-06-22", ("abc", None, date(2025, 6, 22))), + ("a_b/c-0.0.1-2025-06-22", ("a_b/c", "0.0.1", date(2025, 6, 22))), + ], +) +def test_runtime(tmpdir, runtime_txt, expected, base_image): + tmpdir.chdir() + + if runtime_txt is not None: + with open("runtime.txt", "w") as f: + f.write(runtime_txt) + + base = BaseImage(base_image) + assert base.runtime == expected + + +@pytest.mark.parametrize( + "runtime_txt", + [ + "", + "abc", + "abc-001-25-06-22", + ], +) +def test_invalid_runtime(tmpdir, runtime_txt, base_image): + tmpdir.chdir() + + if runtime_txt is not None: + with open("runtime.txt", "w") as f: + f.write(runtime_txt) + + base = BaseImage(base_image) + + with pytest.raises(ValueError, match=r"^Invalid runtime.txt.*"): + base.runtime