Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased breaking changes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this will require a rebase given that we made a release in August 2025, see https://github.com/jupyterhub/repo2docker/releases/tag/2025.08.0.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog wasn't updated for that release, I've added to reminder to fix that
#1449


`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))
Expand Down
48 changes: 48 additions & 0 deletions repo2docker/buildpacks/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import hashlib
import io
import logging
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only support Python and R. I prefer to validate name against a list.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered that, but it breaks the abstraction where subclasses are responsible for calling and validating .runtime. runtime.txt is only parsed if the property is accessed, so we can't guarantee any validation.

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
9 changes: 3 additions & 6 deletions repo2docker/buildpacks/pipfile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
25 changes: 8 additions & 17 deletions repo2docker/buildpacks/python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,18 @@ 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(
f"Python version unspecified, using current default Python version {self._python_version}. This will change in the future."
)
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]]
Expand Down Expand Up @@ -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)
45 changes: 11 additions & 34 deletions repo2docker/buildpacks/r.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import datetime
import os
import re
import warnings
from functools import lru_cache

import requests
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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-<version>-<yyyy>-<mm>-<dd>,
# 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:
Expand All @@ -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):
Expand All @@ -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
Expand Down
49 changes: 48 additions & 1 deletion tests/unit/test_buildpack.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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