From 37e96779167b5db8aeefd7370d2dd7c0fdd1124a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Behmo?= <regis@behmo.com>
Date: Fri, 22 Nov 2024 12:23:47 +0100
Subject: [PATCH] feat: migrate from setuptools to hatch

The official Python packaging guide recommends hatch over setuptools.
Beyond that official recommendation, getting rid of setuptools allows us
to resolve issue #956 on mypy-checking of plugins in editable mode.

Close #956.
---
 .github/workflows/release.yml                 |  7 +-
 .github/workflows/test.yml                    |  4 +-
 .hatch_build.py                               | 37 +++++++
 MANIFEST.in                                   |  5 -
 Makefile                                      |  8 +-
 .../20241122_145752_regis_pyproject_toml.md   |  1 +
 pyproject.toml                                | 35 ++++---
 requirements/base.txt                         | 26 ++---
 requirements/dev.in                           |  1 -
 requirements/dev.txt                          | 43 +++------
 requirements/docs.txt                         | 96 ++++++++++++-------
 11 files changed, 158 insertions(+), 105 deletions(-)
 create mode 100644 .hatch_build.py
 delete mode 100644 MANIFEST.in
 create mode 100644 changelog.d/20241122_145752_regis_pyproject_toml.md

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8b980b178a..9ecca68e65 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -41,11 +41,8 @@ jobs:
           python-version: 3.9
           cache: 'pip'
           cache-dependency-path: requirements/dev.txt
-      - name: Upgrade pip and setuptools
-        # https://pypi.org/project/pip/
-        # https://pypi.org/project/setuptools/
-        # https://pypi.org/project/wheel/
-        run: python -m pip install --upgrade pip setuptools==65.6.3 wheel
+      - name: Install Hatch
+        uses: pypa/hatch@install
       - name: Print info about the current python installation
         run: make ci-info
       - name: Install requirements
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b982239229..35d81d921e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,10 +20,8 @@ jobs:
           python-version:  ${{ matrix.python-version }}
           cache: 'pip'
           cache-dependency-path: requirements/dev.txt
-      - name: Upgrade pip
-        run: python -m pip install --upgrade pip setuptools
       - name: Install dependencies
-        run: pip install -r requirements/dev.txt
+        run: pip install -e .[dev]
       - name: Static code analysis
         run: make test-lint
       - name: Python unit tests
diff --git a/.hatch_build.py b/.hatch_build.py
new file mode 100644
index 0000000000..61f490f603
--- /dev/null
+++ b/.hatch_build.py
@@ -0,0 +1,37 @@
+# https://hatch.pypa.io/latest/how-to/config/dynamic-metadata/
+import os
+import typing as t
+
+from hatchling.metadata.plugin.interface import MetadataHookInterface
+
+HERE = os.path.dirname(__file__)
+
+
+class MetaDataHook(MetadataHookInterface):
+    def update(self, metadata: dict[str, t.Any]) -> None:
+        about = load_about()
+        metadata["version"] = about["__package_version__"]
+        metadata["dependencies"] = load_requirements("base.in")
+        metadata["optional-dependencies"] = {
+            "dev": load_requirements("dev.txt"),
+            "full": load_requirements("plugins.txt"),
+        }
+
+
+def load_about() -> dict[str, str]:
+    about: dict[str, str] = {}
+    with open(os.path.join(HERE, "tutor", "__about__.py"), "rt", encoding="utf-8") as f:
+        exec(f.read(), about)  # pylint: disable=exec-used
+    return about
+
+
+def load_requirements(filename: str) -> list[str]:
+    requirements = []
+    with open(
+        os.path.join(HERE, "requirements", filename), "rt", encoding="utf-8"
+    ) as f:
+        for line in f:
+            line = line.strip()
+            if line != "" and not line.startswith("#"):
+                requirements.append(line)
+    return requirements
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 7d53c0c3bd..0000000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,5 +0,0 @@
-include requirements/base.in
-include requirements/plugins.txt
-include requirements/dev.txt
-recursive-include tutor/templates *
-include tutor/py.typed
diff --git a/Makefile b/Makefile
index 417af4bdbd..ba9d37e924 100644
--- a/Makefile
+++ b/Makefile
@@ -9,16 +9,14 @@ docs: ## Build HTML documentation
 	$(MAKE) -C docs
 
 compile-requirements: ## Compile requirements files
-	pip-compile ${COMPILE_OPTS} --output-file=requirements/base.txt
+	pip-compile ${COMPILE_OPTS} requirements/base.in
 	pip-compile ${COMPILE_OPTS} requirements/dev.in
-	pip-compile ${COMPILE_OPTS} --extra=docs --output-file=requirements/docs.txt
+	pip-compile ${COMPILE_OPTS} requirements/docs.in
 
 upgrade-requirements: ## Upgrade requirements files
 	$(MAKE) compile-requirements COMPILE_OPTS="--upgrade"
 
-build-pythonpackage: build-pythonpackage-tutor ## Build Python packages ready to upload to pypi
-
-build-pythonpackage-tutor: ## Build the "tutor" python package for upload to pypi
+build-pythonpackage: ## Build the "tutor" python package for upload to pypi
 	python -m build --sdist
 
 push-pythonpackage: ## Push python package to pypi
diff --git a/changelog.d/20241122_145752_regis_pyproject_toml.md b/changelog.d/20241122_145752_regis_pyproject_toml.md
new file mode 100644
index 0000000000..dccbb3f48b
--- /dev/null
+++ b/changelog.d/20241122_145752_regis_pyproject_toml.md
@@ -0,0 +1 @@
+- [Improvement] Migrate packaging from setup.py/setuptools to pyproject.toml/hatch. This should not be a breaking change for most users. (by @regisb)
diff --git a/pyproject.toml b/pyproject.toml
index e4863ed32b..1511e9cb51 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,8 +1,7 @@
 # https://packaging.python.org/en/latest/tutorials/packaging-projects/
-# https://setuptools.pypa.io/en/latest/userguide/quickstart.html
+# https://hatch.pypa.io/latest/config/build/
 
 [project]
-dynamic = ["version", "dependencies", "optional-dependencies"]
 name = "tutor"
 license = {file = "LICENSE.txt"}
 authors = [
@@ -22,6 +21,8 @@ classifiers = [
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
 ]
+# these fields will be set by hatch_build.py
+dynamic = ["version", "dependencies", "optional-dependencies"]
 
 [project.scripts]
 tutor = "tutor.commands.cli:main"
@@ -35,17 +36,25 @@ Issues = "https://github.com/overhangio/tutor/issues"
 Changelog = "https://github.com/overhangio/tutor/blob/master/CHANGELOG.md"
 Community = "https://discuss.openedx.org/tag/tutor"
 
-# Setuptools-specific configuration
-[build-system]
-requires = ["setuptools", "wheel"]
-
-[tool.setuptools.dynamic]
-version = {attr = "tutor.__about__.__package_version__"}
-dependencies = {file = ["requirements/base.in"] }
+# hatch-specific configuration
+[tool.hatch.metadata.hooks.custom]
+path = ".hatch_build.py"
 
-[tool.setuptools.dynamic.optional-dependencies]
-dev = {file = ["requirements/dev.txt"]}
-full = {file = ["requirements/plugins.txt"]}
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
 
-[tool.setuptools.packages.find]
+[tool.hatch.build.targets.sdist]
+# Disable strict naming, otherwise twine is not able to detect name/version
+strict-naming = false
+include = [
+  "/tutor",
+  "requirements/base.in",
+  "requirements/plugins.txt",
+  "requirements/dev.txt",
+]
 exclude = ["tests*"]
+
+[tool.hatch.metadata]
+# Allow github dependencies in plugins.txt
+allow-direct-references = true
diff --git a/requirements/base.txt b/requirements/base.txt
index 1dfb795ab4..057cd996f1 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -2,10 +2,10 @@
 # This file is autogenerated by pip-compile with Python 3.12
 # by the following command:
 #
-#    pip-compile --output-file=requirements/base.txt
+#    pip-compile requirements/base.in
 #
 appdirs==1.4.4
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.in
 cachetools==5.5.0
     # via google-auth
 certifi==2024.8.30
@@ -15,7 +15,7 @@ certifi==2024.8.30
 charset-normalizer==3.4.0
     # via requests
 click==8.1.7
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.in
 durationpy==0.9
     # via kubernetes
 google-auth==2.35.0
@@ -23,25 +23,25 @@ google-auth==2.35.0
 idna==3.10
     # via requests
 importlib-metadata==8.5.0
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.in
 importlib-resources==6.4.5
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.in
 jinja2==3.1.4
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.in
 kubernetes==31.0.0
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.in
 markupsafe==3.0.2
     # via jinja2
 mypy==1.13.0
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.in
 mypy-extensions==1.0.0
     # via mypy
 oauthlib==3.2.2
     # via
     #   kubernetes
     #   requests-oauthlib
-packaging==24.1
-    # via tutor (pyproject.toml)
+packaging==24.2
+    # via -r requirements/base.in
 pyasn1==0.6.1
     # via
     #   pyasn1-modules
@@ -49,13 +49,13 @@ pyasn1==0.6.1
 pyasn1-modules==0.4.1
     # via google-auth
 pycryptodome==3.21.0
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.in
 python-dateutil==2.9.0.post0
     # via kubernetes
 pyyaml==6.0.2
     # via
+    #   -r requirements/base.in
     #   kubernetes
-    #   tutor (pyproject.toml)
 requests==2.32.3
     # via
     #   kubernetes
@@ -70,8 +70,8 @@ six==1.16.0
     #   python-dateutil
 typing-extensions==4.12.2
     # via
+    #   -r requirements/base.in
     #   mypy
-    #   tutor (pyproject.toml)
 urllib3==2.2.3
     # via
     #   kubernetes
diff --git a/requirements/dev.in b/requirements/dev.in
index ef0d7ada7b..b2a102e42a 100644
--- a/requirements/dev.in
+++ b/requirements/dev.in
@@ -16,4 +16,3 @@ docutils<0.19.0
 # Types packages
 types-docutils
 types-PyYAML
-types-setuptools
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 205b2710f0..af1eb9d790 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -1,21 +1,17 @@
 #
-# This file is autogenerated by pip-compile with Python 3.9
+# This file is autogenerated by pip-compile with Python 3.12
 # by the following command:
 #
 #    pip-compile requirements/dev.in
 #
 altgraph==0.17.4
-    # via
-    #   macholib
-    #   pyinstaller
+    # via pyinstaller
 appdirs==1.4.4
     # via -r requirements/base.txt
 astroid==3.3.5
     # via pylint
 attrs==24.2.0
     # via scriv
-backports-tarfile==1.2.0
-    # via jaraco-context
 black==24.10.0
     # via -r requirements/dev.in
 build==1.2.2.post1
@@ -29,6 +25,8 @@ certifi==2024.8.30
     #   -r requirements/base.txt
     #   kubernetes
     #   requests
+cffi==1.17.1
+    # via cryptography
 charset-normalizer==3.4.0
     # via
     #   -r requirements/base.txt
@@ -44,6 +42,8 @@ click-log==0.4.0
     # via scriv
 coverage==7.6.4
     # via -r requirements/dev.in
+cryptography==43.0.3
+    # via secretstorage
 dill==0.3.9
     # via pylint
 docutils==0.18.1
@@ -65,10 +65,6 @@ idna==3.10
 importlib-metadata==8.5.0
     # via
     #   -r requirements/base.txt
-    #   build
-    #   keyring
-    #   pyinstaller
-    #   pyinstaller-hooks-contrib
     #   twine
 importlib-resources==6.4.5
     # via -r requirements/base.txt
@@ -80,6 +76,10 @@ jaraco-context==6.0.1
     # via keyring
 jaraco-functools==4.1.0
     # via keyring
+jeepney==0.8.0
+    # via
+    #   keyring
+    #   secretstorage
 jinja2==3.1.4
     # via
     #   -r requirements/base.txt
@@ -88,8 +88,6 @@ keyring==25.5.0
     # via twine
 kubernetes==31.0.0
     # via -r requirements/base.txt
-macholib==1.16.3
-    # via pyinstaller
 markdown-it-py==3.0.0
     # via
     #   rich
@@ -120,7 +118,7 @@ oauthlib==3.2.2
     #   -r requirements/base.txt
     #   kubernetes
     #   requests-oauthlib
-packaging==24.1
+packaging==24.2
     # via
     #   -r requirements/base.txt
     #   black
@@ -146,6 +144,8 @@ pyasn1-modules==0.4.1
     # via
     #   -r requirements/base.txt
     #   google-auth
+pycparser==2.22
+    # via cffi
 pycryptodome==3.21.0
     # via -r requirements/base.txt
 pygments==2.18.0
@@ -196,19 +196,13 @@ rsa==4.9
     #   google-auth
 scriv==1.5.1
     # via -r requirements/dev.in
+secretstorage==3.3.3
+    # via keyring
 six==1.16.0
     # via
     #   -r requirements/base.txt
     #   kubernetes
     #   python-dateutil
-tomli==2.0.2
-    # via
-    #   -r requirements/base.txt
-    #   black
-    #   build
-    #   mypy
-    #   pip-tools
-    #   pylint
 tomlkit==0.13.2
     # via pylint
 twine==5.1.1
@@ -217,16 +211,10 @@ types-docutils==0.21.0.20241005
     # via -r requirements/dev.in
 types-pyyaml==6.0.12.20240917
     # via -r requirements/dev.in
-types-setuptools==75.2.0.20241025
-    # via -r requirements/dev.in
 typing-extensions==4.12.2
     # via
     #   -r requirements/base.txt
-    #   astroid
-    #   black
     #   mypy
-    #   pylint
-    #   rich
 urllib3==2.2.3
     # via
     #   -r requirements/base.txt
@@ -243,7 +231,6 @@ zipp==3.20.2
     # via
     #   -r requirements/base.txt
     #   importlib-metadata
-    #   importlib-resources
 
 # The following packages are considered to be unsafe in a requirements file:
 # pip
diff --git a/requirements/docs.txt b/requirements/docs.txt
index 7c137fa92b..4e04e905e2 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -2,104 +2,131 @@
 # This file is autogenerated by pip-compile with Python 3.12
 # by the following command:
 #
-#    pip-compile --extra=docs --output-file=requirements/docs.txt
+#    pip-compile requirements/docs.in
 #
-alabaster==0.7.16
+alabaster==1.0.0
     # via sphinx
 appdirs==1.4.4
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.txt
 babel==2.16.0
     # via sphinx
 cachetools==5.5.0
-    # via google-auth
+    # via
+    #   -r requirements/base.txt
+    #   google-auth
 certifi==2024.8.30
     # via
+    #   -r requirements/base.txt
     #   kubernetes
     #   requests
 charset-normalizer==3.4.0
-    # via requests
+    # via
+    #   -r requirements/base.txt
+    #   requests
 click==8.1.7
     # via
+    #   -r requirements/base.txt
     #   sphinx-click
-    #   tutor (pyproject.toml)
 docutils==0.21.2
     # via
     #   sphinx
     #   sphinx-click
     #   sphinx-rtd-theme
 durationpy==0.9
-    # via kubernetes
+    # via
+    #   -r requirements/base.txt
+    #   kubernetes
 google-auth==2.35.0
-    # via kubernetes
+    # via
+    #   -r requirements/base.txt
+    #   kubernetes
 idna==3.10
-    # via requests
+    # via
+    #   -r requirements/base.txt
+    #   requests
 imagesize==1.4.1
     # via sphinx
 importlib-metadata==8.5.0
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.txt
 importlib-resources==6.4.5
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.txt
 jinja2==3.1.4
     # via
+    #   -r requirements/base.txt
     #   sphinx
-    #   tutor (pyproject.toml)
 kubernetes==31.0.0
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.txt
 markupsafe==3.0.2
-    # via jinja2
+    # via
+    #   -r requirements/base.txt
+    #   jinja2
 mypy==1.13.0
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.txt
 mypy-extensions==1.0.0
-    # via mypy
+    # via
+    #   -r requirements/base.txt
+    #   mypy
 oauthlib==3.2.2
     # via
+    #   -r requirements/base.txt
     #   kubernetes
     #   requests-oauthlib
-packaging==24.1
+packaging==24.2
     # via
+    #   -r requirements/base.txt
     #   sphinx
-    #   tutor (pyproject.toml)
 pyasn1==0.6.1
     # via
+    #   -r requirements/base.txt
     #   pyasn1-modules
     #   rsa
 pyasn1-modules==0.4.1
-    # via google-auth
+    # via
+    #   -r requirements/base.txt
+    #   google-auth
 pycryptodome==3.21.0
-    # via tutor (pyproject.toml)
+    # via -r requirements/base.txt
 pygments==2.18.0
     # via sphinx
 python-dateutil==2.9.0.post0
-    # via kubernetes
+    # via
+    #   -r requirements/base.txt
+    #   kubernetes
 pyyaml==6.0.2
     # via
+    #   -r requirements/base.txt
     #   kubernetes
-    #   tutor (pyproject.toml)
 requests==2.32.3
     # via
+    #   -r requirements/base.txt
     #   kubernetes
     #   requests-oauthlib
     #   sphinx
 requests-oauthlib==2.0.0
-    # via kubernetes
+    # via
+    #   -r requirements/base.txt
+    #   kubernetes
 rsa==4.9
-    # via google-auth
+    # via
+    #   -r requirements/base.txt
+    #   google-auth
 six==1.16.0
     # via
+    #   -r requirements/base.txt
     #   kubernetes
     #   python-dateutil
 snowballstemmer==2.2.0
     # via sphinx
-sphinx==7.4.7
+sphinx==8.1.3
     # via
+    #   -r requirements/docs.in
     #   sphinx-click
     #   sphinx-rtd-theme
     #   sphinxcontrib-jquery
-    #   tutor (pyproject.toml)
 sphinx-click==6.0.0
-    # via tutor (pyproject.toml)
-sphinx-rtd-theme==3.0.1
-    # via tutor (pyproject.toml)
+    # via -r requirements/docs.in
+sphinx-rtd-theme==3.0.2
+    # via -r requirements/docs.in
 sphinxcontrib-applehelp==2.0.0
     # via sphinx
 sphinxcontrib-devhelp==2.0.0
@@ -116,13 +143,18 @@ sphinxcontrib-serializinghtml==2.0.0
     # via sphinx
 typing-extensions==4.12.2
     # via
+    #   -r requirements/base.txt
     #   mypy
-    #   tutor (pyproject.toml)
 urllib3==2.2.3
     # via
+    #   -r requirements/base.txt
     #   kubernetes
     #   requests
 websocket-client==1.8.0
-    # via kubernetes
+    # via
+    #   -r requirements/base.txt
+    #   kubernetes
 zipp==3.20.2
-    # via importlib-metadata
+    # via
+    #   -r requirements/base.txt
+    #   importlib-metadata