venv-cmds 2026.9.26.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- venv_cmds-2026.9.26.1/LICENSE +21 -0
- venv_cmds-2026.9.26.1/PKG-INFO +91 -0
- venv_cmds-2026.9.26.1/README.md +57 -0
- venv_cmds-2026.9.26.1/pyproject.toml +53 -0
- venv_cmds-2026.9.26.1/setup.cfg +4 -0
- venv_cmds-2026.9.26.1/src/venv_cmds/__init__.py +0 -0
- venv_cmds-2026.9.26.1/src/venv_cmds/_version.py +2 -0
- venv_cmds-2026.9.26.1/src/venv_cmds/cli.py +313 -0
- venv_cmds-2026.9.26.1/src/venv_cmds.egg-info/PKG-INFO +91 -0
- venv_cmds-2026.9.26.1/src/venv_cmds.egg-info/SOURCES.txt +15 -0
- venv_cmds-2026.9.26.1/src/venv_cmds.egg-info/dependency_links.txt +1 -0
- venv_cmds-2026.9.26.1/src/venv_cmds.egg-info/entry_points.txt +2 -0
- venv_cmds-2026.9.26.1/src/venv_cmds.egg-info/requires.txt +7 -0
- venv_cmds-2026.9.26.1/src/venv_cmds.egg-info/top_level.txt +1 -0
- venv_cmds-2026.9.26.1/tests/test_check_updates.py +214 -0
- venv_cmds-2026.9.26.1/tests/test_cli.py +75 -0
- venv_cmds-2026.9.26.1/tests/test_list.py +74 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul McGowan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: venv-cmds
|
|
3
|
+
Version: 2026.9.26.1
|
|
4
|
+
Summary: List available console commands in the active Python environment, check for package updates
|
|
5
|
+
Author-email: Paul McGowan <paul.mcgowan@ophix.io>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://ophix.io
|
|
8
|
+
Project-URL: Documentation, https://github.com/ophixproject/venv-cmds#readme
|
|
9
|
+
Project-URL: Source, https://github.com/ophixproject/venv-cmds
|
|
10
|
+
Keywords: venv,python,cli,console scripts
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.7
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: importlib_metadata>=3.6; python_version < "3.8"
|
|
30
|
+
Requires-Dist: argcomplete>=3.0
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest; extra == "test"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# venv-cmds
|
|
36
|
+
|
|
37
|
+
Small discovery and maintenance utilities for Python virtual environments.
|
|
38
|
+
|
|
39
|
+
## Why this exists
|
|
40
|
+
|
|
41
|
+
When you activate a virtual environment it is not always obvious which
|
|
42
|
+
command-line tools are available or whether they are up to date.
|
|
43
|
+
`venv-cmds` provides two commands: `list` to discover what is installed,
|
|
44
|
+
and `check_updates` to check for newer versions on the configured pip index.
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
Running `venv-cmds` with no subcommand is equivalent to `venv-cmds list`.
|
|
49
|
+
|
|
50
|
+
### `list`
|
|
51
|
+
|
|
52
|
+
Show all `console_scripts` entry points in the active environment.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
venv-cmds list
|
|
56
|
+
venv-cmds list --details # include package name alongside each command
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `check_updates`
|
|
60
|
+
|
|
61
|
+
Check all installed packages against the configured pip index and report
|
|
62
|
+
which have updates available. Respects all pip index sources configured for
|
|
63
|
+
the environment — public PyPI, private indexes, and local mirrors all work
|
|
64
|
+
without any extra configuration.
|
|
65
|
+
|
|
66
|
+
If `pip` isn't importable in the current venv (e.g. one created with
|
|
67
|
+
`uv venv` without `--seed`), falls back automatically to `uv pip list`
|
|
68
|
+
targeting this same interpreter, provided `uv` is installed on `PATH`.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
venv-cmds check_updates
|
|
72
|
+
venv-cmds check_updates --updates-only # only show packages with updates
|
|
73
|
+
venv-cmds check_updates --no-progress # suppress status message on stderr (cron-safe)
|
|
74
|
+
venv-cmds check_updates --include-install-date # add an 'Installed on' column
|
|
75
|
+
venv-cmds check_updates --timeout 60 # per-package query timeout in seconds (default: 30)
|
|
76
|
+
venv-cmds check_updates --output-file updates.txt # write pinned requirements file for updates
|
|
77
|
+
venv-cmds check_updates --output-file - # write pinned requirements to stdout
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
When `--output-file` is used the output file contains `package==version` lines
|
|
81
|
+
for every package with an available update. Passing this file to
|
|
82
|
+
`pip install -r updates.txt` will upgrade exactly the packages that were
|
|
83
|
+
flagged, with no other changes.
|
|
84
|
+
|
|
85
|
+
## Installation
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install venv-cmds
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Requires Python 3.7 or later. Works with both `venv` and `virtualenv`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# venv-cmds
|
|
2
|
+
|
|
3
|
+
Small discovery and maintenance utilities for Python virtual environments.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
When you activate a virtual environment it is not always obvious which
|
|
8
|
+
command-line tools are available or whether they are up to date.
|
|
9
|
+
`venv-cmds` provides two commands: `list` to discover what is installed,
|
|
10
|
+
and `check_updates` to check for newer versions on the configured pip index.
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
Running `venv-cmds` with no subcommand is equivalent to `venv-cmds list`.
|
|
15
|
+
|
|
16
|
+
### `list`
|
|
17
|
+
|
|
18
|
+
Show all `console_scripts` entry points in the active environment.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
venv-cmds list
|
|
22
|
+
venv-cmds list --details # include package name alongside each command
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### `check_updates`
|
|
26
|
+
|
|
27
|
+
Check all installed packages against the configured pip index and report
|
|
28
|
+
which have updates available. Respects all pip index sources configured for
|
|
29
|
+
the environment — public PyPI, private indexes, and local mirrors all work
|
|
30
|
+
without any extra configuration.
|
|
31
|
+
|
|
32
|
+
If `pip` isn't importable in the current venv (e.g. one created with
|
|
33
|
+
`uv venv` without `--seed`), falls back automatically to `uv pip list`
|
|
34
|
+
targeting this same interpreter, provided `uv` is installed on `PATH`.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
venv-cmds check_updates
|
|
38
|
+
venv-cmds check_updates --updates-only # only show packages with updates
|
|
39
|
+
venv-cmds check_updates --no-progress # suppress status message on stderr (cron-safe)
|
|
40
|
+
venv-cmds check_updates --include-install-date # add an 'Installed on' column
|
|
41
|
+
venv-cmds check_updates --timeout 60 # per-package query timeout in seconds (default: 30)
|
|
42
|
+
venv-cmds check_updates --output-file updates.txt # write pinned requirements file for updates
|
|
43
|
+
venv-cmds check_updates --output-file - # write pinned requirements to stdout
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
When `--output-file` is used the output file contains `package==version` lines
|
|
47
|
+
for every package with an available update. Passing this file to
|
|
48
|
+
`pip install -r updates.txt` will upgrade exactly the packages that were
|
|
49
|
+
flagged, with no other changes.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install venv-cmds
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires Python 3.7 or later. Works with both `venv` and `virtualenv`.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "venv-cmds"
|
|
7
|
+
version = "2026.09.26.01"
|
|
8
|
+
description = "List available console commands in the active Python environment, check for package updates"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["venv", "python", "cli", "console scripts"]
|
|
13
|
+
|
|
14
|
+
authors = [
|
|
15
|
+
{name = "Paul McGowan", email = "paul.mcgowan@ophix.io"}
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Intended Audience :: System Administrators",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.7",
|
|
26
|
+
"Programming Language :: Python :: 3.8",
|
|
27
|
+
"Programming Language :: Python :: 3.9",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
|
+
"Topic :: Utilities",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
dependencies = [
|
|
37
|
+
"importlib_metadata>=3.6; python_version < '3.8'",
|
|
38
|
+
"argcomplete>=3.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
test = ["pytest"]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://ophix.io"
|
|
46
|
+
Documentation = "https://github.com/ophixproject/venv-cmds#readme"
|
|
47
|
+
Source = "https://github.com/ophixproject/venv-cmds"
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["src"]
|
|
51
|
+
|
|
52
|
+
[project.scripts]
|
|
53
|
+
venv-cmds = "venv_cmds.cli:main"
|
|
File without changes
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import types
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import argcomplete
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 8):
|
|
14
|
+
from importlib.metadata import distributions, entry_points
|
|
15
|
+
else:
|
|
16
|
+
from importlib_metadata import distributions, entry_points
|
|
17
|
+
|
|
18
|
+
from venv_cmds._version import __version__
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Helpers
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def _in_venv():
|
|
25
|
+
return sys.prefix != getattr(sys, "base_prefix", sys.prefix)
|
|
26
|
+
|
|
27
|
+
def _venv_name():
|
|
28
|
+
return Path(sys.prefix).name
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Command handlers
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def cmd_list(args):
|
|
35
|
+
if _in_venv():
|
|
36
|
+
print("Info: detected virtual environment '{}'".format(_venv_name()))
|
|
37
|
+
else:
|
|
38
|
+
print("Warning: no virtual environment detected; listing system-wide commands.", file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
if sys.version_info >= (3, 9):
|
|
41
|
+
eps = entry_points(group="console_scripts")
|
|
42
|
+
else:
|
|
43
|
+
eps = entry_points().get("console_scripts", [])
|
|
44
|
+
|
|
45
|
+
for ep in sorted(eps, key=lambda e: e.name):
|
|
46
|
+
if getattr(args, "details", False):
|
|
47
|
+
dist = getattr(ep, "dist", None)
|
|
48
|
+
pkg_name = dist.metadata.get("Name", "unknown") if dist else "unknown"
|
|
49
|
+
print("{} (package: {})".format(ep.name, pkg_name))
|
|
50
|
+
else:
|
|
51
|
+
print(ep.name)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_STATUS_OK = "ok"
|
|
55
|
+
_STATUS_UPDATE = "update"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pip_list_json(extra_args, timeout):
|
|
59
|
+
# Falls back to `uv pip list` if pip isn't importable in this environment
|
|
60
|
+
# (e.g. a venv created with `uv venv` without --seed). --python targets
|
|
61
|
+
# this exact interpreter rather than relying on VIRTUAL_ENV being set.
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
[sys.executable, "-m", "pip", "list", "--format=json"] + extra_args,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
)
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
uv_path = shutil.which("uv")
|
|
70
|
+
if uv_path:
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
[uv_path, "pip", "list", "--format=json", "--python", sys.executable] + extra_args,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
timeout=timeout,
|
|
76
|
+
)
|
|
77
|
+
if result.returncode != 0:
|
|
78
|
+
print("Error: pip list failed:\n{}".format(result.stderr.strip()), file=sys.stderr)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
return json.loads(result.stdout)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_install_date(dist):
|
|
84
|
+
try:
|
|
85
|
+
path = getattr(dist, "_path", None)
|
|
86
|
+
if path:
|
|
87
|
+
return datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d")
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
return "—"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_check_updates(args):
|
|
94
|
+
timeout = args.timeout
|
|
95
|
+
output_file = args.output_file
|
|
96
|
+
include_install_date = args.include_install_date
|
|
97
|
+
updates_only = args.updates_only
|
|
98
|
+
no_progress = args.no_progress
|
|
99
|
+
|
|
100
|
+
if not no_progress:
|
|
101
|
+
print(" Querying index...", end="\r", file=sys.stderr, flush=True)
|
|
102
|
+
|
|
103
|
+
outdated = _pip_list_json(["--outdated"], timeout)
|
|
104
|
+
|
|
105
|
+
if updates_only:
|
|
106
|
+
# Single pip call sufficient — no need to fetch the full package list.
|
|
107
|
+
all_pkgs = outdated
|
|
108
|
+
else:
|
|
109
|
+
all_pkgs = _pip_list_json([], timeout)
|
|
110
|
+
|
|
111
|
+
if not no_progress:
|
|
112
|
+
print(" ", end="\r", file=sys.stderr)
|
|
113
|
+
|
|
114
|
+
outdated_map = {p["name"].lower(): p["latest_version"] for p in outdated}
|
|
115
|
+
|
|
116
|
+
# Build install-date lookup from importlib.metadata if needed.
|
|
117
|
+
if include_install_date:
|
|
118
|
+
dist_map = {}
|
|
119
|
+
for dist in distributions():
|
|
120
|
+
name = dist.metadata.get("Name")
|
|
121
|
+
if name:
|
|
122
|
+
dist_map[name.lower()] = dist
|
|
123
|
+
|
|
124
|
+
results = []
|
|
125
|
+
for pkg in sorted(all_pkgs, key=lambda p: p["name"].lower()):
|
|
126
|
+
name = pkg["name"]
|
|
127
|
+
installed = pkg["version"]
|
|
128
|
+
key = name.lower()
|
|
129
|
+
latest = outdated_map.get(key)
|
|
130
|
+
status = _STATUS_UPDATE if latest else _STATUS_OK
|
|
131
|
+
if not latest:
|
|
132
|
+
latest = installed
|
|
133
|
+
install_date = _get_install_date(dist_map[key]) if include_install_date and key in dist_map else None
|
|
134
|
+
results.append((name, installed, install_date, latest, status))
|
|
135
|
+
|
|
136
|
+
# When writing requirements to stdout, redirect table output to stderr
|
|
137
|
+
# so the two streams stay separate and piping works cleanly.
|
|
138
|
+
out = sys.stderr if output_file == "-" else sys.stdout
|
|
139
|
+
|
|
140
|
+
# --- Table ---------------------------------------------------------------
|
|
141
|
+
col_pkg = "Package"
|
|
142
|
+
col_inst = "Installed"
|
|
143
|
+
col_date = "Installed on"
|
|
144
|
+
col_lat = "Latest"
|
|
145
|
+
col_stat = "Status"
|
|
146
|
+
|
|
147
|
+
label = {_STATUS_OK: "OK", _STATUS_UPDATE: "UPDATE AVAILABLE"}
|
|
148
|
+
|
|
149
|
+
rows = [
|
|
150
|
+
(name, inst, idate, lat, label[st], st)
|
|
151
|
+
for name, inst, idate, lat, st in results
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
updates = [r for r in rows if r[5] == _STATUS_UPDATE]
|
|
155
|
+
display_rows = updates if updates_only else rows
|
|
156
|
+
|
|
157
|
+
if display_rows:
|
|
158
|
+
w_pkg = max(len(col_pkg), max(len(r[0]) for r in display_rows))
|
|
159
|
+
w_inst = max(len(col_inst), max(len(r[1]) for r in display_rows))
|
|
160
|
+
w_lat = max(len(col_lat), max(len(r[3]) for r in display_rows))
|
|
161
|
+
w_stat = max(len(col_stat), max(len(r[4]) for r in display_rows))
|
|
162
|
+
|
|
163
|
+
if include_install_date:
|
|
164
|
+
w_date = max(len(col_date), max(len(r[2] or "—") for r in display_rows))
|
|
165
|
+
fmt = "{{:<{}}} {{:<{}}} {{:<{}}} {{:<{}}} {{}}".format(w_pkg, w_inst, w_date, w_lat)
|
|
166
|
+
divider = " ".join(["-" * w_pkg, "-" * w_inst, "-" * w_date, "-" * w_lat, "-" * w_stat])
|
|
167
|
+
header = fmt.format(col_pkg, col_inst, col_date, col_lat, col_stat)
|
|
168
|
+
else:
|
|
169
|
+
fmt = "{{:<{}}} {{:<{}}} {{:<{}}} {{}}".format(w_pkg, w_inst, w_lat)
|
|
170
|
+
divider = " ".join(["-" * w_pkg, "-" * w_inst, "-" * w_lat, "-" * w_stat])
|
|
171
|
+
header = fmt.format(col_pkg, col_inst, col_lat, col_stat)
|
|
172
|
+
|
|
173
|
+
print(header, file=out)
|
|
174
|
+
print(divider, file=out)
|
|
175
|
+
for name, inst, idate, lat, lbl, _ in display_rows:
|
|
176
|
+
if include_install_date:
|
|
177
|
+
print(fmt.format(name, inst, idate or "—", lat, lbl), file=out)
|
|
178
|
+
else:
|
|
179
|
+
print(fmt.format(name, inst, lat, lbl), file=out)
|
|
180
|
+
print(file=out)
|
|
181
|
+
|
|
182
|
+
if not updates_only:
|
|
183
|
+
if updates:
|
|
184
|
+
print("{} update(s) available.".format(len(updates)), file=out)
|
|
185
|
+
else:
|
|
186
|
+
print("All packages are up to date.", file=out)
|
|
187
|
+
elif updates:
|
|
188
|
+
print("{} update(s) available.".format(len(updates)), file=out)
|
|
189
|
+
|
|
190
|
+
if output_file:
|
|
191
|
+
if not updates:
|
|
192
|
+
print("No updates available — {} not written.".format(output_file), file=out)
|
|
193
|
+
elif output_file == "-":
|
|
194
|
+
sys.stdout.write("\n".join("{}=={}".format(r[0], r[3]) for r in updates) + "\n")
|
|
195
|
+
else:
|
|
196
|
+
path = Path(output_file)
|
|
197
|
+
path.write_text("\n".join("{}=={}".format(r[0], r[3]) for r in updates) + "\n", encoding="utf-8")
|
|
198
|
+
print("Update list written to {} ({} package(s)).".format(path, len(updates)), file=out)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Declarative CLI structure
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
_CONFIG = types.SimpleNamespace(
|
|
206
|
+
prog="venv-cmds",
|
|
207
|
+
description="Inspect the active Python environment.",
|
|
208
|
+
epilog="Tab completion: run activate-global-python-argcomplete once per user account to enable.",
|
|
209
|
+
version=__version__,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
COMMANDS = {
|
|
213
|
+
"list": {
|
|
214
|
+
"help": "List console_scripts entry points in the active environment (default).",
|
|
215
|
+
"arguments": [
|
|
216
|
+
{"name": ["-d", "--details"], "action": "store_true", "help": "Show package name alongside each command."},
|
|
217
|
+
],
|
|
218
|
+
"handler": cmd_list,
|
|
219
|
+
},
|
|
220
|
+
"check_updates": {
|
|
221
|
+
"help": "Check all installed packages against the configured pip index for available updates.",
|
|
222
|
+
"arguments": [
|
|
223
|
+
{
|
|
224
|
+
"name": ["-t", "--timeout"],
|
|
225
|
+
"type": int,
|
|
226
|
+
"default": 30,
|
|
227
|
+
"metavar": "SECONDS",
|
|
228
|
+
"help": "Per-package pip query timeout in seconds (default: 30).",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"name": ["-o", "--output-file"],
|
|
232
|
+
"metavar": "FILE",
|
|
233
|
+
"default": None,
|
|
234
|
+
"help": "Write packages with available updates to FILE in requirements.txt format.",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"name": ["-u", "--updates-only"],
|
|
238
|
+
"action": "store_true",
|
|
239
|
+
"default": False,
|
|
240
|
+
"help": "Only show packages with available updates. Produces no output when everything is current.",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"name": ["-i", "--include-install-date"],
|
|
244
|
+
"action": "store_true",
|
|
245
|
+
"default": False,
|
|
246
|
+
"help": "Add an 'Installed on' column showing when each package was installed (approximated from dist-info mtime).",
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
"name": ["-n", "--no-progress"],
|
|
250
|
+
"action": "store_true",
|
|
251
|
+
"default": False,
|
|
252
|
+
"help": "Suppress the status message written to stderr while querying the index. Useful when running from cron.",
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
"handler": cmd_check_updates,
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Parser builder and entry point
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def build_parser(config, commands):
|
|
265
|
+
parser = argparse.ArgumentParser(prog=config.prog, description=config.description, epilog=getattr(config, "epilog", None))
|
|
266
|
+
parser.add_argument("--version", action="version", version="{} {}".format(config.prog, config.version))
|
|
267
|
+
|
|
268
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
269
|
+
|
|
270
|
+
for name, spec in commands.items():
|
|
271
|
+
help_text = argparse.SUPPRESS if spec.get("hidden") else spec.get("help")
|
|
272
|
+
sub = subparsers.add_parser(name, help=help_text)
|
|
273
|
+
|
|
274
|
+
for arg in spec.get("arguments", []):
|
|
275
|
+
arg = arg.copy()
|
|
276
|
+
arg_name = arg.pop("name")
|
|
277
|
+
if isinstance(arg_name, list):
|
|
278
|
+
sub.add_argument(*arg_name, **arg)
|
|
279
|
+
else:
|
|
280
|
+
sub.add_argument(arg_name, **arg)
|
|
281
|
+
|
|
282
|
+
for group_spec in spec.get("mutually_exclusive_groups", []):
|
|
283
|
+
group = sub.add_mutually_exclusive_group(required=group_spec.get("required", False))
|
|
284
|
+
for arg in group_spec["arguments"]:
|
|
285
|
+
arg = arg.copy()
|
|
286
|
+
arg_name = arg.pop("name")
|
|
287
|
+
group.add_argument(arg_name, **arg)
|
|
288
|
+
|
|
289
|
+
sub.set_defaults(func=spec["handler"])
|
|
290
|
+
|
|
291
|
+
return parser
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def make_main(config, commands):
|
|
295
|
+
default_cmd = "list"
|
|
296
|
+
|
|
297
|
+
def main():
|
|
298
|
+
parser = build_parser(config, commands)
|
|
299
|
+
argcomplete.autocomplete(parser)
|
|
300
|
+
args = parser.parse_args()
|
|
301
|
+
|
|
302
|
+
if not hasattr(args, "func"):
|
|
303
|
+
# No subcommand given — reparse as the default command so argparse
|
|
304
|
+
# fills in the same defaults it would for an explicit invocation
|
|
305
|
+
# (e.g. store_true flags default to False, not None).
|
|
306
|
+
args = parser.parse_args([default_cmd])
|
|
307
|
+
|
|
308
|
+
args.func(args)
|
|
309
|
+
|
|
310
|
+
return main
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
main = make_main(_CONFIG, COMMANDS)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: venv-cmds
|
|
3
|
+
Version: 2026.9.26.1
|
|
4
|
+
Summary: List available console commands in the active Python environment, check for package updates
|
|
5
|
+
Author-email: Paul McGowan <paul.mcgowan@ophix.io>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://ophix.io
|
|
8
|
+
Project-URL: Documentation, https://github.com/ophixproject/venv-cmds#readme
|
|
9
|
+
Project-URL: Source, https://github.com/ophixproject/venv-cmds
|
|
10
|
+
Keywords: venv,python,cli,console scripts
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.7
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: importlib_metadata>=3.6; python_version < "3.8"
|
|
30
|
+
Requires-Dist: argcomplete>=3.0
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest; extra == "test"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# venv-cmds
|
|
36
|
+
|
|
37
|
+
Small discovery and maintenance utilities for Python virtual environments.
|
|
38
|
+
|
|
39
|
+
## Why this exists
|
|
40
|
+
|
|
41
|
+
When you activate a virtual environment it is not always obvious which
|
|
42
|
+
command-line tools are available or whether they are up to date.
|
|
43
|
+
`venv-cmds` provides two commands: `list` to discover what is installed,
|
|
44
|
+
and `check_updates` to check for newer versions on the configured pip index.
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
Running `venv-cmds` with no subcommand is equivalent to `venv-cmds list`.
|
|
49
|
+
|
|
50
|
+
### `list`
|
|
51
|
+
|
|
52
|
+
Show all `console_scripts` entry points in the active environment.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
venv-cmds list
|
|
56
|
+
venv-cmds list --details # include package name alongside each command
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `check_updates`
|
|
60
|
+
|
|
61
|
+
Check all installed packages against the configured pip index and report
|
|
62
|
+
which have updates available. Respects all pip index sources configured for
|
|
63
|
+
the environment — public PyPI, private indexes, and local mirrors all work
|
|
64
|
+
without any extra configuration.
|
|
65
|
+
|
|
66
|
+
If `pip` isn't importable in the current venv (e.g. one created with
|
|
67
|
+
`uv venv` without `--seed`), falls back automatically to `uv pip list`
|
|
68
|
+
targeting this same interpreter, provided `uv` is installed on `PATH`.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
venv-cmds check_updates
|
|
72
|
+
venv-cmds check_updates --updates-only # only show packages with updates
|
|
73
|
+
venv-cmds check_updates --no-progress # suppress status message on stderr (cron-safe)
|
|
74
|
+
venv-cmds check_updates --include-install-date # add an 'Installed on' column
|
|
75
|
+
venv-cmds check_updates --timeout 60 # per-package query timeout in seconds (default: 30)
|
|
76
|
+
venv-cmds check_updates --output-file updates.txt # write pinned requirements file for updates
|
|
77
|
+
venv-cmds check_updates --output-file - # write pinned requirements to stdout
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
When `--output-file` is used the output file contains `package==version` lines
|
|
81
|
+
for every package with an available update. Passing this file to
|
|
82
|
+
`pip install -r updates.txt` will upgrade exactly the packages that were
|
|
83
|
+
flagged, with no other changes.
|
|
84
|
+
|
|
85
|
+
## Installation
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install venv-cmds
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Requires Python 3.7 or later. Works with both `venv` and `virtualenv`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/venv_cmds/__init__.py
|
|
5
|
+
src/venv_cmds/_version.py
|
|
6
|
+
src/venv_cmds/cli.py
|
|
7
|
+
src/venv_cmds.egg-info/PKG-INFO
|
|
8
|
+
src/venv_cmds.egg-info/SOURCES.txt
|
|
9
|
+
src/venv_cmds.egg-info/dependency_links.txt
|
|
10
|
+
src/venv_cmds.egg-info/entry_points.txt
|
|
11
|
+
src/venv_cmds.egg-info/requires.txt
|
|
12
|
+
src/venv_cmds.egg-info/top_level.txt
|
|
13
|
+
tests/test_check_updates.py
|
|
14
|
+
tests/test_cli.py
|
|
15
|
+
tests/test_list.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
venv_cmds
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import types
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from venv_cmds import cli
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _completed(returncode=0, stdout="", stderr=""):
|
|
11
|
+
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _args(**overrides):
|
|
15
|
+
defaults = dict(
|
|
16
|
+
timeout=30,
|
|
17
|
+
output_file=None,
|
|
18
|
+
updates_only=False,
|
|
19
|
+
include_install_date=False,
|
|
20
|
+
no_progress=True,
|
|
21
|
+
)
|
|
22
|
+
defaults.update(overrides)
|
|
23
|
+
return types.SimpleNamespace(**defaults)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# _pip_list_json
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def test_pip_list_json_uses_pip_when_it_succeeds(monkeypatch):
|
|
31
|
+
payload = [{"name": "foo", "version": "1.0"}]
|
|
32
|
+
calls = []
|
|
33
|
+
|
|
34
|
+
def fake_run(cmd, **kwargs):
|
|
35
|
+
calls.append(cmd)
|
|
36
|
+
return _completed(0, stdout=json.dumps(payload))
|
|
37
|
+
|
|
38
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
39
|
+
|
|
40
|
+
result = cli._pip_list_json([], timeout=5)
|
|
41
|
+
|
|
42
|
+
assert result == payload
|
|
43
|
+
assert len(calls) == 1
|
|
44
|
+
assert "pip" in calls[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_pip_list_json_falls_back_to_uv_when_pip_fails(monkeypatch):
|
|
48
|
+
payload = [{"name": "bar", "version": "2.0"}]
|
|
49
|
+
calls = []
|
|
50
|
+
|
|
51
|
+
def fake_run(cmd, **kwargs):
|
|
52
|
+
calls.append(cmd)
|
|
53
|
+
if "uv" in cmd[0]:
|
|
54
|
+
return _completed(0, stdout=json.dumps(payload))
|
|
55
|
+
return _completed(1, stderr="pip not importable")
|
|
56
|
+
|
|
57
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
58
|
+
monkeypatch.setattr(cli.shutil, "which", lambda name: "/usr/bin/uv")
|
|
59
|
+
|
|
60
|
+
result = cli._pip_list_json([], timeout=5)
|
|
61
|
+
|
|
62
|
+
assert result == payload
|
|
63
|
+
assert len(calls) == 2
|
|
64
|
+
assert "--python" in calls[1]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_pip_list_json_exits_when_both_pip_and_uv_fail(monkeypatch, capsys):
|
|
68
|
+
def fake_run(cmd, **kwargs):
|
|
69
|
+
return _completed(1, stderr="boom")
|
|
70
|
+
|
|
71
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
72
|
+
monkeypatch.setattr(cli.shutil, "which", lambda name: None)
|
|
73
|
+
|
|
74
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
75
|
+
cli._pip_list_json([], timeout=5)
|
|
76
|
+
|
|
77
|
+
assert exc_info.value.code == 1
|
|
78
|
+
_, err = capsys.readouterr()
|
|
79
|
+
assert "boom" in err
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# _get_install_date
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def test_get_install_date_from_path_mtime(tmp_path):
|
|
87
|
+
marker = tmp_path / "pkg.dist-info"
|
|
88
|
+
marker.write_text("x")
|
|
89
|
+
|
|
90
|
+
dist = types.SimpleNamespace(_path=marker)
|
|
91
|
+
|
|
92
|
+
result = cli._get_install_date(dist)
|
|
93
|
+
|
|
94
|
+
assert len(result) == 10
|
|
95
|
+
assert result.count("-") == 2
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_get_install_date_returns_placeholder_without_path():
|
|
99
|
+
dist = types.SimpleNamespace()
|
|
100
|
+
|
|
101
|
+
assert cli._get_install_date(dist) == "—"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# cmd_check_updates
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def test_check_updates_all_current(monkeypatch, capsys):
|
|
109
|
+
all_pkgs = [{"name": "alpha", "version": "1.0"}]
|
|
110
|
+
|
|
111
|
+
def fake_pip_list_json(extra_args, timeout):
|
|
112
|
+
return [] if extra_args == ["--outdated"] else all_pkgs
|
|
113
|
+
|
|
114
|
+
monkeypatch.setattr(cli, "_pip_list_json", fake_pip_list_json)
|
|
115
|
+
|
|
116
|
+
cli.cmd_check_updates(_args())
|
|
117
|
+
|
|
118
|
+
out, _ = capsys.readouterr()
|
|
119
|
+
assert "alpha" in out
|
|
120
|
+
assert "OK" in out
|
|
121
|
+
assert "All packages are up to date." in out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_check_updates_reports_available_update(monkeypatch, capsys):
|
|
125
|
+
def fake_pip_list_json(extra_args, timeout):
|
|
126
|
+
if extra_args == ["--outdated"]:
|
|
127
|
+
return [{"name": "alpha", "version": "1.0", "latest_version": "2.0"}]
|
|
128
|
+
return [{"name": "alpha", "version": "1.0"}, {"name": "beta", "version": "3.0"}]
|
|
129
|
+
|
|
130
|
+
monkeypatch.setattr(cli, "_pip_list_json", fake_pip_list_json)
|
|
131
|
+
|
|
132
|
+
cli.cmd_check_updates(_args())
|
|
133
|
+
|
|
134
|
+
out, _ = capsys.readouterr()
|
|
135
|
+
assert "alpha" in out and "UPDATE AVAILABLE" in out
|
|
136
|
+
assert "beta" in out and "OK" in out
|
|
137
|
+
assert "1 update(s) available." in out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_check_updates_only_flag_hides_current_packages(monkeypatch, capsys):
|
|
141
|
+
def fake_pip_list_json(extra_args, timeout):
|
|
142
|
+
# updates_only mode should never request the full package list
|
|
143
|
+
assert extra_args == ["--outdated"]
|
|
144
|
+
return [{"name": "alpha", "version": "1.0", "latest_version": "2.0"}]
|
|
145
|
+
|
|
146
|
+
monkeypatch.setattr(cli, "_pip_list_json", fake_pip_list_json)
|
|
147
|
+
|
|
148
|
+
cli.cmd_check_updates(_args(updates_only=True))
|
|
149
|
+
|
|
150
|
+
out, _ = capsys.readouterr()
|
|
151
|
+
assert "alpha" in out
|
|
152
|
+
assert "beta" not in out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_check_updates_writes_output_file(monkeypatch, tmp_path):
|
|
156
|
+
def fake_pip_list_json(extra_args, timeout):
|
|
157
|
+
if extra_args == ["--outdated"]:
|
|
158
|
+
return [{"name": "alpha", "version": "1.0", "latest_version": "2.0"}]
|
|
159
|
+
return [{"name": "alpha", "version": "1.0"}]
|
|
160
|
+
|
|
161
|
+
monkeypatch.setattr(cli, "_pip_list_json", fake_pip_list_json)
|
|
162
|
+
|
|
163
|
+
out_file = tmp_path / "updates.txt"
|
|
164
|
+
cli.cmd_check_updates(_args(output_file=str(out_file)))
|
|
165
|
+
|
|
166
|
+
assert out_file.read_text(encoding="utf-8") == "alpha==2.0\n"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_check_updates_output_file_dash_writes_to_stdout(monkeypatch, capsys):
|
|
170
|
+
def fake_pip_list_json(extra_args, timeout):
|
|
171
|
+
if extra_args == ["--outdated"]:
|
|
172
|
+
return [{"name": "alpha", "version": "1.0", "latest_version": "2.0"}]
|
|
173
|
+
return [{"name": "alpha", "version": "1.0"}]
|
|
174
|
+
|
|
175
|
+
monkeypatch.setattr(cli, "_pip_list_json", fake_pip_list_json)
|
|
176
|
+
|
|
177
|
+
cli.cmd_check_updates(_args(output_file="-"))
|
|
178
|
+
|
|
179
|
+
out, err = capsys.readouterr()
|
|
180
|
+
assert out == "alpha==2.0\n"
|
|
181
|
+
# the table itself is redirected to stderr so stdout stays parseable
|
|
182
|
+
assert "alpha" in err
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_check_updates_no_output_file_when_nothing_outdated(monkeypatch, tmp_path, capsys):
|
|
186
|
+
def fake_pip_list_json(extra_args, timeout):
|
|
187
|
+
return [] if extra_args == ["--outdated"] else [{"name": "alpha", "version": "1.0"}]
|
|
188
|
+
|
|
189
|
+
monkeypatch.setattr(cli, "_pip_list_json", fake_pip_list_json)
|
|
190
|
+
|
|
191
|
+
out_file = tmp_path / "updates.txt"
|
|
192
|
+
cli.cmd_check_updates(_args(output_file=str(out_file)))
|
|
193
|
+
|
|
194
|
+
assert not out_file.exists()
|
|
195
|
+
out, _ = capsys.readouterr()
|
|
196
|
+
assert "not written" in out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_check_updates_include_install_date_column(monkeypatch, tmp_path, capsys):
|
|
200
|
+
dist_info = tmp_path / "alpha.dist-info"
|
|
201
|
+
dist_info.write_text("x")
|
|
202
|
+
|
|
203
|
+
fake_dist = types.SimpleNamespace(metadata={"Name": "alpha"}, _path=dist_info)
|
|
204
|
+
|
|
205
|
+
def fake_pip_list_json(extra_args, timeout):
|
|
206
|
+
return [] if extra_args == ["--outdated"] else [{"name": "alpha", "version": "1.0"}]
|
|
207
|
+
|
|
208
|
+
monkeypatch.setattr(cli, "_pip_list_json", fake_pip_list_json)
|
|
209
|
+
monkeypatch.setattr(cli, "distributions", lambda: [fake_dist])
|
|
210
|
+
|
|
211
|
+
cli.cmd_check_updates(_args(include_install_date=True))
|
|
212
|
+
|
|
213
|
+
out, _ = capsys.readouterr()
|
|
214
|
+
assert "Installed on" in out
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from venv_cmds import cli
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_build_parser_registers_both_commands():
|
|
9
|
+
parser = cli.build_parser(cli._CONFIG, cli.COMMANDS)
|
|
10
|
+
subparsers_action = next(
|
|
11
|
+
a for a in parser._actions if isinstance(a, argparse._SubParsersAction)
|
|
12
|
+
)
|
|
13
|
+
assert set(subparsers_action.choices) == {"list", "check_updates"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_version_flag_prints_version_and_exits(capsys):
|
|
17
|
+
parser = cli.build_parser(cli._CONFIG, cli.COMMANDS)
|
|
18
|
+
|
|
19
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
20
|
+
parser.parse_args(["--version"])
|
|
21
|
+
|
|
22
|
+
assert exc_info.value.code == 0
|
|
23
|
+
out, _ = capsys.readouterr()
|
|
24
|
+
assert cli._CONFIG.version in out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_check_updates_args_parse_short_flags():
|
|
28
|
+
parser = cli.build_parser(cli._CONFIG, cli.COMMANDS)
|
|
29
|
+
|
|
30
|
+
args = parser.parse_args(["check_updates", "-u", "-t", "5", "-n"])
|
|
31
|
+
|
|
32
|
+
assert args.updates_only is True
|
|
33
|
+
assert args.timeout == 5
|
|
34
|
+
assert args.no_progress is True
|
|
35
|
+
assert args.func is cli.cmd_check_updates
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_list_details_flag_parses():
|
|
39
|
+
parser = cli.build_parser(cli._CONFIG, cli.COMMANDS)
|
|
40
|
+
|
|
41
|
+
args = parser.parse_args(["list", "--details"])
|
|
42
|
+
|
|
43
|
+
assert args.details is True
|
|
44
|
+
assert args.func is cli.cmd_list
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_main_with_no_subcommand_runs_list_with_defaults(monkeypatch):
|
|
48
|
+
seen = {}
|
|
49
|
+
|
|
50
|
+
def fake_cmd_list(args):
|
|
51
|
+
seen["details"] = args.details
|
|
52
|
+
|
|
53
|
+
monkeypatch.setitem(cli.COMMANDS["list"], "handler", fake_cmd_list)
|
|
54
|
+
monkeypatch.setattr(cli.argcomplete, "autocomplete", lambda parser: None)
|
|
55
|
+
monkeypatch.setattr("sys.argv", ["venv-cmds"])
|
|
56
|
+
|
|
57
|
+
cli.main()
|
|
58
|
+
|
|
59
|
+
assert seen == {"details": False}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_main_dispatches_to_explicit_subcommand(monkeypatch):
|
|
63
|
+
seen = {}
|
|
64
|
+
|
|
65
|
+
def fake_cmd_list(args):
|
|
66
|
+
seen["called"] = True
|
|
67
|
+
seen["details"] = args.details
|
|
68
|
+
|
|
69
|
+
monkeypatch.setitem(cli.COMMANDS["list"], "handler", fake_cmd_list)
|
|
70
|
+
monkeypatch.setattr(cli.argcomplete, "autocomplete", lambda parser: None)
|
|
71
|
+
monkeypatch.setattr("sys.argv", ["venv-cmds", "list", "--details"])
|
|
72
|
+
|
|
73
|
+
cli.main()
|
|
74
|
+
|
|
75
|
+
assert seen == {"called": True, "details": True}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import types
|
|
2
|
+
from importlib.metadata import entry_points
|
|
3
|
+
|
|
4
|
+
from venv_cmds import cli
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_console_scripts_exist():
|
|
8
|
+
eps = entry_points(group="console_scripts")
|
|
9
|
+
names = {ep.name for ep in eps}
|
|
10
|
+
|
|
11
|
+
# pip should always exist in a venv
|
|
12
|
+
assert "pip" in names
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _FakeDist:
|
|
16
|
+
def __init__(self, name):
|
|
17
|
+
self.metadata = {"Name": name}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _FakeEntryPoint:
|
|
21
|
+
def __init__(self, name, dist=None):
|
|
22
|
+
self.name = name
|
|
23
|
+
self.dist = dist
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fake_entry_points(monkeypatch, eps):
|
|
27
|
+
monkeypatch.setattr(cli, "entry_points", lambda group=None: eps)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_cmd_list_plain_sorted(monkeypatch, capsys):
|
|
31
|
+
monkeypatch.setattr(cli, "_in_venv", lambda: False)
|
|
32
|
+
_fake_entry_points(monkeypatch, [
|
|
33
|
+
_FakeEntryPoint("zeta"),
|
|
34
|
+
_FakeEntryPoint("alpha"),
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
cli.cmd_list(types.SimpleNamespace(details=False))
|
|
38
|
+
|
|
39
|
+
out, err = capsys.readouterr()
|
|
40
|
+
assert out.splitlines() == ["alpha", "zeta"]
|
|
41
|
+
assert "Warning" in err
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_cmd_list_details_shows_package_name(monkeypatch, capsys):
|
|
45
|
+
monkeypatch.setattr(cli, "_in_venv", lambda: False)
|
|
46
|
+
_fake_entry_points(monkeypatch, [
|
|
47
|
+
_FakeEntryPoint("thing", dist=_FakeDist("thing-package")),
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
cli.cmd_list(types.SimpleNamespace(details=True))
|
|
51
|
+
|
|
52
|
+
out, _ = capsys.readouterr()
|
|
53
|
+
assert out.strip() == "thing (package: thing-package)"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_cmd_list_details_unknown_package_when_no_dist(monkeypatch, capsys):
|
|
57
|
+
monkeypatch.setattr(cli, "_in_venv", lambda: False)
|
|
58
|
+
_fake_entry_points(monkeypatch, [_FakeEntryPoint("orphan", dist=None)])
|
|
59
|
+
|
|
60
|
+
cli.cmd_list(types.SimpleNamespace(details=True))
|
|
61
|
+
|
|
62
|
+
out, _ = capsys.readouterr()
|
|
63
|
+
assert out.strip() == "orphan (package: unknown)"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_cmd_list_in_venv_prints_info(monkeypatch, capsys):
|
|
67
|
+
monkeypatch.setattr(cli, "_in_venv", lambda: True)
|
|
68
|
+
monkeypatch.setattr(cli, "_venv_name", lambda: "myenv")
|
|
69
|
+
_fake_entry_points(monkeypatch, [])
|
|
70
|
+
|
|
71
|
+
cli.cmd_list(types.SimpleNamespace(details=False))
|
|
72
|
+
|
|
73
|
+
out, _ = capsys.readouterr()
|
|
74
|
+
assert "detected virtual environment 'myenv'" in out
|