From 0f861f3c1c35bf649e727931bdc899e6f7e387e1 Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Sat, 16 Aug 2025 14:50:47 +0200 Subject: [PATCH 01/14] Start implementation of support for pyproject.yml Related to https://github.com/jupyterhub/repo2docker/issues/1427 --- repo2docker/buildpacks/pyproject/__init__.py | 125 +++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 repo2docker/buildpacks/pyproject/__init__.py diff --git a/repo2docker/buildpacks/pyproject/__init__.py b/repo2docker/buildpacks/pyproject/__init__.py new file mode 100644 index 000000000..0c3e27929 --- /dev/null +++ b/repo2docker/buildpacks/pyproject/__init__.py @@ -0,0 +1,125 @@ +"""Buildpack for Git repositories with pyproject.toml + +Support to pyproject.toml was added to pip v10.0, +see https://pip.pypa.io/en/latest/reference/build-system/pyproject-toml/. +""" + +import os +import re +import tomllib +from functools import lru_cache + +from ..conda import CondaBuildPack + +VERSION_PAT = re.compile(r"\d+(\.\d+)*") + + +class PyprojectBuildPack(CondaBuildPack): + """Setup Python with pyproject.toml for use with a repository.""" + + @property + def python_version(self): + """ + Detect the Python version declared in a `pyproject.toml`. + Will return 'x.y' if version is found (e.g '3.6'), + or a Falsy empty string '' if not found. + """ + + if hasattr(self, "_python_version"): + return self._python_version + + pyproject_file = self.binder_path("pyproject.toml") + with open(pyproject_file, "rb") as _pyproject_file: + pyproject_toml = tomllib.load(_pyproject_file) + + if "project" in pyproject_toml: + if "requires-python" in pyproject_toml["project"]: + raw_version = pyproject_toml["project"]["requires-python"] + + match = VERSION_PAT.match(raw_version) + if match: + return match.group() + + return "" + + @lru_cache + def get_preassemble_script_files(self): + """Return files needed for preassembly""" + files = super().get_preassemble_script_files() + path = self.binder_path("pyproject.toml") + if os.path.exists(path): + files[path] = path + return files + + @lru_cache + def get_preassemble_scripts(self): + """scripts to run prior to staging the repo contents""" + scripts = super().get_preassemble_scripts() + # install pipenv to install dependencies within Pipfile.lock or Pipfile + if V(self.python_version) < V("3.6"): + # last pipenv version to support 2.7, 3.5 + pipenv_version = "2021.5.29" + else: + pipenv_version = "2022.1.8" + scripts.append( + ( + "${NB_USER}", + f"${{KERNEL_PYTHON_PREFIX}}/bin/pip install --no-cache-dir pipenv=={pipenv_version}", + ) + ) + return scripts + + @lru_cache + def get_assemble_scripts(self): + """Return series of build-steps specific to this repository.""" + # If we have pyproject.toml declare the + # use of Python 2, Python 2.7 will be made available in the *kernel* + # environment. The notebook servers environment on the other hand + # requires Python 3 but may require something additional installed in it + # still such as `nbgitpuller`. For this purpose, a "requirements3.txt" + # file will be used to install dependencies for the notebook servers + # environment, if Python 2 had been specified for the kernel + # environment. + assemble_scripts = super().get_assemble_scripts() + + if self.separate_kernel_env: + # using legacy Python (e.g. 2.7) as a kernel + + # requirements3.txt allows for packages to be installed to the + # notebook servers Python environment + nb_requirements_file = self.binder_path("requirements3.txt") + if os.path.exists(nb_requirements_file): + assemble_scripts.append( + ( + "${NB_USER}", + f'${{NB_PYTHON_PREFIX}}/bin/pip install --no-cache-dir -r "{nb_requirements_file}"', + ) + ) + + assemble_scripts.append( + ( + "${NB_USER}", + """(cd && \\ + PATH="${{KERNEL_PYTHON_PREFIX}}/bin:$PATH" \\ + pip install --no-cache-dir --editable {working_directory} + )""".format( + working_directory=working_directory, + ), + ) + ) + + return assemble_scripts + + def detect(self): + """Check if current repo should be built with the pyproject.toml 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 + + pyproject_file = self.binder_path("pyproject.toml") + + return os.path.exists(pyproject_file) From c1eedaa0774b44f2bcc52a7962e8bb75b0d10978 Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Mon, 18 Aug 2025 10:38:18 +0200 Subject: [PATCH 02/14] Add builldpack for pyproject to list of buildpack Related to https://github.com/jupyterhub/repo2docker/issues/1427 --- repo2docker/app.py | 2 + repo2docker/buildpacks/__init__.py | 1 + repo2docker/buildpacks/pyproject/__init__.py | 68 +++++++++++++------- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/repo2docker/app.py b/repo2docker/app.py index 7a70a470d..cdd0fadea 100755 --- a/repo2docker/app.py +++ b/repo2docker/app.py @@ -33,6 +33,7 @@ LegacyBinderDockerBuildPack, NixBuildPack, PipfileBuildPack, + PyprojectBuildPack, PythonBuildPack, RBuildPack, ) @@ -100,6 +101,7 @@ def _default_log_level(self): RBuildPack, CondaBuildPack, PipfileBuildPack, + PyprojectBuildPack, PythonBuildPack, ], config=True, diff --git a/repo2docker/buildpacks/__init__.py b/repo2docker/buildpacks/__init__.py index 7568000bd..21a512a81 100644 --- a/repo2docker/buildpacks/__init__.py +++ b/repo2docker/buildpacks/__init__.py @@ -5,5 +5,6 @@ from .legacy import LegacyBinderDockerBuildPack from .nix import NixBuildPack from .pipfile import PipfileBuildPack +from .pyproject import PyprojectBuildPack from .python import PythonBuildPack from .r import RBuildPack diff --git a/repo2docker/buildpacks/pyproject/__init__.py b/repo2docker/buildpacks/pyproject/__init__.py index 0c3e27929..21eedfb7c 100644 --- a/repo2docker/buildpacks/pyproject/__init__.py +++ b/repo2docker/buildpacks/pyproject/__init__.py @@ -28,19 +28,58 @@ 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 runtime.startswith("python-"): + runtime_python_version = runtime.split("-", 1)[1] + else: + # not a Python runtime (e.g. R, which subclasses this) + # use the default Python + runtime_python_version = self.major_pythons["3"] + self.log.warning( + f"Python version unspecified in runtime.txt, using current default Python version {runtime_python_version}. This will change in the future." + ) + + runtime_python_version_info = runtime_python_version.split(".") + if len(runtime_python_version_info) == 1: + runtime_python_version = self.major_pythons[runtime_python_version_info[0]] + runtime_python_version_info = runtime_python_version.split(".") + pyproject_file = self.binder_path("pyproject.toml") with open(pyproject_file, "rb") as _pyproject_file: pyproject_toml = tomllib.load(_pyproject_file) if "project" in pyproject_toml: if "requires-python" in pyproject_toml["project"]: - raw_version = pyproject_toml["project"]["requires-python"] + # This is the minumum version! + raw_pyproject_minimum_version = pyproject_toml["project"][ + "requires-python" + ] - match = VERSION_PAT.match(raw_version) + match = VERSION_PAT.match(raw_pyproject_minimum_version) if match: - return match.group() + pyproject_minimum_version = match.group() + pyproject_minimum_version_info = pyproject_minimum_version.split( + "." + ) - return "" + if ( + runtime_python_version_info[0] + < pyproject_minimum_version_info[0] + ) or ( + runtime_python_version_info[1] + < pyproject_minimum_version_info[1] + ): + raise RuntimeError( + "runtime.txt version not supported by pyproject.toml." + ) + + self._python_version = runtime_python_version + return self._python_version @lru_cache def get_preassemble_script_files(self): @@ -55,18 +94,6 @@ def get_preassemble_script_files(self): def get_preassemble_scripts(self): """scripts to run prior to staging the repo contents""" scripts = super().get_preassemble_scripts() - # install pipenv to install dependencies within Pipfile.lock or Pipfile - if V(self.python_version) < V("3.6"): - # last pipenv version to support 2.7, 3.5 - pipenv_version = "2021.5.29" - else: - pipenv_version = "2022.1.8" - scripts.append( - ( - "${NB_USER}", - f"${{KERNEL_PYTHON_PREFIX}}/bin/pip install --no-cache-dir pipenv=={pipenv_version}", - ) - ) return scripts @lru_cache @@ -99,12 +126,9 @@ def get_assemble_scripts(self): assemble_scripts.append( ( "${NB_USER}", - """(cd && \\ - PATH="${{KERNEL_PYTHON_PREFIX}}/bin:$PATH" \\ - pip install --no-cache-dir --editable {working_directory} - )""".format( - working_directory=working_directory, - ), + """PATH="${KERNEL_PYTHON_PREFIX}/bin:$PATH" \\ + pip install --no-cache-dir --editable . + """, ) ) From 3dcaa44e277039280f2c2ea9214350fa34de1888 Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Tue, 19 Aug 2025 09:27:23 +0200 Subject: [PATCH 03/14] Change requirement to Python 3.11 because of tomllib. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c8fcbbd73..577254ed6 100644 --- a/setup.py +++ b/setup.py @@ -62,7 +62,7 @@ def get_identifier(json): "toml", "traitlets", ], - python_requires=">=3.9", + python_requires=">=3.11", author="Project Jupyter Contributors", author_email="jupyter@googlegroups.com", url="https://repo2docker.readthedocs.io/en/latest/", From 616b3ecaf88a85f5cafee8ad479787850622f23b Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Tue, 19 Aug 2025 09:34:54 +0200 Subject: [PATCH 04/14] Remove test with Python 3.9 since Python 3.9 is no longer supported as it does not have tomllib. --- .github/workflows/test.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4645d8c43..d70da9ee9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,11 +68,6 @@ jobs: - norun # Playwright test - ui - include: - # The earliest actions/setup-python versions depend on the runner. - - ubuntu_version: "22.04" - python_version: "3.9" - repo_type: venv steps: - uses: actions/checkout@v5 From d9cb6a0b937847f840c8676986069272d15456a0 Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Tue, 19 Aug 2025 09:36:43 +0200 Subject: [PATCH 05/14] Avoid change PATH during bash command --- repo2docker/buildpacks/pyproject/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/repo2docker/buildpacks/pyproject/__init__.py b/repo2docker/buildpacks/pyproject/__init__.py index 21eedfb7c..a952c8af1 100644 --- a/repo2docker/buildpacks/pyproject/__init__.py +++ b/repo2docker/buildpacks/pyproject/__init__.py @@ -126,9 +126,7 @@ def get_assemble_scripts(self): assemble_scripts.append( ( "${NB_USER}", - """PATH="${KERNEL_PYTHON_PREFIX}/bin:$PATH" \\ - pip install --no-cache-dir --editable . - """, + "${KERNEL_PYTHON_PREFIX}/bin/pip install --no-cache-dir --editable .", ) ) From 8632198e45076d54d670e86985c40c9d1e830705 Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Tue, 19 Aug 2025 09:56:57 +0200 Subject: [PATCH 06/14] Change Python version used by ReadTheDocks because now repo2docker requires Python >= 3.11 given the requirement of tomllib. --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 7bb04d170..c0e4658d6 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -11,7 +11,7 @@ sphinx: build: os: ubuntu-22.04 tools: - python: "3.10" + python: "3.11" python: install: From 8d987039b8207f8dff199074c52408fed458a06a Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Mon, 8 Sep 2025 16:54:27 +0200 Subject: [PATCH 07/14] Reduce code duplication in PyprojectBuildPack --- repo2docker/buildpacks/pyproject/__init__.py | 26 ++++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/repo2docker/buildpacks/pyproject/__init__.py b/repo2docker/buildpacks/pyproject/__init__.py index a952c8af1..9de1bdf70 100644 --- a/repo2docker/buildpacks/pyproject/__init__.py +++ b/repo2docker/buildpacks/pyproject/__init__.py @@ -28,26 +28,17 @@ 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 runtime.startswith("python-"): - runtime_python_version = runtime.split("-", 1)[1] + name, version, _ = self.runtime + + if name == "python": + runtime_python_version = version else: - # not a Python runtime (e.g. R, which subclasses this) - # use the default Python runtime_python_version = self.major_pythons["3"] self.log.warning( f"Python version unspecified in runtime.txt, using current default Python version {runtime_python_version}. This will change in the future." ) runtime_python_version_info = runtime_python_version.split(".") - if len(runtime_python_version_info) == 1: - runtime_python_version = self.major_pythons[runtime_python_version_info[0]] - runtime_python_version_info = runtime_python_version.split(".") pyproject_file = self.binder_path("pyproject.toml") with open(pyproject_file, "rb") as _pyproject_file: @@ -135,12 +126,9 @@ def get_assemble_scripts(self): def detect(self): """Check if current repo should be built with the pyproject.toml 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 + if name != "python": + return False pyproject_file = self.binder_path("pyproject.toml") From ddd6d2fcc1364663efcaa8d13608a2e53767fa1b Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Mon, 20 Oct 2025 09:36:10 +0200 Subject: [PATCH 08/14] Use tomli for Python < 3.11 --- repo2docker/buildpacks/pyproject/__init__.py | 6 +++++- setup.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/repo2docker/buildpacks/pyproject/__init__.py b/repo2docker/buildpacks/pyproject/__init__.py index 9de1bdf70..11db16f79 100644 --- a/repo2docker/buildpacks/pyproject/__init__.py +++ b/repo2docker/buildpacks/pyproject/__init__.py @@ -6,9 +6,13 @@ import os import re -import tomllib from functools import lru_cache +try: + import tomllib +except ImportError: + import tomli as tomllib + from ..conda import CondaBuildPack VERSION_PAT = re.compile(r"\d+(\.\d+)*") diff --git a/setup.py b/setup.py index 577254ed6..c8fcbbd73 100644 --- a/setup.py +++ b/setup.py @@ -62,7 +62,7 @@ def get_identifier(json): "toml", "traitlets", ], - python_requires=">=3.11", + python_requires=">=3.9", author="Project Jupyter Contributors", author_email="jupyter@googlegroups.com", url="https://repo2docker.readthedocs.io/en/latest/", From c972f45d113bfa3f615e888a40938bbb20693dbd Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Mon, 20 Oct 2025 09:41:27 +0200 Subject: [PATCH 09/14] Check that pyproject.toml has required fields --- repo2docker/buildpacks/pyproject/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/repo2docker/buildpacks/pyproject/__init__.py b/repo2docker/buildpacks/pyproject/__init__.py index 11db16f79..294d92501 100644 --- a/repo2docker/buildpacks/pyproject/__init__.py +++ b/repo2docker/buildpacks/pyproject/__init__.py @@ -135,5 +135,14 @@ def detect(self): return False pyproject_file = self.binder_path("pyproject.toml") + with open(pyproject_file, "rb") as _pyproject_file: + pyproject_toml = tomllib.load(_pyproject_file) + + if ( + ("project" in pyproject_toml) + and ("build-system" in pyproject_toml) + and ("requires" in pyproject_toml["build-system"]) + ): + return os.path.exists(pyproject_file) - return os.path.exists(pyproject_file) + return False From 8b99430984858932b68ed5a236fa669672c1e335 Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Mon, 20 Oct 2025 09:52:45 +0200 Subject: [PATCH 10/14] Add one test for pyproject --- tests/pyproject/pyproject-toml/README.rst | 4 +++ .../pyproject/pyproject-toml/mwt/__init__.py | 0 tests/pyproject/pyproject-toml/pyproject.toml | 26 +++++++++++++++++++ tests/pyproject/pyproject-toml/verify | 5 ++++ 4 files changed, 35 insertions(+) create mode 100644 tests/pyproject/pyproject-toml/README.rst create mode 100644 tests/pyproject/pyproject-toml/mwt/__init__.py create mode 100644 tests/pyproject/pyproject-toml/pyproject.toml create mode 100755 tests/pyproject/pyproject-toml/verify diff --git a/tests/pyproject/pyproject-toml/README.rst b/tests/pyproject/pyproject-toml/README.rst new file mode 100644 index 000000000..86564f364 --- /dev/null +++ b/tests/pyproject/pyproject-toml/README.rst @@ -0,0 +1,4 @@ +Python - pyproject.toml +----------------------- + +``pyproject.toml`` should be used over ``setup.py``. diff --git a/tests/pyproject/pyproject-toml/mwt/__init__.py b/tests/pyproject/pyproject-toml/mwt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/pyproject/pyproject-toml/pyproject.toml b/tests/pyproject/pyproject-toml/pyproject.toml new file mode 100644 index 000000000..800af7eda --- /dev/null +++ b/tests/pyproject/pyproject-toml/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mwt" +version = "1.0.0" +dependencies = [ + "numpy", +] +requires-python = ">=3.12" +authors = [ + {name = "Project Jupyter Contributors", email = "jupyter@googlegroups.com"}, +] +maintainers = [ + {name = "Project Jupyter Contributors", email = "jupyter@googlegroups.com"}, +] +description = "Test for repo2docker" +readme = "README.rst" +license = "MIT" + +[project.urls] +Homepage = "https://repo2docker.readthedocs.io/" +Documentation = "https://repo2docker.readthedocs.io/" +Repository = "https://github.com/jupyterhub/repo2docker.git" +"Bug Tracker" = "https://github.com/jupyterhub/repo2docker/issues" \ No newline at end of file diff --git a/tests/pyproject/pyproject-toml/verify b/tests/pyproject/pyproject-toml/verify new file mode 100755 index 000000000..4d7131e1e --- /dev/null +++ b/tests/pyproject/pyproject-toml/verify @@ -0,0 +1,5 @@ +#!/usr/bin/env python +try: + import mwt +except ImportError: + raise Exception("'mwt' shouldn't have been installed!") From 1a393bb5ecf255fd3fd37e6a9a8e10a04af20e89 Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Mon, 20 Oct 2025 10:00:28 +0200 Subject: [PATCH 11/14] Add documentation about pyproject.toml --- docs/source/configuration/development.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/source/configuration/development.md b/docs/source/configuration/development.md index a770f0cb7..8cd1c5e75 100644 --- a/docs/source/configuration/development.md +++ b/docs/source/configuration/development.md @@ -24,10 +24,22 @@ environment. Our [example `requirements.txt` file](https://github.com/binder-examples/requirements/blob/HEAD/requirements.txt) on GitHub shows a typical requirements file. +(pyproject)= + +## `pyproject.toml` - Install Python packages + +To install your reprository like a Python package, you may include a +`pyproject.toml` file. `repo2docker`install `pyproject.toml` files by running +`pip install -e .`. + (setup-py)= ## `setup.py` - Install Python packages +```{note} +We recommend to use `pyproject.toml` as it is the recommended way since 2020 when PEPs [621](https://peps.python.org/pep-0621/) and [631](https://peps.python.org/pep-0631/) were accepted. +``` + To install your repository like a Python package, you may include a `setup.py` file. `repo2docker` installs `setup.py` files by running `pip install -e .`. From 22234b9ee45de5f408f8c6cfa7f80624c5548834 Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Mon, 20 Oct 2025 15:48:34 +0200 Subject: [PATCH 12/14] Fix English typos --- docs/source/configuration/development.md | 4 ++-- repo2docker/buildpacks/pyproject/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/configuration/development.md b/docs/source/configuration/development.md index 8cd1c5e75..632bcd136 100644 --- a/docs/source/configuration/development.md +++ b/docs/source/configuration/development.md @@ -28,8 +28,8 @@ on GitHub shows a typical requirements file. ## `pyproject.toml` - Install Python packages -To install your reprository like a Python package, you may include a -`pyproject.toml` file. `repo2docker`install `pyproject.toml` files by running +To install your repository like a Python package, you may include a +`pyproject.toml` file. `repo2docker`installs `pyproject.toml` files by running `pip install -e .`. (setup-py)= diff --git a/repo2docker/buildpacks/pyproject/__init__.py b/repo2docker/buildpacks/pyproject/__init__.py index 294d92501..814d5ed0e 100644 --- a/repo2docker/buildpacks/pyproject/__init__.py +++ b/repo2docker/buildpacks/pyproject/__init__.py @@ -1,6 +1,6 @@ """Buildpack for Git repositories with pyproject.toml -Support to pyproject.toml was added to pip v10.0, +Support for pyproject.toml was added to pip v10.0, see https://pip.pypa.io/en/latest/reference/build-system/pyproject-toml/. """ From 8e96d3ebdf6bdacb3268b7fe73823aed3292596f Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Mon, 20 Oct 2025 16:17:07 +0200 Subject: [PATCH 13/14] Only read pyproject.toml if file exists --- repo2docker/buildpacks/pyproject/__init__.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/repo2docker/buildpacks/pyproject/__init__.py b/repo2docker/buildpacks/pyproject/__init__.py index 814d5ed0e..4b6376057 100644 --- a/repo2docker/buildpacks/pyproject/__init__.py +++ b/repo2docker/buildpacks/pyproject/__init__.py @@ -135,14 +135,15 @@ def detect(self): return False pyproject_file = self.binder_path("pyproject.toml") - with open(pyproject_file, "rb") as _pyproject_file: - pyproject_toml = tomllib.load(_pyproject_file) - - if ( - ("project" in pyproject_toml) - and ("build-system" in pyproject_toml) - and ("requires" in pyproject_toml["build-system"]) - ): - return os.path.exists(pyproject_file) + if os.path.exists(pyproject_file): + with open(pyproject_file, "rb") as _pyproject_file: + pyproject_toml = tomllib.load(_pyproject_file) + + if ( + ("project" in pyproject_toml) + and ("build-system" in pyproject_toml) + and ("requires" in pyproject_toml["build-system"]) + ): + return os.path.exists(pyproject_file) return False From b755b7a8345734b2df279a852a86b0faae10fd1d Mon Sep 17 00:00:00 2001 From: Raniere Gaia Costa da Silva Date: Tue, 21 Oct 2025 13:38:51 +0200 Subject: [PATCH 14/14] Merge PyprojectBuildPack into PythonBuildPack --- repo2docker/app.py | 2 - repo2docker/buildpacks/__init__.py | 1 - repo2docker/buildpacks/pyproject/__init__.py | 149 ------------------ repo2docker/buildpacks/python/__init__.py | 66 +++++++- tests/pyproject/pyproject-toml/pyproject.toml | 2 +- 5 files changed, 60 insertions(+), 160 deletions(-) delete mode 100644 repo2docker/buildpacks/pyproject/__init__.py diff --git a/repo2docker/app.py b/repo2docker/app.py index cdd0fadea..7a70a470d 100755 --- a/repo2docker/app.py +++ b/repo2docker/app.py @@ -33,7 +33,6 @@ LegacyBinderDockerBuildPack, NixBuildPack, PipfileBuildPack, - PyprojectBuildPack, PythonBuildPack, RBuildPack, ) @@ -101,7 +100,6 @@ def _default_log_level(self): RBuildPack, CondaBuildPack, PipfileBuildPack, - PyprojectBuildPack, PythonBuildPack, ], config=True, diff --git a/repo2docker/buildpacks/__init__.py b/repo2docker/buildpacks/__init__.py index 21a512a81..7568000bd 100644 --- a/repo2docker/buildpacks/__init__.py +++ b/repo2docker/buildpacks/__init__.py @@ -5,6 +5,5 @@ from .legacy import LegacyBinderDockerBuildPack from .nix import NixBuildPack from .pipfile import PipfileBuildPack -from .pyproject import PyprojectBuildPack from .python import PythonBuildPack from .r import RBuildPack diff --git a/repo2docker/buildpacks/pyproject/__init__.py b/repo2docker/buildpacks/pyproject/__init__.py deleted file mode 100644 index 4b6376057..000000000 --- a/repo2docker/buildpacks/pyproject/__init__.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Buildpack for Git repositories with pyproject.toml - -Support for pyproject.toml was added to pip v10.0, -see https://pip.pypa.io/en/latest/reference/build-system/pyproject-toml/. -""" - -import os -import re -from functools import lru_cache - -try: - import tomllib -except ImportError: - import tomli as tomllib - -from ..conda import CondaBuildPack - -VERSION_PAT = re.compile(r"\d+(\.\d+)*") - - -class PyprojectBuildPack(CondaBuildPack): - """Setup Python with pyproject.toml for use with a repository.""" - - @property - def python_version(self): - """ - Detect the Python version declared in a `pyproject.toml`. - Will return 'x.y' if version is found (e.g '3.6'), - or a Falsy empty string '' if not found. - """ - - if hasattr(self, "_python_version"): - return self._python_version - - name, version, _ = self.runtime - - if name == "python": - runtime_python_version = version - else: - runtime_python_version = self.major_pythons["3"] - self.log.warning( - f"Python version unspecified in runtime.txt, using current default Python version {runtime_python_version}. This will change in the future." - ) - - runtime_python_version_info = runtime_python_version.split(".") - - pyproject_file = self.binder_path("pyproject.toml") - with open(pyproject_file, "rb") as _pyproject_file: - pyproject_toml = tomllib.load(_pyproject_file) - - if "project" in pyproject_toml: - if "requires-python" in pyproject_toml["project"]: - # This is the minumum version! - raw_pyproject_minimum_version = pyproject_toml["project"][ - "requires-python" - ] - - match = VERSION_PAT.match(raw_pyproject_minimum_version) - if match: - pyproject_minimum_version = match.group() - pyproject_minimum_version_info = pyproject_minimum_version.split( - "." - ) - - if ( - runtime_python_version_info[0] - < pyproject_minimum_version_info[0] - ) or ( - runtime_python_version_info[1] - < pyproject_minimum_version_info[1] - ): - raise RuntimeError( - "runtime.txt version not supported by pyproject.toml." - ) - - self._python_version = runtime_python_version - return self._python_version - - @lru_cache - def get_preassemble_script_files(self): - """Return files needed for preassembly""" - files = super().get_preassemble_script_files() - path = self.binder_path("pyproject.toml") - if os.path.exists(path): - files[path] = path - return files - - @lru_cache - def get_preassemble_scripts(self): - """scripts to run prior to staging the repo contents""" - scripts = super().get_preassemble_scripts() - return scripts - - @lru_cache - def get_assemble_scripts(self): - """Return series of build-steps specific to this repository.""" - # If we have pyproject.toml declare the - # use of Python 2, Python 2.7 will be made available in the *kernel* - # environment. The notebook servers environment on the other hand - # requires Python 3 but may require something additional installed in it - # still such as `nbgitpuller`. For this purpose, a "requirements3.txt" - # file will be used to install dependencies for the notebook servers - # environment, if Python 2 had been specified for the kernel - # environment. - assemble_scripts = super().get_assemble_scripts() - - if self.separate_kernel_env: - # using legacy Python (e.g. 2.7) as a kernel - - # requirements3.txt allows for packages to be installed to the - # notebook servers Python environment - nb_requirements_file = self.binder_path("requirements3.txt") - if os.path.exists(nb_requirements_file): - assemble_scripts.append( - ( - "${NB_USER}", - f'${{NB_PYTHON_PREFIX}}/bin/pip install --no-cache-dir -r "{nb_requirements_file}"', - ) - ) - - assemble_scripts.append( - ( - "${NB_USER}", - "${KERNEL_PYTHON_PREFIX}/bin/pip install --no-cache-dir --editable .", - ) - ) - - return assemble_scripts - - def detect(self): - """Check if current repo should be built with the pyproject.toml buildpack.""" - # first make sure python is not explicitly unwanted - name, _, _ = self.runtime - if name != "python": - return False - - pyproject_file = self.binder_path("pyproject.toml") - if os.path.exists(pyproject_file): - with open(pyproject_file, "rb") as _pyproject_file: - pyproject_toml = tomllib.load(_pyproject_file) - - if ( - ("project" in pyproject_toml) - and ("build-system" in pyproject_toml) - and ("requires" in pyproject_toml["build-system"]) - ): - return os.path.exists(pyproject_file) - - return False diff --git a/repo2docker/buildpacks/python/__init__.py b/repo2docker/buildpacks/python/__init__.py index 6c9a6d6e0..d4ec0331a 100644 --- a/repo2docker/buildpacks/python/__init__.py +++ b/repo2docker/buildpacks/python/__init__.py @@ -1,8 +1,14 @@ """Generates Dockerfiles based on an input matrix based on Python.""" import os +import re from functools import lru_cache +try: + import tomllib +except ImportError: + import tomli as tomllib + from ...utils import is_local_pip_requirement, open_guess_encoding from ..conda import CondaBuildPack @@ -34,6 +40,35 @@ def python_version(self): # get major.minor py_version = ".".join(py_version_info[:2]) self._python_version = py_version + + pyproject_toml = "pyproject.toml" + if not self.binder_dir and os.path.exists(pyproject_toml): + with open(pyproject_toml, "rb") as _pyproject_file: + pyproject = tomllib.load(_pyproject_file) + + if "project" in pyproject: + if "requires-python" in pyproject["project"]: + # This is the minumum version! + raw_pyproject_minimum_version = pyproject["project"][ + "requires-python" + ] + + match = re.compile(r"\d+(\.\d+)*").match( + raw_pyproject_minimum_version + ) + if match: + pyproject_minimum_version = match.group() + pyproject_minimum_version_info = ( + pyproject_minimum_version.split(".") + ) + + if (py_version_info[0] < pyproject_minimum_version_info[0]) or ( + py_version_info[1] < pyproject_minimum_version_info[1] + ): + raise RuntimeError( + "runtime.txt version not supported by pyproject.toml." + ) + return self._python_version def _get_pip_scripts(self): @@ -78,9 +113,11 @@ def _should_preassemble_pip(self): If there are any local references, e.g. `-e .`, stage the whole repo prior to installation. """ - if not os.path.exists("binder") and os.path.exists("setup.py"): - # can't install from subset if we're using setup.py - return False + # can't install from subset + for _configuration_file in ("pyproject.toml", "setup.py"): + if not os.path.exists("binder") and os.path.exists(_configuration_file): + return False + for name in ("requirements.txt", "requirements3.txt"): requirements_txt = self.binder_path(name) if not os.path.exists(requirements_txt): @@ -119,26 +156,41 @@ def get_assemble_scripts(self): # and requirements3.txt (if it exists) # will be installed in the python 3 notebook server env. assemble_scripts = super().get_assemble_scripts() - setup_py = "setup.py" # KERNEL_PYTHON_PREFIX is the env with the kernel, # whether it's distinct from the notebook or the same. pip = "${KERNEL_PYTHON_PREFIX}/bin/pip" if not self._should_preassemble_pip: assemble_scripts.extend(self._get_pip_scripts()) - # setup.py exists *and* binder dir is not used - if not self.binder_dir and os.path.exists(setup_py): - assemble_scripts.append(("${NB_USER}", f"{pip} install --no-cache-dir .")) + for _configuration_file in ("pyproject.toml", "setup.py"): + if not self.binder_dir and os.path.exists(_configuration_file): + assemble_scripts.append( + ("${NB_USER}", f"{pip} install --no-cache-dir .") + ) + break + return assemble_scripts def detect(self): """Check if current repo should be built with the Python buildpack.""" requirements_txt = self.binder_path("requirements.txt") + pyproject_toml = "pyproject.toml" setup_py = "setup.py" name = self.runtime[0] if name: return name == "python" + if not self.binder_dir and os.path.exists(pyproject_toml): + with open(pyproject_toml, "rb") as _pyproject_file: + pyproject = tomllib.load(_pyproject_file) + + if ( + ("project" in pyproject) + and ("build-system" in pyproject) + and ("requires" in pyproject["build-system"]) + ): + return True + if not self.binder_dir and os.path.exists(setup_py): return True return os.path.exists(requirements_txt) diff --git a/tests/pyproject/pyproject-toml/pyproject.toml b/tests/pyproject/pyproject-toml/pyproject.toml index 800af7eda..530f83977 100644 --- a/tests/pyproject/pyproject-toml/pyproject.toml +++ b/tests/pyproject/pyproject-toml/pyproject.toml @@ -8,7 +8,7 @@ version = "1.0.0" dependencies = [ "numpy", ] -requires-python = ">=3.12" +requires-python = ">=3.10" authors = [ {name = "Project Jupyter Contributors", email = "jupyter@googlegroups.com"}, ]