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: diff --git a/docs/source/configuration/development.md b/docs/source/configuration/development.md index a770f0cb7..632bcd136 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 repository like a Python package, you may include a +`pyproject.toml` file. `repo2docker`installs `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 .`. diff --git a/pyproject.toml b/pyproject.toml index 1f31d6210..beb52e3c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "semver", "toml", "traitlets", + "packaging", ] [project.urls] diff --git a/repo2docker/buildpacks/python/__init__.py b/repo2docker/buildpacks/python/__init__.py index 6c9a6d6e0..11ac291f0 100644 --- a/repo2docker/buildpacks/python/__init__.py +++ b/repo2docker/buildpacks/python/__init__.py @@ -1,8 +1,17 @@ """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 packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import Version + from ...utils import is_local_pip_requirement, open_guess_encoding from ..conda import CondaBuildPack @@ -17,8 +26,8 @@ def python_version(self): name, version, _ = self.runtime - if name != "python" or not version: - # Either not specified, or not a Python runtime (e.g. R, which subclasses this) + if name is not None and name != "python": + # Either not a Python runtime (e.g. R, which subclasses this) # use the default Python self._python_version = self.major_pythons["3"] self.log.warning( @@ -26,14 +35,33 @@ def python_version(self): ) return self._python_version - py_version_info = version.split(".") - py_version = "" - if len(py_version_info) == 1: - py_version = self.major_pythons[py_version_info[0]] + if name is None or version is None: + self._python_version = self.major_pythons["3"] + runtime_version = 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." + ) else: - # get major.minor - py_version = ".".join(py_version_info[:2]) - self._python_version = py_version + self._python_version = version + runtime_version = Version(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 and "requires-python" in pyproject["project"]: + # This is the minumum version! + # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#python-requires + pyproject_python_specifier = SpecifierSet( + pyproject["project"]["requires-python"] + ) + + if runtime_version not in pyproject_python_specifier: + raise RuntimeError( + "runtime.txt version not supported by pyproject.toml." + ) + return self._python_version def _get_pip_scripts(self): @@ -78,9 +106,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 +149,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/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..530f83977 --- /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.10" +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!")