Skip to content

Commit 500435c

Browse files
authored
fix: show update environment before confirmation (#246)
## Summary - show the Python executable, environment prefix and type, and installed package location before updating - run pip through the displayed interpreter so the update targets the environment running `cwms-cli` - recognize standard Python, virtualenv, and Conda environments across Windows and POSIX - document the safer `python -m pip` manual update form and the updater's environment behavior ## Root cause and impact A bare `pip install` can resolve to a different Python installation from the one running `cwms-cli`. Although the updater already used the running interpreter, it did not expose that environment before asking for confirmation. Users can now verify the exact runtime and package location before any update begins. ## Validation - `poetry run pytest -q` - 228 passed - repository-pinned Black and isort checks - ownership synchronization check - CLI smoke test - Sphinx HTML build with warnings treated as errors Fixes #222
1 parent 24db6d6 commit 500435c

6 files changed

Lines changed: 334 additions & 12 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,20 @@ Note: You may need to run `python -m pip install cwms-cli` if PIP is not in your
1414

1515
### Update
1616
```sh
17-
pip install cwms-cli --upgrade
17+
python -m pip install --upgrade cwms-cli
1818
```
1919

2020
Or as of version `0.3.0+`
2121
```sh
2222
cwms-cli update
2323
```
2424

25+
`cwms-cli update` displays the Python executable, environment, package metadata
26+
location, and editable project location when applicable before asking for
27+
confirmation. It runs pip through the same Python interpreter that is running
28+
`cwms-cli`, avoiding accidental updates to a different Python installation or
29+
virtual environment.
30+
2531
To install a specific version:
2632
```sh
2733
cwms-cli update --target-version 0.7.1 --yes

cwmscli/commands/commands_cwms.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logging
22
import os
33
import subprocess
4-
import sys
54
import textwrap
65
from pathlib import Path
76
from typing import Optional
@@ -26,7 +25,9 @@
2625
from cwmscli.utils.deps import requires
2726
from cwmscli.utils.update import (
2827
build_update_package_spec,
28+
get_update_environment,
2929
launch_windows_update,
30+
looks_like_externally_managed_environment,
3031
looks_like_missing_version,
3132
)
3233
from cwmscli.utils.version import get_cwms_cli_version
@@ -416,6 +417,7 @@ def csv2cwms_cmd(**kwargs):
416417
def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
417418
current_version = get_cwms_cli_version()
418419
package_spec = build_update_package_spec(target_version)
420+
update_environment = get_update_environment()
419421

420422
click.echo(
421423
"Current cwms-cli version: " f"{colors.c(current_version, 'cyan', bright=True)}"
@@ -428,12 +430,35 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
428430
else:
429431
click.echo("Requested cwms-cli version: latest available release")
430432

431-
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", package_spec]
433+
click.echo("Update environment:")
434+
click.echo(f" Python executable: {update_environment.python_executable}")
435+
click.echo(
436+
f" Environment: {update_environment.environment_prefix} "
437+
f"({update_environment.environment_type})"
438+
)
439+
click.echo(f" Package metadata location: {update_environment.package_location}")
440+
if update_environment.editable_project_location:
441+
click.echo(
442+
" Editable project location: "
443+
f"{update_environment.editable_project_location}"
444+
)
445+
446+
cmd = [
447+
update_environment.python_executable,
448+
"-m",
449+
"pip",
450+
"install",
451+
"--upgrade",
452+
package_spec,
453+
]
432454
if pre:
433455
cmd.append("--pre")
434456

435457
if not yes:
436-
proceed = click.confirm("Proceed with updating cwms-cli via pip?", default=True)
458+
proceed = click.confirm(
459+
"Proceed with updating cwms-cli in this environment via pip?",
460+
default=True,
461+
)
437462
if not proceed:
438463
click.echo(colors.warn("Update canceled."))
439464
return
@@ -472,6 +497,16 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
472497

473498
if result.returncode != 0:
474499
pip_output = "\n".join(part for part in [result.stdout, result.stderr] if part)
500+
if looks_like_externally_managed_environment(pip_output):
501+
raise click.ClickException(
502+
colors.err(
503+
"The selected Python installation is externally managed, so pip "
504+
"refused to update cwms-cli. Install cwms-cli in a virtual "
505+
"environment or with pipx, then run that installation's "
506+
"cwms-cli update command. cwms-cli will not use "
507+
"--break-system-packages automatically."
508+
)
509+
)
475510
if target_version and looks_like_missing_version(pip_output, package_spec):
476511
raise click.ClickException(
477512
colors.err(

cwmscli/utils/update.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,96 @@
1+
import importlib.metadata
2+
import json
3+
import os
14
import subprocess
5+
import sys
26
import tempfile
7+
from dataclasses import dataclass
38
from typing import List, Optional
9+
from urllib.parse import unquote, urlparse
10+
from urllib.request import url2pathname
11+
12+
13+
@dataclass(frozen=True)
14+
class UpdateEnvironment:
15+
python_executable: str
16+
environment_prefix: str
17+
environment_type: str
18+
package_location: str
19+
editable_project_location: Optional[str] = None
20+
21+
22+
def _absolute_path(path: str) -> str:
23+
return os.path.abspath(os.path.expanduser(path))
24+
25+
26+
def _same_path(left: str, right: str) -> bool:
27+
return os.path.normcase(_absolute_path(left)) == os.path.normcase(
28+
_absolute_path(right)
29+
)
30+
31+
32+
def _editable_project_location(
33+
distribution: importlib.metadata.Distribution,
34+
) -> Optional[str]:
35+
direct_url_text = distribution.read_text("direct_url.json")
36+
if not direct_url_text:
37+
return None
38+
39+
try:
40+
direct_url = json.loads(direct_url_text)
41+
except (json.JSONDecodeError, TypeError):
42+
return None
43+
44+
if not isinstance(direct_url, dict):
45+
return None
46+
directory_info = direct_url.get("dir_info")
47+
if not isinstance(directory_info, dict) or not directory_info.get("editable"):
48+
return None
49+
50+
url = direct_url.get("url")
51+
if not isinstance(url, str):
52+
return None
53+
parsed_url = urlparse(url)
54+
if parsed_url.scheme != "file":
55+
return None
56+
57+
project_path = url2pathname(unquote(parsed_url.path))
58+
if parsed_url.netloc and parsed_url.netloc != "localhost":
59+
project_path = f"//{parsed_url.netloc}{project_path}"
60+
return _absolute_path(project_path)
61+
62+
63+
def get_update_environment() -> UpdateEnvironment:
64+
"""Describe the Python environment targeted by ``cwms-cli update``."""
65+
python_executable = _absolute_path(sys.executable)
66+
environment_prefix = _absolute_path(sys.prefix)
67+
conda_prefix = os.getenv("CONDA_PREFIX")
68+
69+
if conda_prefix and _same_path(conda_prefix, sys.prefix):
70+
environment_type = "Conda environment"
71+
elif not _same_path(sys.prefix, sys.base_prefix):
72+
environment_type = "virtual environment"
73+
else:
74+
environment_type = "Python installation"
75+
76+
try:
77+
distribution = importlib.metadata.distribution("cwms-cli")
78+
package_location = os.path.realpath(os.fspath(distribution.locate_file("")))
79+
editable_project_location = _editable_project_location(distribution)
80+
except importlib.metadata.PackageNotFoundError:
81+
# This can happen when the CLI is invoked directly from a source checkout.
82+
package_location = os.path.dirname(
83+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
84+
)
85+
editable_project_location = None
86+
87+
return UpdateEnvironment(
88+
python_executable=python_executable,
89+
environment_prefix=environment_prefix,
90+
environment_type=environment_type,
91+
package_location=package_location,
92+
editable_project_location=editable_project_location,
93+
)
494

595

696
def build_update_package_spec(target_version: Optional[str]) -> str:
@@ -16,6 +106,10 @@ def looks_like_missing_version(pip_output: str, package_spec: str) -> bool:
16106
) and package_spec in pip_output
17107

18108

109+
def looks_like_externally_managed_environment(pip_output: str) -> bool:
110+
return "externally-managed-environment" in pip_output.lower()
111+
112+
19113
def write_windows_update_script(cmd: List[str]) -> str:
20114
quoted_cmd = subprocess.list2cmdline(cmd)
21115
script = "\r\n".join(

docs/cli/update.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,49 @@ By default it installs the latest available release, and you can optionally
88
target a specific version with ``--target-version``. After updating, use
99
:doc:`Version argument <version>` to confirm the installed version.
1010

11+
Before asking for confirmation, the command displays the Python executable,
12+
environment prefix and type, and installed package metadata location. For an
13+
editable installation, it also displays the editable project location recorded
14+
by the installer. The update runs pip through the displayed executable
15+
(``python -m pip``), so it targets the same Python environment that is running
16+
``cwms-cli``. This is especially useful when multiple Python installations or
17+
virtual environments are present.
18+
1119
On Windows, the command launches the pip install in a separate command window so
1220
the running ``cwms-cli.exe`` does not block its own replacement.
1321

22+
.. note::
23+
24+
A standalone ``pip install --upgrade cwms-cli`` command uses whichever
25+
``pip`` executable appears first on the shell's path, which may belong to a
26+
different Python environment. Prefer ``cwms-cli update``. When updating
27+
manually, use the full Python executable displayed by ``cwms-cli update``
28+
with ``-m pip install --upgrade cwms-cli``.
29+
30+
Linux externally managed environments
31+
-------------------------------------
32+
33+
Some Linux distributions mark their system Python installation as externally
34+
managed under PEP 668. If pip reports ``externally-managed-environment``,
35+
``cwms-cli update`` explains that the selected Python installation cannot be
36+
changed safely and recommends installing ``cwms-cli`` in a virtual environment
37+
or with pipx. The updater does not automatically pass
38+
``--break-system-packages``, because doing so can conflict with packages managed
39+
by the operating system.
40+
41+
A correctly created virtual environment is not subject to the system Python's
42+
externally managed restriction. Confirm that the displayed Python executable
43+
and environment prefix both point into the intended virtual environment before
44+
continuing.
45+
46+
Editable installations
47+
----------------------
48+
49+
Editable installations keep their distribution metadata in the environment's
50+
``site-packages`` directory while loading source code from a project directory.
51+
The updater displays both paths when ``direct_url.json`` identifies the current
52+
installation as editable.
53+
1454
Examples
1555
--------
1656

0 commit comments

Comments
 (0)