Skip to content

Move runtime.txt parsing into base class #1428

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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

`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
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