Skip to content

Commit ad6bc0f

Browse files
committed
Rewrite editable VCS paths at build time too
Heroku builds occur at a different path to which the app will be run at run-time. As such, we have to perform path rewriting for editable dependencies, so that they work after relocation. The existing rewriting is performed at app boot (see code comment for more details), and works fine with pip and Poetry. However, I discovered that Pipenv doesn't correctly reinstall editable VCS dependencies if they use the new PEP660 style editable interface, which I've reported upstream here: pypa/pipenv#6348 This issue has affected apps using editable VCS dependencies with Pipenv for some time, but until now only at build-time for cached builds. However, after #1753 (which thankfully isn't yet released, due to me catching this as part of updating the tests to exercise the new PEP660 style editable interface) would otherwise affect apps at run-time too. As a workaround, we can perform build time rewriting of paths too, but must do so only for VCS dependencies (see code comment for why). Lastly, the Pipenv bug also requires that we perform explicit cache invalidation for Pipenv apps after the src dir move in #1753.
1 parent bb28227 commit ad6bc0f

File tree

12 files changed

+85
-64
lines changed

12 files changed

+85
-64
lines changed

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [Unreleased]
44

5+
- Added build-time rewriting of editable VCS dependency paths (in addition to the existing run-time rewriting), to work around an upstream Pipenv bug with editable VCS dependencies not being reinstalled correctly for cached builds. ([#1756](https://github.com/heroku/heroku-buildpack-python/pull/1756))
56
- Changed the location of repositories for editable VCS dependencies when using pip and Pipenv, to improve build performance and match the behaviour when using Poetry. ([#1753](https://github.com/heroku/heroku-buildpack-python/pull/1753))
67

78
## [v277] - 2025-02-17

Diff for: bin/compile

+36-6
Original file line numberDiff line numberDiff line change
@@ -280,13 +280,43 @@ if [[ \$HOME != "/app" ]]; then
280280
fi
281281
EOT
282282

283-
# At runtime, rewrite paths in editable package .egg-link, .pth and finder files from the build time paths
284-
# (such as `/tmp/build_<hash>`) back to `/app`. This is not done during the build itself, since later
285-
# buildpacks still need the build time paths.
283+
# When dependencies are installed in editable mode, the package manager/build backend creates `.pth`
284+
# (and related) files in site-packages, which contain absolute path references to the actual location
285+
# of the packages. By default the Heroku build runs from a directory like `/tmp/build_<hash>`, which
286+
# changes every build and also differs from the app location at runtime (`/app`). This means any build
287+
# directory paths referenced in .pth and related files will no longer exist at runtime or during cached
288+
# rebuilds, unless we rewrite the paths.
289+
#
290+
# Ideally, we would be able to rewrite all paths to use the `/app/.heroku/python/` symlink trick we use
291+
# when invoking Python, since then the same path would work across the current build, runtime and cached
292+
# rebuilds. However, this trick only works for paths under that directory (since it's not possible to
293+
# symlink `/app` or other directories we don't own), and when apps use path-based editable dependencies
294+
# the paths will be outside of that (such as a subdirectory of the app source, or even the root of the
295+
# build directory). We also can't just rewrite all paths now ready for runtime, since other buildpacks
296+
# might run after this one that make use of the editable dependencies. As such, we have to perform path
297+
# rewriting for path-based editable dependencies at app boot instead.
298+
#
299+
# For VCS editable dependencies, we can use the symlink trick and so configure the repo checkout location
300+
# as `/app/.heroku/python/src/`, which in theory should mean the `.pth` files use that path. However,
301+
# some build backends (such as setuptools' PEP660 implementation) call realpath on it causing the
302+
# `/tmp/build_*` location to be written instead, meaning VCS src paths need to be rewritten regardless.
303+
#
304+
# In addition to ensuring dependencies work for subsequent buildpacks and at runtime, they must also
305+
# work for cached rebuilds. Most package managers will reinstall editable dependencies regardless on
306+
# next install, which means we can avoid having to rewrite paths on cache restore from the old build
307+
# directory to the new location (`/tmp/build_<different-hash>`). However, Pipenv has a bug when using
308+
# PEP660 style editable VCS dependencies where it won't reinstall if it's missing (or in our case, the
309+
# path has changed), which means we must make sure that VCS src paths stored in the cache do use the
310+
# symlink path. See: https://github.com/pypa/pipenv/issues/6348
311+
#
312+
# As such, we have to perform two rewrites:
313+
# 1. At build time, of just the VCS editable paths (which we can safely change to /app paths early).
314+
# 2. At runtime, to rewrite the remaining path-based editable dependency paths.
286315
if [[ "${BUILD_DIR}" != "/app" ]]; then
287-
cat <<EOT >>"$PROFILE_PATH"
288-
find .heroku/python/lib/python*/site-packages/ -type f -and \( -name '*.egg-link' -or -name '*.pth' -or -name '__editable___*_finder.py' \) -exec sed -i -e 's#${BUILD_DIR}#/app#' {} \+
289-
EOT
316+
find .heroku/python/lib/python*/site-packages/ -type f -and \( -name '*.egg-link' -or -name '*.pth' -or -name '__editable___*_finder.py' \) -exec sed -i -e "s#${BUILD_DIR}/.heroku/python#/app/.heroku/python#" {} \+
317+
cat <<-EOT >>"${PROFILE_PATH}"
318+
find .heroku/python/lib/python*/site-packages/ -type f -and \( -name '*.egg-link' -or -name '*.pth' -or -name '__editable___*_finder.py' \) -exec sed -i -e 's#${BUILD_DIR}#/app#' {} \+
319+
EOT
290320
fi
291321

292322
# Install sane-default script for $WEB_CONCURRENCY and $FORWARDED_ALLOW_IPS.

Diff for: lib/cache.sh

+4
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ function cache::restore() {
102102
elif [[ "${cached_pipenv_version}" != "${PIPENV_VERSION:?}" ]]; then
103103
cache_invalidation_reasons+=("The Pipenv version has changed from ${cached_pipenv_version} to ${PIPENV_VERSION}")
104104
fi
105+
# TODO: Remove this next time the Pipenv version is bumped (since it will trigger cache invalidation of its own)
106+
if [[ -d "${cache_dir}/.heroku/src" ]]; then
107+
cache_invalidation_reasons+=("The editable VCS repository location has changed (and Pipenv doesn't handle this correctly)")
108+
fi
105109
;;
106110
poetry)
107111
local cached_poetry_version

Diff for: spec/fixtures/pip_editable/bin/test-entrypoints.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ set -euo pipefail
44

55
cd .heroku/python/lib/python*/site-packages/
66

7-
# List any path like strings in .egg-link, .pth, and finder files in site-packages.
7+
# List any path like strings in the .egg-link, .pth, and finder files in site-packages.
88
grep --extended-regexp --only-matching -- '/\S+' *.egg-link *.pth __editable___*_finder.py | sort
99
echo
1010

Diff for: spec/fixtures/pipenv_editable/.python-version

-3
This file was deleted.

Diff for: spec/fixtures/pipenv_editable/Pipfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ verify_ssl = true
44
name = "pypi"
55

66
[packages]
7-
gunicorn = {git = "git+https://github.com/benoitc/gunicorn", ref = "20.1.0", editable = true}
7+
gunicorn = {git = "git+https://github.com/benoitc/gunicorn", editable = true}
88
local-package-pyproject-toml = {file = "packages/local_package_pyproject_toml", editable = true}
99
local-package-setup-py = {file = "packages/local_package_setup_py", editable = true}
1010
pipenv-editable = {file = ".", editable = true}

Diff for: spec/fixtures/pipenv_editable/Pipfile.lock

+10-10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: spec/fixtures/pipenv_editable/bin/test-entrypoints.sh

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ set -euo pipefail
44

55
cd .heroku/python/lib/python*/site-packages/
66

7-
# List any path like strings in .egg-link, .pth, and finder files in site-packages.
8-
grep --extended-regexp --only-matching -- '/\S+' *.egg-link *.pth __editable___*_finder.py | sort
7+
# List any path like strings in the .pth and finder files in site-packages.
8+
grep --extended-regexp --only-matching -- '/\S+' *.pth __editable___*_finder.py | sort
99
echo
1010

1111
echo -n "Running entrypoint for the pyproject.toml-based local package: "

Diff for: spec/fixtures/pipenv_editable/pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "pipenv-editable"
33
version = "0.0.0"
4-
requires-python = ">=3.12"
4+
requires-python = ">=3.13"
55

66
[build-system]
77
requires = ["hatchling"]

Diff for: spec/hatchet/pip_spec.rb

+8-8
Original file line numberDiff line numberDiff line change
@@ -101,20 +101,20 @@
101101
app.deploy do |app|
102102
expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX))
103103
remote: -----> Running bin/post_compile hook
104-
remote: easy-install.pth:/tmp/build_.+/.heroku/python/src/gunicorn
104+
remote: easy-install.pth:/app/.heroku/python/src/gunicorn
105105
remote: easy-install.pth:/tmp/build_.+/packages/local_package_setup_py
106106
remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
107-
remote: gunicorn.egg-link:/tmp/build_.+/.heroku/python/src/gunicorn
107+
remote: gunicorn.egg-link:/app/.heroku/python/src/gunicorn
108108
remote: local-package-setup-py.egg-link:/tmp/build_.+/packages/local_package_setup_py
109109
remote:
110110
remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
111111
remote: Running entrypoint for the setup.py-based local package: Hello setup.py!
112112
remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
113113
remote: -----> Inline app detected
114-
remote: easy-install.pth:/tmp/build_.+/.heroku/python/src/gunicorn
114+
remote: easy-install.pth:/app/.heroku/python/src/gunicorn
115115
remote: easy-install.pth:/tmp/build_.+/packages/local_package_setup_py
116116
remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
117-
remote: gunicorn.egg-link:/tmp/build_.+/.heroku/python/src/gunicorn
117+
remote: gunicorn.egg-link:/app/.heroku/python/src/gunicorn
118118
remote: local-package-setup-py.egg-link:/tmp/build_.+/packages/local_package_setup_py
119119
remote:
120120
remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
@@ -140,20 +140,20 @@
140140
app.push!
141141
expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX))
142142
remote: -----> Running bin/post_compile hook
143-
remote: easy-install.pth:/tmp/build_.+/.heroku/python/src/gunicorn
143+
remote: easy-install.pth:/app/.heroku/python/src/gunicorn
144144
remote: easy-install.pth:/tmp/build_.+/packages/local_package_setup_py
145145
remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
146-
remote: gunicorn.egg-link:/tmp/build_.+/.heroku/python/src/gunicorn
146+
remote: gunicorn.egg-link:/app/.heroku/python/src/gunicorn
147147
remote: local-package-setup-py.egg-link:/tmp/build_.+/packages/local_package_setup_py
148148
remote:
149149
remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
150150
remote: Running entrypoint for the setup.py-based local package: Hello setup.py!
151151
remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
152152
remote: -----> Inline app detected
153-
remote: easy-install.pth:/tmp/build_.+/.heroku/python/src/gunicorn
153+
remote: easy-install.pth:/app/.heroku/python/src/gunicorn
154154
remote: easy-install.pth:/tmp/build_.+/packages/local_package_setup_py
155155
remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
156-
remote: gunicorn.egg-link:/tmp/build_.+/.heroku/python/src/gunicorn
156+
remote: gunicorn.egg-link:/app/.heroku/python/src/gunicorn
157157
remote: local-package-setup-py.egg-link:/tmp/build_.+/packages/local_package_setup_py
158158
remote:
159159
remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!

Diff for: spec/hatchet/pipenv_spec.rb

+17-28
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@
337337
remote: -----> Discarding cache since:
338338
remote: - The Python version has changed from 3.12.4 to #{DEFAULT_PYTHON_FULL_VERSION}
339339
remote: - The Pipenv version has changed from 2023.12.1 to #{PIPENV_VERSION}
340+
remote: - The editable VCS repository location has changed (and Pipenv doesn't handle this correctly)
340341
remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION}
341342
remote: -----> Installing pip #{PIP_VERSION}
342343
remote: -----> Installing Pipenv #{PIPENV_VERSION}
@@ -348,53 +349,45 @@
348349
end
349350
end
350351

351-
# This test has to use Python 3.12 until we work around the Pipenv editable VCS dependency
352-
# cache invalidation bug when using pyproject.toml / PEP517 based installs.
353352
context 'when Pipfile contains editable requirements' do
354353
let(:buildpacks) { [:default, 'heroku-community/inline'] }
355354
let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_editable', buildpacks:) }
356355

357-
it 'rewrites .pth, .egg-link and finder paths correctly for hooks, later buildpacks, runtime and cached builds' do
356+
it 'rewrites .pth and finder paths correctly for hooks, later buildpacks, runtime and cached builds' do
358357
app.deploy do |app|
359358
expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX))
360359
remote: -----> Installing dependencies using 'pipenv install --deploy'
361360
remote: Installing dependencies from Pipfile.lock \\(.+\\)...
362361
remote: -----> Running bin/post_compile hook
363-
remote: easy-install.pth:/tmp/build_.+/.heroku/python/src/gunicorn
364-
remote: easy-install.pth:/tmp/build_.+/packages/local_package_setup_py
362+
remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'}
365363
remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
366-
remote: gunicorn.egg-link:/tmp/build_.+/.heroku/python/src/gunicorn
367-
remote: local-package-setup-py.egg-link:/tmp/build_.+/packages/local_package_setup_py
364+
remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'}
368365
remote: _pipenv_editable.pth:/tmp/build_.+
369366
remote:
370367
remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
371368
remote: Running entrypoint for the setup.py-based local package: Hello setup.py!
372-
remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
369+
remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\)
373370
remote: -----> Inline app detected
374-
remote: easy-install.pth:/tmp/build_.+/.heroku/python/src/gunicorn
375-
remote: easy-install.pth:/tmp/build_.+/packages/local_package_setup_py
371+
remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'}
376372
remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
377-
remote: gunicorn.egg-link:/tmp/build_.+/.heroku/python/src/gunicorn
378-
remote: local-package-setup-py.egg-link:/tmp/build_.+/packages/local_package_setup_py
373+
remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'}
379374
remote: _pipenv_editable.pth:/tmp/build_.+
380375
remote:
381376
remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
382377
remote: Running entrypoint for the setup.py-based local package: Hello setup.py!
383-
remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
378+
remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\)
384379
REGEX
385380

386381
# Test rewritten paths work at runtime.
387382
expect(app.run('bin/test-entrypoints.sh')).to include(<<~OUTPUT)
388-
easy-install.pth:/app/.heroku/python/src/gunicorn
389-
easy-install.pth:/app/packages/local_package_setup_py
383+
__editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'}
390384
__editable___local_package_pyproject_toml_0_0_1_finder.py:/app/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
391-
gunicorn.egg-link:/app/.heroku/python/src/gunicorn
392-
local-package-setup-py.egg-link:/app/packages/local_package_setup_py
385+
__editable___local_package_setup_py_0_0_1_finder.py:/app/packages/local_package_setup_py/local_package_setup_py'}
393386
_pipenv_editable.pth:/app
394387
395388
Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
396389
Running entrypoint for the setup.py-based local package: Hello setup.py!
397-
Running entrypoint for the VCS package: gunicorn (version 20.1.0)
390+
Running entrypoint for the VCS package: gunicorn (version 23.0.0)
398391
OUTPUT
399392

400393
# Test that the cached .pth files work correctly.
@@ -404,27 +397,23 @@
404397
remote: -----> Installing dependencies using 'pipenv install --deploy'
405398
remote: Installing dependencies from Pipfile.lock \\(.+\\)...
406399
remote: -----> Running bin/post_compile hook
407-
remote: easy-install.pth:/tmp/build_.+/.heroku/python/src/gunicorn
408-
remote: easy-install.pth:/tmp/build_.+/packages/local_package_setup_py
400+
remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'}
409401
remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
410-
remote: gunicorn.egg-link:/tmp/build_.+/.heroku/python/src/gunicorn
411-
remote: local-package-setup-py.egg-link:/tmp/build_.+/packages/local_package_setup_py
402+
remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'}
412403
remote: _pipenv_editable.pth:/tmp/build_.+
413404
remote:
414405
remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
415406
remote: Running entrypoint for the setup.py-based local package: Hello setup.py!
416-
remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
407+
remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\)
417408
remote: -----> Inline app detected
418-
remote: easy-install.pth:/tmp/build_.+/.heroku/python/src/gunicorn
419-
remote: easy-install.pth:/tmp/build_.+/packages/local_package_setup_py
409+
remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'}
420410
remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
421-
remote: gunicorn.egg-link:/tmp/build_.+/.heroku/python/src/gunicorn
422-
remote: local-package-setup-py.egg-link:/tmp/build_.+/packages/local_package_setup_py
411+
remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'}
423412
remote: _pipenv_editable.pth:/tmp/build_.+
424413
remote:
425414
remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
426415
remote: Running entrypoint for the setup.py-based local package: Hello setup.py!
427-
remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
416+
remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\)
428417
REGEX
429418
end
430419
end

0 commit comments

Comments
 (0)