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.
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,2 @@
1
+ __version__ = "2026.09.26.01"
2
+ __package_name__ = "venv-cmds"
@@ -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,2 @@
1
+ [console_scripts]
2
+ venv-cmds = venv_cmds.cli:main
@@ -0,0 +1,7 @@
1
+ argcomplete>=3.0
2
+
3
+ [:python_version < "3.8"]
4
+ importlib_metadata>=3.6
5
+
6
+ [test]
7
+ pytest
@@ -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