Skip to content

Commit 664c272

Browse files
authored
Merge pull request #1428 from manics/runtime-version-date
Move `runtime.txt` parsing into base class
2 parents f2de11f + 53cc506 commit 664c272

File tree

6 files changed

+123
-58
lines changed

6 files changed

+123
-58
lines changed

docs/source/changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## Unreleased breaking changes
4+
5+
`RBuildPack.runtime` previously returned the contents of `runtime.txt` as a string.
6+
It has been replaced by `BuildPack.runtime` which returns a tuple `(name, version, date)`.
7+
38
## 2024.07.0
49

510
([full changelog](https://github.com/jupyterhub/repo2docker/compare/2024.03.0...2024.07.0))

repo2docker/buildpacks/base.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import datetime
12
import hashlib
23
import io
34
import logging
@@ -750,3 +751,50 @@ def get_start_script(self):
750751
# the only path evaluated at container start time rather than build time
751752
return os.path.join("${REPO_DIR}", start)
752753
return None
754+
755+
@property
756+
def runtime(self):
757+
"""
758+
Return parsed contents of runtime.txt
759+
760+
Returns (runtime, version, date), tuple components may be None.
761+
Returns (None, None, None) if runtime.txt not found.
762+
763+
Supported formats:
764+
name-version
765+
name-version-yyyy-mm-dd
766+
name-yyyy-mm-dd
767+
"""
768+
if hasattr(self, "_runtime"):
769+
return self._runtime
770+
771+
self._runtime = (None, None, None)
772+
773+
runtime_path = self.binder_path("runtime.txt")
774+
try:
775+
with open(runtime_path) as f:
776+
runtime_txt = f.read().strip()
777+
except FileNotFoundError:
778+
return self._runtime
779+
780+
name = None
781+
version = None
782+
date = None
783+
784+
parts = runtime_txt.split("-")
785+
if len(parts) not in (2, 4, 5) or any(not (p) for p in parts):
786+
raise ValueError(f"Invalid runtime.txt: {runtime_txt}")
787+
788+
name = parts[0]
789+
790+
if len(parts) in (2, 5):
791+
version = parts[1]
792+
793+
if len(parts) in (4, 5):
794+
date = "-".join(parts[-3:])
795+
if not re.match(r"\d\d\d\d-\d\d-\d\d", date):
796+
raise ValueError(f"Invalid runtime.txt date: {date}")
797+
date = datetime.datetime.fromisoformat(date).date()
798+
799+
self._runtime = (name, version, date)
800+
return self._runtime

repo2docker/buildpacks/pipfile/__init__.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,9 @@ def get_assemble_scripts(self):
187187
def detect(self):
188188
"""Check if current repo should be built with the Pipfile buildpack."""
189189
# first make sure python is not explicitly unwanted
190-
runtime_txt = self.binder_path("runtime.txt")
191-
if os.path.exists(runtime_txt):
192-
with open(runtime_txt) as f:
193-
runtime = f.read().strip()
194-
if not runtime.startswith("python-"):
195-
return False
190+
name = self.runtime[0]
191+
if name and name != "python":
192+
return False
196193

197194
pipfile = self.binder_path("Pipfile")
198195
pipfile_lock = self.binder_path("Pipfile.lock")

repo2docker/buildpacks/python/__init__.py

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,18 @@ def python_version(self):
1515
if hasattr(self, "_python_version"):
1616
return self._python_version
1717

18-
try:
19-
with open(self.binder_path("runtime.txt")) as f:
20-
runtime = f.read().strip()
21-
except FileNotFoundError:
22-
runtime = ""
23-
24-
if not runtime.startswith("python-"):
25-
# not a Python runtime (e.g. R, which subclasses this)
18+
name, version, _ = self.runtime
19+
20+
if name != "python" or not version:
21+
# Either not specified, or not a Python runtime (e.g. R, which subclasses this)
2622
# use the default Python
2723
self._python_version = self.major_pythons["3"]
2824
self.log.warning(
2925
f"Python version unspecified, using current default Python version {self._python_version}. This will change in the future."
3026
)
3127
return self._python_version
3228

33-
py_version_info = runtime.split("-", 1)[1].split(".")
29+
py_version_info = version.split(".")
3430
py_version = ""
3531
if len(py_version_info) == 1:
3632
py_version = self.major_pythons[py_version_info[0]]
@@ -138,16 +134,11 @@ def get_assemble_scripts(self):
138134
def detect(self):
139135
"""Check if current repo should be built with the Python buildpack."""
140136
requirements_txt = self.binder_path("requirements.txt")
141-
runtime_txt = self.binder_path("runtime.txt")
142137
setup_py = "setup.py"
143138

144-
if os.path.exists(runtime_txt):
145-
with open(runtime_txt) as f:
146-
runtime = f.read().strip()
147-
if runtime.startswith("python-"):
148-
return True
149-
else:
150-
return False
139+
name = self.runtime[0]
140+
if name:
141+
return name == "python"
151142
if not self.binder_dir and os.path.exists(setup_py):
152143
return True
153144
return os.path.exists(requirements_txt)

repo2docker/buildpacks/r.py

Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import datetime
22
import os
3-
import re
3+
import warnings
44
from functools import lru_cache
55

66
import requests
@@ -45,21 +45,6 @@ class RBuildPack(PythonBuildPack):
4545
R is installed from https://docs.rstudio.com/resources/install-r/
4646
"""
4747

48-
@property
49-
def runtime(self):
50-
"""
51-
Return contents of runtime.txt if it exists, '' otherwise
52-
"""
53-
if not hasattr(self, "_runtime"):
54-
runtime_path = self.binder_path("runtime.txt")
55-
try:
56-
with open(runtime_path) as f:
57-
self._runtime = f.read().strip()
58-
except FileNotFoundError:
59-
self._runtime = ""
60-
61-
return self._runtime
62-
6348
@property
6449
def r_version(self):
6550
"""Detect the R version for a given `runtime.txt`
@@ -91,11 +76,11 @@ def r_version(self):
9176
r_version = version_map["4.4"]
9277

9378
if not hasattr(self, "_r_version"):
94-
parts = self.runtime.split("-")
79+
_, version, date = self.runtime
9580
# If runtime.txt is not set, or if it isn't of the form r-<version>-<yyyy>-<mm>-<dd>,
9681
# we don't use any of it in determining r version and just use the default
97-
if len(parts) == 5:
98-
r_version = parts[1]
82+
if version and date:
83+
r_version = version
9984
# For versions of form x.y, we want to explicitly provide x.y.z - latest patchlevel
10085
# available. Users can however explicitly specify the full version to get something specific
10186
if r_version in version_map:
@@ -117,15 +102,11 @@ def checkpoint_date(self):
117102
Returns '' if no date is specified
118103
"""
119104
if not hasattr(self, "_checkpoint_date"):
120-
match = re.match(r"r-(\d.\d(.\d)?-)?(\d\d\d\d)-(\d\d)-(\d\d)", self.runtime)
121-
if not match:
122-
self._checkpoint_date = False
105+
name, version, date = self.runtime
106+
if name == "r" and date:
107+
self._checkpoint_date = date
123108
else:
124-
# turn the last three groups of the match into a date
125-
self._checkpoint_date = datetime.date(
126-
*[int(s) for s in match.groups()[-3:]]
127-
)
128-
109+
self._checkpoint_date = False
129110
return self._checkpoint_date
130111

131112
def detect(self):
@@ -143,13 +124,9 @@ def detect(self):
143124

144125
description_R = "DESCRIPTION"
145126
if not self.binder_dir and os.path.exists(description_R):
146-
if not self.checkpoint_date:
147-
# no R snapshot date set through runtime.txt
148-
# Set it to two days ago from today
149-
self._checkpoint_date = datetime.date.today() - datetime.timedelta(
150-
days=2
151-
)
152-
self._runtime = f"r-{str(self._checkpoint_date)}"
127+
# no R snapshot date set through runtime.txt
128+
# Set it to two days ago from today
129+
self._checkpoint_date = datetime.date.today() - datetime.timedelta(days=2)
153130
return True
154131

155132
@lru_cache

tests/unit/test_buildpack.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
from datetime import date
12
from os.path import join as pjoin
23
from tempfile import TemporaryDirectory
34

45
import pytest
56

6-
from repo2docker.buildpacks import LegacyBinderDockerBuildPack, PythonBuildPack
7+
from repo2docker.buildpacks import (
8+
BaseImage,
9+
LegacyBinderDockerBuildPack,
10+
PythonBuildPack,
11+
)
712
from repo2docker.utils import chdir
813

914

@@ -46,3 +51,45 @@ def test_unsupported_python(tmpdir, python_version, base_image):
4651
assert bp.python_version == python_version
4752
with pytest.raises(ValueError):
4853
bp.render()
54+
55+
56+
@pytest.mark.parametrize(
57+
"runtime_txt, expected",
58+
[
59+
(None, (None, None, None)),
60+
("abc-001", ("abc", "001", None)),
61+
("abc-001-2025-06-22", ("abc", "001", date(2025, 6, 22))),
62+
("abc-2025-06-22", ("abc", None, date(2025, 6, 22))),
63+
("a_b/c-0.0.1-2025-06-22", ("a_b/c", "0.0.1", date(2025, 6, 22))),
64+
],
65+
)
66+
def test_runtime(tmpdir, runtime_txt, expected, base_image):
67+
tmpdir.chdir()
68+
69+
if runtime_txt is not None:
70+
with open("runtime.txt", "w") as f:
71+
f.write(runtime_txt)
72+
73+
base = BaseImage(base_image)
74+
assert base.runtime == expected
75+
76+
77+
@pytest.mark.parametrize(
78+
"runtime_txt",
79+
[
80+
"",
81+
"abc",
82+
"abc-001-25-06-22",
83+
],
84+
)
85+
def test_invalid_runtime(tmpdir, runtime_txt, base_image):
86+
tmpdir.chdir()
87+
88+
if runtime_txt is not None:
89+
with open("runtime.txt", "w") as f:
90+
f.write(runtime_txt)
91+
92+
base = BaseImage(base_image)
93+
94+
with pytest.raises(ValueError, match=r"^Invalid runtime.txt.*"):
95+
base.runtime

0 commit comments

Comments
 (0)