diff --git a/README.md b/README.md index eda068e0b..5eda0cd12 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,25 @@ This buildpack supports running Django and Flask apps. Official buildpack documentation can be found at [python buildpack docs](http://docs.cloudfoundry.org/buildpacks/python/index.html). +### uv + +Apps managed with [uv](https://docs.astral.sh/uv/) are supported by pushing the `pyproject.toml` together with a `uv.lock`: + +* During staging the buildpack runs `uv sync --frozen` to install the locked packages into the buildpack's Python installation. Every package is verified against the hashes in `uv.lock`, and staging fails on a mismatch. Keep `uv.lock` up to date (`uv lock`) before pushing, as it is used as is. +* Only the default dependencies are installed, dependency groups such as `dev` are skipped. If the project defines a `[build-system]`, the project itself is installed as well (non-editable). +* The Python version is taken from `.python-version` (e.g. `3.12`, as written by `uv python pin`), unless a `runtime.txt` exists. uv never downloads its own Python; staging fails if the version does not satisfy `requires-python`. +* Package indexes configured via `[[tool.uv.index]]` are used as configured. Credentials are not part of `uv.lock`; provide them as environment variables, e.g. `cf set-env UV_INDEX__USERNAME ...` and `UV_INDEX__PASSWORD`. uv trusts the system certificate store, like pip does. +* Django's `collectstatic`, as well as the installation of libffi and libmemcached, work as for pip apps. +* If the app also contains a `requirements.txt`, the `uv.lock` and `.python-version` are ignored and pip is used. +* For vendored (offline) installs, download the wheels for the target platform into the `vendor` directory. They are installed with `uv pip install --offline` and are verified against the hashes in `uv.lock` as well. Write the exported file outside the app, so that it does not take precedence over `uv.lock`: + + ```bash + uv export --frozen --no-dev --no-hashes --no-emit-project -o /tmp/requirements.txt + pip download -r /tmp/requirements.txt -d vendor --only-binary=:all: --platform manylinux2014_x86_64 --python-version 3.12 + ``` + + If the project defines a `[build-system]`, its build backend (e.g. `hatchling`) must be vendored as well. + ## Adding new dependencies If you want to add a new dependency to the buildpack, please add it to the [config.yml](https://github.com/cloudfoundry/buildpacks-ci/blob/5a63d13df09f83d5dff7c71d0a12c3e2dc798d39/pipelines/dependency-builds/config.yml#L272) file. For example, if you want to add a new version of Python, add an entry like the following: diff --git a/bin/detect b/bin/detect index 2b5958489..e4d29ad92 100755 --- a/bin/detect +++ b/bin/detect @@ -13,7 +13,7 @@ BUILD_DIR=$1 BP=$(dirname $(dirname $0)) # Exit early if app is clearly not Python. -if [ ! -f $BUILD_DIR/requirements.txt ] && [ ! -f $BUILD_DIR/setup.py ] && [ ! -f $BUILD_DIR/environment.yml ] && [ ! -f $BUILD_DIR/Pipfile ]; then +if [ ! -f $BUILD_DIR/requirements.txt ] && [ ! -f $BUILD_DIR/setup.py ] && [ ! -f $BUILD_DIR/environment.yml ] && [ ! -f $BUILD_DIR/Pipfile ] && { [ ! -f $BUILD_DIR/uv.lock ] || [ ! -f $BUILD_DIR/pyproject.toml ]; }; then exit 1 fi diff --git a/fixtures/uv/.gitignore b/fixtures/uv/.gitignore new file mode 100644 index 000000000..2419db02f --- /dev/null +++ b/fixtures/uv/.gitignore @@ -0,0 +1,2 @@ +*.pyc +.venv diff --git a/fixtures/uv/.python-version b/fixtures/uv/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/fixtures/uv/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/fixtures/uv/Procfile b/fixtures/uv/Procfile new file mode 100644 index 000000000..caf672282 --- /dev/null +++ b/fixtures/uv/Procfile @@ -0,0 +1 @@ +web: gunicorn server:app diff --git a/fixtures/uv/pyproject.toml b/fixtures/uv/pyproject.toml new file mode 100644 index 000000000..74224d67b --- /dev/null +++ b/fixtures/uv/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "uv-app" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "flask", + "gunicorn", +] + +[dependency-groups] +dev = [ + "pytest", +] diff --git a/fixtures/uv/server.py b/fixtures/uv/server.py new file mode 100644 index 000000000..46f26a92c --- /dev/null +++ b/fixtures/uv/server.py @@ -0,0 +1,18 @@ +import os + +from flask import Flask + +app = Flask(__name__) + +port = int(os.getenv('VCAP_APP_PORT', 8080)) + + +@app.route("/") +def hello(): + return "Hello, World!" + + +app.debug = True + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=port) diff --git a/fixtures/uv/uv.lock b/fixtures/uv/uv.lock new file mode 100644 index 000000000..3839d73ef --- /dev/null +++ b/fixtures/uv/uv.lock @@ -0,0 +1,326 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "gunicorn" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "uv-app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "gunicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask" }, + { name = "gunicorn" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "werkzeug" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, +] diff --git a/fixtures/uv_django/.gitignore b/fixtures/uv_django/.gitignore new file mode 100644 index 000000000..88c4100e4 --- /dev/null +++ b/fixtures/uv_django/.gitignore @@ -0,0 +1,3 @@ +*.pyc +.venv +static_root diff --git a/fixtures/uv_django/Procfile b/fixtures/uv_django/Procfile new file mode 100644 index 000000000..61ab607f6 --- /dev/null +++ b/fixtures/uv_django/Procfile @@ -0,0 +1 @@ +web: waitress-serve --port=$PORT django_web_app.wsgi:application diff --git a/fixtures/uv_django/django_web_app/__init__.py b/fixtures/uv_django/django_web_app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/uv_django/django_web_app/asgi.py b/fixtures/uv_django/django_web_app/asgi.py new file mode 100644 index 000000000..06cbcf4b6 --- /dev/null +++ b/fixtures/uv_django/django_web_app/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for django_web_app project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_web_app.settings') + +application = get_asgi_application() diff --git a/fixtures/uv_django/django_web_app/settings.py b/fixtures/uv_django/django_web_app/settings.py new file mode 100644 index 000000000..b8832722e --- /dev/null +++ b/fixtures/uv_django/django_web_app/settings.py @@ -0,0 +1,119 @@ +""" +Django settings for django_web_app project. + +Generated by 'django-admin startproject' using Django 4.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-1&02gvsqhx5gvh44@)x%k03&79r*vkievzv0%yl*+vq5m)unm_' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'django_web_app.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'django_web_app.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = 'static_root' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/fixtures/uv_django/django_web_app/urls.py b/fixtures/uv_django/django_web_app/urls.py new file mode 100644 index 000000000..8d90cd0db --- /dev/null +++ b/fixtures/uv_django/django_web_app/urls.py @@ -0,0 +1,21 @@ +"""django_web_app URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/fixtures/uv_django/django_web_app/wsgi.py b/fixtures/uv_django/django_web_app/wsgi.py new file mode 100644 index 000000000..270728c0e --- /dev/null +++ b/fixtures/uv_django/django_web_app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for django_web_app project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_web_app.settings') + +application = get_wsgi_application() diff --git a/fixtures/uv_django/manage.py b/fixtures/uv_django/manage.py new file mode 100755 index 000000000..f22506b2d --- /dev/null +++ b/fixtures/uv_django/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_web_app.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/fixtures/uv_django/pyproject.toml b/fixtures/uv_django/pyproject.toml new file mode 100644 index 000000000..95280905b --- /dev/null +++ b/fixtures/uv_django/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "uv-django-app" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "dj-database-url==1.2.0", + "Django==4.1.5", + "gunicorn==20.1.0", + "waitress==2.1.2", + "whitenoise==6.3.0", +] diff --git a/fixtures/uv_django/uv.lock b/fixtures/uv_django/uv.lock new file mode 100644 index 000000000..9fe6ccfc1 --- /dev/null +++ b/fixtures/uv_django/uv.lock @@ -0,0 +1,128 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "dj-database-url" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/3f/c55c0152ca441111af0f969f2d37b99279874ab4f313a3262fed7bf6aaef/dj-database-url-1.2.0.tar.gz", hash = "sha256:b23b15046cb38180e0c95207bcc90fe5e9dbde8eef16065907dd85cf4ca7036c", size = 7310, upload-time = "2022-12-13T09:24:11.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/62/e2786a98fda838f25ce86782810930b54eec2ad87d37950e80c3ce3538b8/dj_database_url-1.2.0-py3-none-any.whl", hash = "sha256:5c2993b91801c0f78a8b19e642b497b90831124cbade0c265900d4c1037b4730", size = 7089, upload-time = "2022-12-13T09:24:29.732Z" }, +] + +[[package]] +name = "django" +version = "4.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c8/b3c469353f9d1b7f0e99b45520582b891da02cd87408bc867affa6e039a3/Django-4.1.5.tar.gz", hash = "sha256:ff56ebd7ead0fd5dbe06fe157b0024a7aaea2e0593bb3785fb594cf94dad58ef", size = 10507020, upload-time = "2023-01-02T07:34:49.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/ac/9f013a51e6008ba94a282c15778a3ea51a0953f6711a77f9baa471fd1b1d/Django-4.1.5-py3-none-any.whl", hash = "sha256:4b214a05fe4c99476e99e2445c8b978c8369c18d4dea8e22ec412862715ad763", size = 8102437, upload-time = "2023-01-02T07:34:45.541Z" }, +] + +[[package]] +name = "gunicorn" +version = "20.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/5b/0d1f0296485a6af03366604142ea8f19f0833894db3512a40ed07b2a56dd/gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8", size = 370601, upload-time = "2021-03-27T01:54:37.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/dd/5b190393e6066286773a67dfcc2f9492058e9b57c4867a95f1ba5caf0a83/gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", size = 79531, upload-time = "2021-04-27T12:16:23.375Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + +[[package]] +name = "uv-django-app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "dj-database-url" }, + { name = "django" }, + { name = "gunicorn" }, + { name = "waitress" }, + { name = "whitenoise" }, +] + +[package.metadata] +requires-dist = [ + { name = "dj-database-url", specifier = "==1.2.0" }, + { name = "django", specifier = "==4.1.5" }, + { name = "gunicorn", specifier = "==20.1.0" }, + { name = "waitress", specifier = "==2.1.2" }, + { name = "whitenoise", specifier = "==6.3.0" }, +] + +[[package]] +name = "waitress" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/83/c3de9799e2305898b02ea67bcd125ad06f271e2a82cc86fe66b7bf4e6f63/waitress-2.1.2.tar.gz", hash = "sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba", size = 179823, upload-time = "2022-05-30T21:46:21.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/6a/b4b5c582e04e837e4422cab6ec9de7fc10ca7ad7f4e370bb89d280d39552/waitress-2.1.2-py3-none-any.whl", hash = "sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a", size = 57741, upload-time = "2022-05-30T21:46:19.371Z" }, +] + +[[package]] +name = "whitenoise" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/84/c05ab17d96e2546c120a9271d7f696b6e355397176482b07ae62153bd555/whitenoise-6.3.0.tar.gz", hash = "sha256:fe0af31504ab08faa1ec7fc02845432096e40cc1b27e6a7747263d7b30fb51fa", size = 18566, upload-time = "2023-01-03T10:31:35.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/fd/90d04468b1b05445a2f0686bd6d30bf9b5512d85026d88f3679b7f417b49/whitenoise-6.3.0-py3-none-any.whl", hash = "sha256:cf8ecf56d86ba1c734fdb5ef6127312e39e92ad5947fef9033dc9e43ba2777d9", size = 19997, upload-time = "2023-01-03T10:31:33.56Z" }, +] diff --git a/src/python/finalize/finalize.go b/src/python/finalize/finalize.go index 44fdf97c5..ffd9e1cfa 100644 --- a/src/python/finalize/finalize.go +++ b/src/python/finalize/finalize.go @@ -75,10 +75,19 @@ func (f *Finalizer) HandleCollectstatic() error { return nil } - exists, err := f.Requirements.FindAnyPackage(f.Stager.BuildDir(), "django", "Django") - if err != nil { - f.Log.Debug("Error during FindAnyPackage, skipping collectstatic") - return err + // Supply moves the requirements.txt generated from uv.lock from the app + // directory to the deps dir. + var exists bool + for _, dir := range []string{f.Stager.BuildDir(), f.Stager.DepDir()} { + found, err := f.Requirements.FindAnyPackage(dir, "django") + if err != nil { + f.Log.Debug("Error during FindAnyPackage, skipping collectstatic") + return err + } + if found { + exists = true + break + } } if !exists { diff --git a/src/python/finalize/finalize_test.go b/src/python/finalize/finalize_test.go index bb4d8a41b..024b8936f 100644 --- a/src/python/finalize/finalize_test.go +++ b/src/python/finalize/finalize_test.go @@ -87,7 +87,7 @@ var _ = Describe("Finalize", func() { }) Context("app uses Django", func() { BeforeEach(func() { - mockRequirements.EXPECT().FindAnyPackage(buildDir, "django", "Django").Return(true, nil) + mockRequirements.EXPECT().FindAnyPackage(buildDir, "django").Return(true, nil) mockManagePyFinder.EXPECT().FindManagePy(buildDir).Return("/foo/bar/manage.py", nil) }) @@ -112,13 +112,27 @@ var _ = Describe("Finalize", func() { Context("app does not use Django", func() { BeforeEach(func() { - mockRequirements.EXPECT().FindAnyPackage(buildDir, "django", "Django").Return(false, nil) + mockRequirements.EXPECT().FindAnyPackage(buildDir, "django").Return(false, nil) + mockRequirements.EXPECT().FindAnyPackage(filepath.Join(depsDir, depsIdx), "django").Return(false, nil) }) It("does not run anything", func() { Expect(finalizer.HandleCollectstatic()).To(Succeed()) }) }) + + Context("Django is only in the requirements.txt generated during supply", func() { + BeforeEach(func() { + mockRequirements.EXPECT().FindAnyPackage(buildDir, "django").Return(false, nil) + mockRequirements.EXPECT().FindAnyPackage(filepath.Join(depsDir, depsIdx), "django").Return(true, nil) + mockManagePyFinder.EXPECT().FindManagePy(buildDir).Return("/foo/bar/manage.py", nil) + }) + + It("runs collectstatic", func() { + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "/foo/bar/manage.py", "collectstatic", "--noinput", "--traceback").Return(nil) + Expect(finalizer.HandleCollectstatic()).To(Succeed()) + }) + }) }) }) diff --git a/src/python/integration/init_test.go b/src/python/integration/init_test.go index 9f9f19159..ecf7dcadf 100644 --- a/src/python/integration/init_test.go +++ b/src/python/integration/init_test.go @@ -104,6 +104,7 @@ func TestIntegration(t *testing.T) { suite("Miscellaneous", testMiscellaneous(platform, fixtures)) suite("Django", testDjango(platform, fixtures)) suite("Pipenv", testPipenv(platform, fixtures)) + suite("Uv", testUv(platform, fixtures)) suite("Override", testOverride(platform, fixtures)) suite("Multibuildpack", testMultibuildpack(platform, fixtures)) suite("Sealights", testSealights(platform, fixtures)) diff --git a/src/python/integration/uv_test.go b/src/python/integration/uv_test.go new file mode 100644 index 000000000..00bf35b31 --- /dev/null +++ b/src/python/integration/uv_test.go @@ -0,0 +1,134 @@ +package integration_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cloudfoundry/switchblade" + "github.com/sclevine/spec" + + . "github.com/cloudfoundry/switchblade/matchers" + . "github.com/onsi/gomega" +) + +func testUv(platform switchblade.Platform, fixtures string) func(*testing.T, spec.G, spec.S) { + return func(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + Eventually = NewWithT(t).Eventually + + name string + source string + ) + + it.Before(func() { + var err error + name, err = switchblade.RandomName() + Expect(err).NotTo(HaveOccurred()) + + source, err = switchblade.Source(filepath.Join(fixtures, "uv")) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(platform.Delete.Execute(name)).To(Succeed()) + Expect(os.RemoveAll(source)).To(Succeed()) + }) + + context("when pushing an app that has a uv.lock and no requirements.txt", func() { + it("successfully deploys, generates a requirements.txt, and installs the packages", func() { + deployment, logs, err := platform.Deploy. + WithBuildpacks("python_buildpack"). + Execute(name, source) + Expect(err).NotTo(HaveOccurred()) + + Eventually(deployment).Should(Serve(ContainSubstring("Hello, World!"))) + + Expect(logs.String()).To(SatisfyAll( + ContainSubstring("Using Python version 3.12 from .python-version"), + MatchRegexp(`Installing python 3\.12\.\d+`), + ContainSubstring("Installing uv"), + ContainSubstring("Running uv sync"), + ContainSubstring("+ flask==3.1.3"), + ContainSubstring("+ gunicorn==26.2.0"), + Not(ContainSubstring("pytest")), + Not(ContainSubstring("Running Pip Install")), + )) + }) + }) + + context("when pushing an app whose uv.lock contains a wrong hash", func() { + it.Before(func() { + lockPath := filepath.Join(source, "uv.lock") + lock, err := os.ReadFile(lockPath) + Expect(err).NotTo(HaveOccurred()) + + const blinkerWheelHash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc" + Expect(string(lock)).To(ContainSubstring(blinkerWheelHash)) + lock = []byte(strings.ReplaceAll(string(lock), blinkerWheelHash, "sha256:"+strings.Repeat("0", 64))) + Expect(os.WriteFile(lockPath, lock, 0644)).To(Succeed()) + }) + + it("fails to stage", func() { + _, logs, err := platform.Deploy. + WithBuildpacks("python_buildpack"). + Execute(name, source) + Expect(err).To(HaveOccurred()) + + Expect(logs.String()).To(SatisfyAll( + ContainSubstring("Hash mismatch for `blinker==1.9.0`"), + ContainSubstring("Could not install packages with uv"), + )) + }) + }) + + context("when pushing a Django app that has a uv.lock", func() { + it.Before(func() { + Expect(os.RemoveAll(source)).To(Succeed()) + + var err error + source, err = switchblade.Source(filepath.Join(fixtures, "uv_django")) + Expect(err).NotTo(HaveOccurred()) + }) + + it("installs the packages with uv and runs collectstatic", func() { + deployment, logs, err := platform.Deploy. + WithBuildpacks("python_buildpack"). + Execute(name, source) + Expect(err).NotTo(HaveOccurred()) + + Eventually(deployment).Should(Serve(ContainSubstring("The install worked successfully!"))) + + Expect(logs.String()).To(SatisfyAll( + ContainSubstring("Running uv sync"), + ContainSubstring("+ django==4.1.5"), + ContainSubstring("collectstatic --noinput"), + )) + }) + }) + + context("when pushing an app that has a uv.lock and a requirements.txt", func() { + it.Before(func() { + CreateRequirementsTxtFile(Expect, source, "requirements.txt", + "Flask", "Jinja2", "MarkupSafe", "Werkzeug", "gunicorn", "itsdangerous") + }) + + it("installs the packages from requirements.txt", func() { + deployment, logs, err := platform.Deploy. + WithBuildpacks("python_buildpack"). + Execute(name, source) + Expect(err).NotTo(HaveOccurred()) + + Eventually(deployment).Should(Serve(ContainSubstring("Hello, World!"))) + + Expect(logs.String()).To(SatisfyAll( + ContainSubstring("Found requirements.txt, ignoring uv.lock"), + Not(ContainSubstring("Installing uv")), + ContainSubstring("Collecting Flask"), + )) + }) + }) + } +} diff --git a/src/python/requirements/requirements.go b/src/python/requirements/requirements.go index e40c58c45..fea7d9c39 100644 --- a/src/python/requirements/requirements.go +++ b/src/python/requirements/requirements.go @@ -11,6 +11,8 @@ import ( const requirementsParserRegex = `(?m)^[\w\-\w\[\]]+` +var packageNameSeparatorRegex = regexp.MustCompile(`[-_.]+`) + type Reqs struct{} func (m Reqs) FindAnyPackage(buildDir string, searchedPackages ...string) (bool, error) { @@ -28,9 +30,17 @@ func (m Reqs) FindAnyPackage(buildDir string, searchedPackages ...string) (bool, return false, err } - for _, searchedPackage := range searchedPackages { - if containsPackage(reqPackages, searchedPackage) { - return true, nil + normalizedSearchedPackages := make([]string, len(searchedPackages)) + for i, searchedPackage := range searchedPackages { + normalizedSearchedPackages[i] = normalizePackageName(searchedPackage) + } + + for _, reqPackage := range reqPackages { + normalizedReqPackage := normalizePackageName(reqPackage) + for _, searchedPackage := range normalizedSearchedPackages { + if normalizedReqPackage == searchedPackage { + return true, nil + } } } } @@ -81,6 +91,12 @@ func containsPackage(packages []string, searchedPackage string) bool { return false } +// normalizePackageName normalizes a package name as defined in PEP 503, since +// e.g. 'PyNaCl' may be written as 'pynacl' in generated requirements files. +func normalizePackageName(name string) string { + return strings.ToLower(packageNameSeparatorRegex.ReplaceAllString(name, "-")) +} + func packageIsExcluded(excludedPackages []string, packageFullName string) bool { regex := regexp.MustCompile(requirementsParserRegex) diff --git a/src/python/requirements/requirements_test.go b/src/python/requirements/requirements_test.go index 24be38d56..5d3a5b7ef 100644 --- a/src/python/requirements/requirements_test.go +++ b/src/python/requirements/requirements_test.go @@ -60,6 +60,33 @@ package6[test]==6.0.0 }) }) + Context("package names are written differently", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(tempDir, "requirements.txt"), []byte(`pynacl==1.5.0 +argon2_cffi==23.1.0 +Django==5.2 +`), 0644)).To(Succeed()) + }) + + It("matches the normalized package names", func() { + exists, err := req.FindAnyPackage(tempDir, "PyNaCl") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + exists, err = req.FindAnyPackage(tempDir, "argon2-cffi") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + exists, err = req.FindAnyPackage(tempDir, "django") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + exists, err = req.FindAnyPackage(tempDir, "cffi") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + }) + Context("multiple requirements.txt files", func() { Context("packages are in a recursive requirements.txt file", func() { BeforeEach(func() { diff --git a/src/python/supply/mocks_test.go b/src/python/supply/mocks_test.go index 0397d0613..f0d378c1d 100644 --- a/src/python/supply/mocks_test.go +++ b/src/python/supply/mocks_test.go @@ -313,6 +313,20 @@ func (mr *MockCommandMockRecorder) Output(dir, program interface{}, args ...inte return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Output", reflect.TypeOf((*MockCommand)(nil).Output), varargs...) } +// Run mocks base method. +func (m *MockCommand) Run(cmd *exec.Cmd) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Run", cmd) + ret0, _ := ret[0].(error) + return ret0 +} + +// Run indicates an expected call of Run. +func (mr *MockCommandMockRecorder) Run(cmd interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockCommand)(nil).Run), cmd) +} + // RunWithOutput mocks base method. func (m *MockCommand) RunWithOutput(cmd *exec.Cmd) ([]byte, error) { m.ctrl.T.Helper() diff --git a/src/python/supply/supply.go b/src/python/supply/supply.go index 613c055d3..219d5d3a0 100644 --- a/src/python/supply/supply.go +++ b/src/python/supply/supply.go @@ -22,6 +22,9 @@ import ( const EnvPipVersion = "BP_PIP_VERSION" +var uvDir = filepath.Join("/tmp", "uv") +var uvBin = filepath.Join(uvDir, "bin", "uv") + type Stager interface { BuildDir() string CacheDir() string @@ -46,6 +49,7 @@ type Installer interface { type Command interface { Execute(dir string, stdout io.Writer, stderr io.Writer, program string, args ...string) error Output(dir string, program string, args ...string) (string, error) + Run(cmd *exec.Cmd) error RunWithOutput(cmd *exec.Cmd) ([]byte, error) } @@ -64,6 +68,7 @@ type Supplier struct { Logfile *os.File HasNltkData bool removeRequirementsText bool + usesUv bool Requirements Reqs } @@ -92,6 +97,11 @@ func RunPython(s *Supplier) error { return err } + if err := s.HandleUvPythonVersion(); err != nil { + s.Log.Error("Error checking for .python-version: %v", err) + return err + } + if err := s.HandlePipfile(); err != nil { s.Log.Error("Error checking for Pipfile.lock: %v", err) return err @@ -107,6 +117,11 @@ func RunPython(s *Supplier) error { return err } + if err := s.InstallUv(); err != nil { + s.Log.Error("Could not install uv: %v", err) + return err + } + if err := s.InstallPipEnv(); err != nil { s.Log.Error("Could not install pipenv: %v", err) return err @@ -137,7 +152,17 @@ func RunPython(s *Supplier) error { return fmt.Errorf("could not check vendor existence: %v", err) } - if vendored { + if s.usesUv && vendored { + if err := s.RunUvVendored(); err != nil { + s.Log.Error("Could not install vendored packages with uv: %v", err) + return err + } + } else if s.usesUv { + if err := s.RunUvSync(); err != nil { + s.Log.Error("Could not install packages with uv: %v", err) + return err + } + } else if vendored { if err := s.RunPipVendored(); err != nil { s.Log.Error("Could not install vendored pip packages: %v", err) return err @@ -168,11 +193,9 @@ func RunPython(s *Supplier) error { s.Log.Debug("Size of pip cache dir: %s", cacheDirSize) } - if s.removeRequirementsText { - if err := os.Remove(filepath.Join(s.Stager.BuildDir(), "requirements.txt")); err != nil { - s.Log.Error("Unable to clean up app directory: %s", err.Error()) - return err - } + if err := s.MoveGeneratedRequirementsTxt(); err != nil { + s.Log.Error("Unable to clean up app directory: %s", err.Error()) + return err } dirSnapshot.Diff() @@ -218,6 +241,72 @@ func (s *Supplier) HandlePipfile() error { return nil } +// HandleUvPythonVersion writes runtime.txt from the .python-version file of a +// uv app, unless the app provides its own runtime.txt. Like InstallUv, it +// ignores apps that provide a requirements.txt. +func (s *Supplier) HandleUvPythonVersion() error { + uvLockExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "uv.lock")) + if err != nil { + return err + } + + requirementstxtExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "requirements.txt")) + if err != nil { + return err + } + + runtimeExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.DepDir(), "runtime.txt")) + if err != nil { + return err + } + + pythonVersionPath := filepath.Join(s.Stager.BuildDir(), ".python-version") + pythonVersionExists, err := libbuildpack.FileExists(pythonVersionPath) + if err != nil { + return err + } + + if !uvLockExists || requirementstxtExists || runtimeExists || !pythonVersionExists { + return nil + } + + contents, err := os.ReadFile(pythonVersionPath) + if err != nil { + return err + } + + version, ok := parsePythonVersionFile(string(contents)) + if !ok { + s.Log.Warning("Unsupported Python version in .python-version, using the default Python version") + return nil + } + + s.Log.Info("Using Python version %s from .python-version", version) + return os.WriteFile(filepath.Join(s.Stager.DepDir(), "runtime.txt"), []byte(s.formatVersion(version)), 0644) +} + +// parsePythonVersionFile returns the first version in a .python-version +// file. Only CPython versions like "3.12", "3.12.4" or "cpython-3.12" are +// supported. +func parsePythonVersionFile(contents string) (string, bool) { + versionRegex := regexp.MustCompile(`^(?:cpython[-@]?)?(\d+\.\d+(?:\.\d+)?)$`) + + for _, line := range strings.Split(contents, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + match := versionRegex.FindStringSubmatch(line) + if match == nil { + return "", false + } + return match[1], true + } + + return "", false +} + func (s *Supplier) InstallPython() error { var dep libbuildpack.Dependency @@ -452,6 +541,170 @@ func (s *Supplier) InstallPipEnv() error { return s.writeTempRequirementsTxt(outputString) } +// InstallUv installs uv for apps with a uv.lock and exports the locked +// packages into a temporary requirements.txt, which is used to detect +// packages that need special handling (e.g. libffi or Django). The packages +// themselves are installed by RunUvSync. An existing requirements.txt always +// takes precedence. +func (s *Supplier) InstallUv() error { + uvLockExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "uv.lock")) + if err != nil { + return fmt.Errorf("could not check uv.lock existence: %v", err) + } else if !uvLockExists { + return nil + } + + requirementstxtExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "requirements.txt")) + if err != nil { + return err + } else if requirementstxtExists { + s.Log.Info("Found requirements.txt, ignoring uv.lock") + return nil + } + + s.Log.Info("Installing uv") + if err := s.installUv(); err != nil { + return err + } + s.usesUv = true + + s.Log.Info("Generating 'requirements.txt' from uv.lock") + cmd := exec.Command(uvBin, uvExportArgs()...) + cmd.Dir = s.Stager.BuildDir() + cmd.Env = s.uvEnv() + cmd.Stderr = indentWriter(os.Stderr) + output, err := s.Command.RunWithOutput(cmd) + if err != nil { + return fmt.Errorf("failed to export uv.lock: %v", err) + } + + return s.writeTempRequirementsTxt(string(output)) +} + +// installUv installs the uv wheel from the manifest into uvDir, outside of the +// Python installation, so that uv does not end up in the droplet. +func (s *Supplier) installUv() error { + wheelDir, err := os.MkdirTemp("", "uv-wheel") + if err != nil { + return err + } + defer os.RemoveAll(wheelDir) + + if err := s.Installer.InstallOnlyVersion("uv", wheelDir); err != nil { + return err + } + + if err := os.RemoveAll(uvDir); err != nil { + return err + } + + if err := s.Command.Execute(s.Stager.BuildDir(), indentWriter(os.Stdout), indentWriter(os.Stderr), + "python", + "-m", "pip", + "install", "uv", + "--no-index", + fmt.Sprintf("--find-links=%s", wheelDir), + fmt.Sprintf("--target=%s", uvDir), + ); err != nil { + return fmt.Errorf("could not install uv: %v", err) + } + + return nil +} + +// RunUvSync installs the locked packages with 'uv sync' directly into the +// Python installation, verifying them against the hashes in uv.lock. +func (s *Supplier) RunUvSync() error { + s.Log.BeginStep("Running uv sync") + + args := uvSyncArgs() + s.Log.Info("uv %s", strings.Join(args, " ")) + + cmd := exec.Command(uvBin, args...) + cmd.Dir = s.Stager.BuildDir() + cmd.Env = append(s.uvEnv(), "UV_PROJECT_ENVIRONMENT="+filepath.Join(s.Stager.DepDir(), "python")) + cmd.Stdout = indentWriter(os.Stdout) + cmd.Stderr = indentWriter(os.Stderr) + if err := s.Command.Run(cmd); err != nil { + return fmt.Errorf("could not run uv sync: %v", err) + } + + return s.Stager.LinkDirectoryInDepDir(filepath.Join(s.Stager.DepDir(), "python", "bin"), "bin") +} + +// RunUvVendored installs the packages exported from uv.lock from the vendor +// directory without network access. 'uv sync' cannot be used for this, as it +// only downloads the files referenced in uv.lock. The hashes in the exported +// requirements.txt are still verified. +func (s *Supplier) RunUvVendored() error { + s.Log.BeginStep("Running uv pip install (Vendored)") + + pythonPath := filepath.Join(s.Stager.DepDir(), "python", "bin", "python") + args := []string{ + "pip", "install", + "--python", pythonPath, + "--offline", + "--no-index", + "--find-links", filepath.Join(s.Stager.BuildDir(), "vendor"), + "-r", filepath.Join(s.Stager.BuildDir(), "requirements.txt"), + } + s.Log.Info("uv %s", strings.Join(args, " ")) + + cmd := exec.Command(uvBin, args...) + cmd.Dir = s.Stager.BuildDir() + cmd.Env = s.uvEnv() + cmd.Stdout = indentWriter(os.Stdout) + cmd.Stderr = indentWriter(os.Stderr) + if err := s.Command.Run(cmd); err != nil { + s.Log.Info("Running uv pip install failed. The vendor directory must contain all packages from uv.lock, matching the hashes in uv.lock. If the project defines a [build-system], its build backend must be vendored as well.") + return fmt.Errorf("could not run uv pip install: %v", err) + } + + return s.Stager.LinkDirectoryInDepDir(filepath.Join(s.Stager.DepDir(), "python", "bin"), "bin") +} + +// uvExportArgs exports the locked production dependencies without touching +// the network. The project itself is only emitted if it is a package (i.e. +// it defines a build-system). +func uvExportArgs() []string { + return []string{ + "export", + "--frozen", + "--format", "requirements-txt", + "--no-dev", + "--no-editable", + "--no-header", + "--no-annotate", + } +} + +// uvSyncArgs installs the same packages as exported by uvExportArgs. +// '--inexact' keeps packages that are not part of uv.lock, like pip. +func uvSyncArgs() []string { + return []string{ + "sync", + "--frozen", + "--no-dev", + "--no-editable", + "--inexact", + } +} + +// uvEnv makes uv use the Python installed by the buildpack, trust the same +// certificates as pip, and keep its cache in the app cache. +func (s *Supplier) uvEnv() []string { + env := append(os.Environ(), + "UV_CACHE_DIR="+filepath.Join(s.Stager.CacheDir(), "uv_cache"), + "UV_LINK_MODE=copy", + "UV_PYTHON="+filepath.Join(s.Stager.DepDir(), "python", "bin", "python"), + "UV_PYTHON_DOWNLOADS=never", + ) + if os.Getenv("UV_SYSTEM_CERTS") == "" { + env = append(env, "UV_SYSTEM_CERTS=1") + } + return env +} + func pipfileToRequirements(lockFilePath string) (string, error) { var lockFile struct { Meta struct { @@ -928,6 +1181,24 @@ func (s *Supplier) writeTempRequirementsTxt(content string) error { return os.WriteFile(filepath.Join(s.Stager.BuildDir(), "requirements.txt"), []byte(content), 0644) } +// MoveGeneratedRequirementsTxt removes a generated requirements.txt from the +// app directory. For uv apps, a copy is kept in the deps dir, so that +// finalize can still detect packages like Django. Pipfile apps keep their +// previous behaviour and do not get a copy. +func (s *Supplier) MoveGeneratedRequirementsTxt() error { + if !s.removeRequirementsText { + return nil + } + + requirementsPath := filepath.Join(s.Stager.BuildDir(), "requirements.txt") + if s.usesUv { + if err := libbuildpack.CopyFile(requirementsPath, filepath.Join(s.Stager.DepDir(), "requirements.txt")); err != nil { + return err + } + } + return os.Remove(requirementsPath) +} + func (s *Supplier) hasBuildOptions() bool { helpCommand := append(pipCommand(), "install", "--no-build-isolation", "-h") err := s.Command.Execute(s.Stager.BuildDir(), nil, nil, helpCommand[0], helpCommand[1:]...) diff --git a/src/python/supply/supply_test.go b/src/python/supply/supply_test.go index b6533dee4..dfbd5dd10 100644 --- a/src/python/supply/supply_test.go +++ b/src/python/supply/supply_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "github.com/cloudfoundry/python-buildpack/src/python/supply" @@ -211,6 +212,80 @@ var _ = Describe("Supply", func() { }) }) + Describe("HandleUvPythonVersion", func() { + BeforeEach(func() { + Expect(os.MkdirAll(depDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte("version = 1"), 0644)).To(Succeed()) + }) + + DescribeTable("creates runtime.txt from .python-version", + func(pythonVersion, expectedRuntime string) { + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte(pythonVersion), 0644)).To(Succeed()) + + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + + runtimeContents, err := os.ReadFile(filepath.Join(depDir, "runtime.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(runtimeContents)).To(Equal(expectedRuntime)) + }, + Entry("minor version", "3.12\n", "python-3.12.x"), + Entry("patch version", "3.12.4\n", "python-3.12.4"), + Entry("implementation prefix", "cpython-3.13\n", "python-3.13.x"), + Entry("comments and multiple versions", "# pinned\n\n3.11\n3.12\n", "python-3.11.x"), + ) + + Context("when .python-version contains an unsupported version", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte("pypy@3.10\n"), 0644)).To(Succeed()) + }) + + It("warns and falls back to the default Python version", func() { + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Unsupported Python version in .python-version")) + Expect(filepath.Join(depDir, "runtime.txt")).NotTo(BeAnExistingFile()) + }) + }) + + Context("when runtime.txt already exists", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(depDir, "runtime.txt"), []byte("python-3.11.x"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte("3.12\n"), 0644)).To(Succeed()) + }) + + It("keeps runtime.txt", func() { + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + + runtimeContents, err := os.ReadFile(filepath.Join(depDir, "runtime.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(runtimeContents)).To(Equal("python-3.11.x")) + }) + }) + + Context("when the app provides a requirements.txt", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, "requirements.txt"), []byte("flask"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte("3.12\n"), 0644)).To(Succeed()) + }) + + It("ignores .python-version, as uv.lock is ignored as well", func() { + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + Expect(filepath.Join(depDir, "runtime.txt")).NotTo(BeAnExistingFile()) + }) + }) + + Context("when the app does not use uv", func() { + BeforeEach(func() { + Expect(os.Remove(filepath.Join(buildDir, "uv.lock"))).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte("3.12\n"), 0644)).To(Succeed()) + }) + + It("ignores .python-version", func() { + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + Expect(filepath.Join(depDir, "runtime.txt")).NotTo(BeAnExistingFile()) + }) + }) + }) + // Add the expects for what the installFfi function uses expectInstallFfi := func() string { ffiDir := filepath.Join(depDir, "libffi") @@ -297,6 +372,234 @@ var _ = Describe("Supply", func() { }) }) + Describe("InstallUv", func() { + BeforeEach(func() { + Expect(os.MkdirAll(depDir, 0755)).To(Succeed()) + }) + + Context("when uv.lock does not exist", func() { + It("does not install uv", func() { + Expect(supplier.InstallUv()).To(Succeed()) + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + + Context("when uv.lock and requirements.txt both exist", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte("version = 1"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "requirements.txt"), []byte("flask"), 0644)).To(Succeed()) + }) + + It("does not install uv and keeps requirements.txt", func() { + Expect(supplier.InstallUv()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Found requirements.txt, ignoring uv.lock")) + + requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal("flask")) + }) + }) + + Context("when uv.lock exists but requirements.txt does not exist", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, "pyproject.toml"), []byte("[project]"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte("version = 1"), 0644)).To(Succeed()) + }) + + It("installs uv and generates requirements.txt with hashes using uv export", func() { + const exported = "flask==3.1.3 \\\n --hash=sha256:4eda\ngunicorn==26.2.0 \\\n --hash=sha256:17ef\n" + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv") + mockCommand.EXPECT().RunWithOutput(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) ([]byte, error) { + Expect(cmd.Path).To(Equal("/tmp/uv/bin/uv")) + Expect(cmd.Args[1:]).To(Equal([]string{ + "export", "--frozen", "--format", "requirements-txt", + "--no-dev", "--no-editable", "--no-header", "--no-annotate", + })) + Expect(cmd.Dir).To(Equal(buildDir)) + Expect(cmd.Env).To(ContainElements( + "UV_CACHE_DIR="+filepath.Join(cacheDir, "uv_cache"), + "UV_PYTHON="+filepath.Join(depDir, "python", "bin", "python"), + "UV_PYTHON_DOWNLOADS=never", + "UV_SYSTEM_CERTS=1", + )) + return []byte(exported), nil + }) + + Expect(supplier.InstallUv()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Generating 'requirements.txt' from uv.lock")) + + requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal(exported)) + }) + + Context("when UV_SYSTEM_CERTS is set by the app", func() { + BeforeEach(func() { + DeferCleanup(os.Unsetenv, "UV_SYSTEM_CERTS") + Expect(os.Setenv("UV_SYSTEM_CERTS", "false")).To(Succeed()) + }) + + It("does not override it", func() { + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv") + mockCommand.EXPECT().RunWithOutput(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) ([]byte, error) { + Expect(cmd.Env).To(ContainElement("UV_SYSTEM_CERTS=false")) + Expect(cmd.Env).NotTo(ContainElement("UV_SYSTEM_CERTS=1")) + return []byte("flask==3.1.3\n"), nil + }) + + Expect(supplier.InstallUv()).To(Succeed()) + }) + }) + + Context("when uv cannot be installed", func() { + It("returns the error", func() { + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()).Return(errors.New("no versions of uv found")) + + Expect(supplier.InstallUv()).To(MatchError("no versions of uv found")) + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + + Context("when pip cannot install the uv wheel", func() { + It("returns a useful error", func() { + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv").Return(errors.New("exit status 1")) + + Expect(supplier.InstallUv()).To(MatchError("could not install uv: exit status 1")) + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + + Context("when uv export fails", func() { + It("returns a useful error", func() { + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv") + mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return(nil, errors.New("exit status 2")) + + Expect(supplier.InstallUv()).To(MatchError("failed to export uv.lock: exit status 2")) + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + }) + }) + + Describe("RunUvSync", func() { + It("installs the locked packages into the Python installation", func() { + mockCommand.EXPECT().Run(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { + Expect(cmd.Path).To(Equal("/tmp/uv/bin/uv")) + Expect(cmd.Args[1:]).To(Equal([]string{"sync", "--frozen", "--no-dev", "--no-editable", "--inexact"})) + Expect(cmd.Dir).To(Equal(buildDir)) + Expect(cmd.Env).To(ContainElements( + "UV_PROJECT_ENVIRONMENT="+filepath.Join(depDir, "python"), + "UV_PYTHON="+filepath.Join(depDir, "python", "bin", "python"), + "UV_PYTHON_DOWNLOADS=never", + )) + return nil + }) + mockStager.EXPECT().LinkDirectoryInDepDir(filepath.Join(depDir, "python", "bin"), "bin") + + Expect(supplier.RunUvSync()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Running uv sync")) + }) + + Context("when uv sync fails", func() { + It("returns a useful error", func() { + mockCommand.EXPECT().Run(gomock.Any()).Return(errors.New("exit status 1")) + + Expect(supplier.RunUvSync()).To(MatchError("could not run uv sync: exit status 1")) + }) + }) + }) + + Describe("RunUvVendored", func() { + It("installs the exported packages from the vendor directory", func() { + mockCommand.EXPECT().Run(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { + Expect(cmd.Path).To(Equal("/tmp/uv/bin/uv")) + Expect(cmd.Args[1:]).To(Equal([]string{ + "pip", "install", + "--python", filepath.Join(depDir, "python", "bin", "python"), + "--offline", + "--no-index", + "--find-links", filepath.Join(buildDir, "vendor"), + "-r", filepath.Join(buildDir, "requirements.txt"), + })) + Expect(cmd.Dir).To(Equal(buildDir)) + Expect(cmd.Env).To(ContainElement("UV_PYTHON_DOWNLOADS=never")) + return nil + }) + mockStager.EXPECT().LinkDirectoryInDepDir(filepath.Join(depDir, "python", "bin"), "bin") + + Expect(supplier.RunUvVendored()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Running uv pip install (Vendored)")) + }) + + Context("when uv pip install fails", func() { + It("alerts the user", func() { + mockCommand.EXPECT().Run(gomock.Any()).Return(errors.New("exit status 1")) + + Expect(supplier.RunUvVendored()).To(MatchError("could not run uv pip install: exit status 1")) + Expect(buffer.String()).To(ContainSubstring("The vendor directory must contain all packages from uv.lock, matching the hashes in uv.lock. If the project defines a [build-system], its build backend must be vendored as well.")) + }) + }) + }) + + Describe("MoveGeneratedRequirementsTxt", func() { + BeforeEach(func() { + Expect(os.MkdirAll(depDir, 0755)).To(Succeed()) + }) + + Context("when requirements.txt was generated from uv.lock", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, "pyproject.toml"), []byte("[project]"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte("version = 1"), 0644)).To(Succeed()) + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv") + mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return([]byte("django==5.2\n"), nil) + Expect(supplier.InstallUv()).To(Succeed()) + }) + + It("moves it from the app directory to the deps dir", func() { + Expect(supplier.MoveGeneratedRequirementsTxt()).To(Succeed()) + + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + requirementsContents, err := os.ReadFile(filepath.Join(depDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal("django==5.2\n")) + }) + }) + + Context("when requirements.txt was generated from Pipfile.lock", func() { + BeforeEach(func() { + const lockFileContent string = `{"_meta":{"sources":[]},"default":{"django":{"version":"==5.2"}}}` + Expect(os.WriteFile(filepath.Join(buildDir, "Pipfile"), []byte("some content"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "Pipfile.lock"), []byte(lockFileContent), 0644)).To(Succeed()) + Expect(supplier.InstallPipEnv()).To(Succeed()) + }) + + It("removes it from the app directory without keeping a copy", func() { + Expect(supplier.MoveGeneratedRequirementsTxt()).To(Succeed()) + + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + Expect(filepath.Join(depDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + + Context("when requirements.txt was provided by the app", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, "requirements.txt"), []byte("django"), 0644)).To(Succeed()) + }) + + It("keeps it in the app directory", func() { + Expect(supplier.MoveGeneratedRequirementsTxt()).To(Succeed()) + + Expect(filepath.Join(buildDir, "requirements.txt")).To(BeARegularFile()) + Expect(filepath.Join(depDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + }) + Describe("HandlePylibmc", func() { BeforeEach(func() { DeferCleanup(os.Setenv, "LIBMEMCACHED", "")