What is the problem?
Installing a pip-based recipe package without a version specifier through the Moderne CLI / OpenRewrite Python RPC engine does not upgrade an already-installed package. The engine reuses whatever version happens to be present in the recipe-install directory instead of resolving the latest from the index.
This is a regression. Engine versions ≤ 8.81.0 performed no in-engine pip install at all; the short-circuit guard responsible for this was introduced around 8.84.9.
Where
rewrite-python/rewrite/src/rewrite/rpc/server.py
handle_install_recipes() pip-installs a recipe package into --recipe-install-dir before activating it, guarded by _is_package_installed:
if _recipe_install_dir is not None and not _is_package_installed(package_name, version):
_pip_install_recipe_package(package_name, version, _recipe_install_dir)
def _is_package_installed(package_name, version):
try:
installed = importlib.metadata.version(package_name)
except Exception:
return False
return version is None or installed == version
Root cause
There are actually two coupled defects on the version-less path:
1. The install is skipped entirely.
When version is None (a version-less request, which per the "Adds or updates a pip package" command header and by analogy with jar install g:a defaulting to LATEST should install the newest compatible version), _is_package_installed returns True as soon as any version is installed. So _pip_install_recipe_package is never called, and the engine activates the stale, already-present version. The --upgrade flag inside _pip_install_recipe_package is dead code on this path.
The same flaw applies to non-exact PEP 440 specifiers (>=1.0, ~=1.4): installed == version is a string compare that can never match a comparator spec, so those currently do fall through to pip — but the intent (never treat an arbitrary installed version as "satisfied") should be made explicit so a future refactor doesn't reintroduce the short-circuit.
2. Even once pip runs, the reported version can stay stale.
_pip_install_recipe_package uses pip install --target <dir> --upgrade. pip install --target cannot uninstall — it overwrites the package's module files but leaves the previous version's {name}-{version}.dist-info directory sitting next to the newly installed one (dist-info dir names embed the version, so they don't collide). importlib.metadata.version() then enumerates the install dir and returns whichever dist-info it finds first — frequently the stale lower version. The result: even after the latest code is installed, the version reported back in the RPC response (and used for activation/attribution) can be the old one.
Reproduced with a local wheel index offering 0.7.0 and 0.9.2:
install 0.7.0 -> target/orw_dummy_recipe-0.7.0.dist-info
pip install --upgrade -> target/orw_dummy_recipe-0.9.2.dist-info (0.7.0 dist-info NOT removed)
importlib.metadata.version("orw-dummy-recipe") -> "0.7.0" # stale
Impact (as seen from the CLI)
For a user running a version-less recipe install through the Moderne CLI against the Python engine:
- The recipe package is never upgraded. Once any version is installed into the recipe-install dir, subsequent version-less installs are silently no-ops — the CLI reports success while continuing to run an outdated recipe package.
- Fixes and new recipes never arrive. Publishing a new recipe version to the index has no effect for anyone who already has an older version cached; they keep running the stale one until the install dir is manually cleared or an exact newer version is pinned.
- Silent / misleading. There is no error — the install "succeeds" and reports the old version, so the staleness is invisible unless the user notices behavior that a newer release was supposed to change.
- Even the two-part scenario (clear dir won't help long-term): because of defect #2, once an upgrade does eventually run into a dir that still holds an older dist-info, the version echoed back to the CLI can still be the stale one.
This diverges from the documented/expected semantics that a version-less install resolves and installs the latest compatible version, mirroring jar install g:a → LATEST.
Steps to reproduce
- Configure the Python RPC engine with
--recipe-install-dir.
- Install a recipe package pinned to an older version (e.g.
0.7.0).
- Issue a version-less install of the same package (
{'packageName': ..., 'version': None}) with a newer version (0.9.2) available on the index.
- Observe the engine returns/activates
0.7.0 rather than 0.9.2.
Affected versions
Engine ≥ ~8.84.9 (guard introduced). Not present in ≤ 8.81.0 (which did no in-engine pip install).
What is the problem?
Installing a pip-based recipe package without a version specifier through the Moderne CLI / OpenRewrite Python RPC engine does not upgrade an already-installed package. The engine reuses whatever version happens to be present in the recipe-install directory instead of resolving the latest from the index.
This is a regression. Engine versions ≤ 8.81.0 performed no in-engine pip install at all; the short-circuit guard responsible for this was introduced around 8.84.9.
Where
rewrite-python/rewrite/src/rewrite/rpc/server.pyhandle_install_recipes()pip-installs a recipe package into--recipe-install-dirbefore activating it, guarded by_is_package_installed:Root cause
There are actually two coupled defects on the version-less path:
1. The install is skipped entirely.
When
version is None(a version-less request, which per the "Adds or updates a pip package" command header and by analogy withjar install g:adefaulting toLATESTshould install the newest compatible version),_is_package_installedreturnsTrueas soon as any version is installed. So_pip_install_recipe_packageis never called, and the engine activates the stale, already-present version. The--upgradeflag inside_pip_install_recipe_packageis dead code on this path.The same flaw applies to non-exact PEP 440 specifiers (
>=1.0,~=1.4):installed == versionis a string compare that can never match a comparator spec, so those currently do fall through to pip — but the intent (never treat an arbitrary installed version as "satisfied") should be made explicit so a future refactor doesn't reintroduce the short-circuit.2. Even once pip runs, the reported version can stay stale.
_pip_install_recipe_packageusespip install --target <dir> --upgrade.pip install --targetcannot uninstall — it overwrites the package's module files but leaves the previous version's{name}-{version}.dist-infodirectory sitting next to the newly installed one (dist-info dir names embed the version, so they don't collide).importlib.metadata.version()then enumerates the install dir and returns whichever dist-info it finds first — frequently the stale lower version. The result: even after the latest code is installed, the version reported back in the RPC response (and used for activation/attribution) can be the old one.Reproduced with a local wheel index offering
0.7.0and0.9.2:Impact (as seen from the CLI)
For a user running a version-less recipe install through the Moderne CLI against the Python engine:
This diverges from the documented/expected semantics that a version-less install resolves and installs the latest compatible version, mirroring
jar install g:a→LATEST.Steps to reproduce
--recipe-install-dir.0.7.0).{'packageName': ..., 'version': None}) with a newer version (0.9.2) available on the index.0.7.0rather than0.9.2.Affected versions
Engine ≥ ~8.84.9 (guard introduced). Not present in ≤ 8.81.0 (which did no in-engine pip install).