From d9b58a8ec7d7159519f6d3247fd1452dc3b74e34 Mon Sep 17 00:00:00 2001 From: stonebig Date: Thu, 6 Aug 2026 09:04:45 +0200 Subject: [PATCH 1/3] associate: give each distribution its own start menu folder --register/--unregister assumed the WinPython layout in two ways that misfired on a plain Python target: - the start menu folder was hardcoded to Programs\WinPython, and is rmtree'd on both register and unregister, so acting on any other Python deleted the real WinPython menu folder, - shortcuts were built from every .exe found in the target's parent directory, which is right for WinPython (Spyder, Jupyter, command prompts sit next to the Python dir) but picks up unrelated files for a Python in C:\Python313 or a venv. is_winpython_layout() tests for the sibling scripts/env.bat already used by do_pip_action, and drives both: WinPython keeps its folder name and its parent scan, anything else gets a folder named after the target directory and shortcuts to its own python.exe/pythonw.exe. The folder helpers keep folder_name='WinPython' as default, so outside callers are unaffected. Menu folder creation moves out of the shortcut loop, where it was rmtree'ing and recreating once per shortcut. --movable/--fix are untouched: they patch Scripts/ shebangs and pip's vendored distlib, which is generic to any Windows Python. Help text reworded accordingly. Co-Authored-By: Claude Opus 5 --- wppm/associate.py | 75 +++++++++++++++++++++++++++++++---------------- wppm/wppm.py | 6 ++-- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/wppm/associate.py b/wppm/associate.py index 49da0ca0..fcb9098d 100644 --- a/wppm/associate.py +++ b/wppm/associate.py @@ -23,28 +23,40 @@ def get_special_folder_path(path_name): except OSError: print(f"{path_name} is an unknown path ID") -def get_winpython_start_menu_folder(current=True): - """Return WinPython Start menu shortcuts folder.""" +def is_winpython_layout(target): + """Return True if target is the Python of a WinPython distribution (sibling 'scripts/env.bat').""" + return (Path(target).parent / "scripts" / "env.bat").is_file() + +def get_start_menu_folder_name(target): + """Return the Start menu folder name of a target: 'WinPython' for a WinPython + distribution, the target directory name otherwise, so that registering a plain + Python never overwrites (nor removes) the menu of another distribution.""" + if is_winpython_layout(target): + return 'WinPython' + return Path(target).resolve().name or 'Python' + +def get_winpython_start_menu_folder(current=True, folder_name='WinPython'): + """Return Start menu shortcuts folder of a distribution.""" folder = get_special_folder_path("CSIDL_PROGRAMS") if not current: try: folder = get_special_folder_path("CSIDL_COMMON_PROGRAMS") except OSError: pass - return str(Path(folder) / 'WinPython') + return str(Path(folder) / folder_name) -def remove_winpython_start_menu_folder(current=True): - """Remove WinPython Start menu folder -- remove it if it already exists""" - path = get_winpython_start_menu_folder(current=current) +def remove_winpython_start_menu_folder(current=True, folder_name='WinPython'): + """Remove a distribution Start menu folder -- remove it if it already exists""" + path = get_winpython_start_menu_folder(current=current, folder_name=folder_name) if Path(path).is_dir(): try: shutil.rmtree(path) except WindowsError: print(f"Directory {path} could not be removed", file=sys.stderr) -def create_winpython_start_menu_folder(current=True): - """Create WinPython Start menu folder.""" - path = get_winpython_start_menu_folder(current=current) +def create_winpython_start_menu_folder(current=True, folder_name='WinPython'): + """Create a distribution Start menu folder.""" + path = get_winpython_start_menu_folder(current=current, folder_name=folder_name) if Path(path).is_dir(): try: shutil.rmtree(path) @@ -114,31 +126,42 @@ def _has_pywin32(): return importlib.util.find_spec('pythoncom') is not None def _remove_start_menu_folder(target, current=True, has_pywin32=False): - "remove menu Folder for target WinPython if pywin32 exists" + "remove menu Folder of target distribution if pywin32 exists" if has_pywin32: - remove_winpython_start_menu_folder(current=current) + remove_winpython_start_menu_folder(current=current, folder_name=get_start_menu_folder_name(target)) else: print("Skipping start menu removal as pywin32 package is not installed.") +def _get_launchers(target): + """Return the executables to give a Start menu shortcut to. + A WinPython distribution keeps its launchers (Spyder, Jupyter, ...) next to its + Python directory, so all of them are taken. For a plain Python, the neighbours + are unrelated files, so only its own interpreters are taken.""" + if is_winpython_layout(target): + wpdir = Path(target).parent + return [wpdir / name for name in os.listdir(wpdir) if Path(name).suffix.lower() == ".exe"] + return [exe for exe in (Path(target) / "python.exe", Path(target) / "pythonw.exe") if exe.is_file()] + def _get_shortcut_data(target, current=True, has_pywin32=False): "get windows menu access data if pywin32 exists, otherwise empty list" if not has_pywin32: return [] - - wpdir = str(Path(target).parent) + + launchers = _get_launchers(target) + if not launchers: + return [] + # create the menu folder once, not once per shortcut + menu_folder = create_winpython_start_menu_folder(current=current, folder_name=get_start_menu_folder_name(target)) data = [] - for name in os.listdir(wpdir): - bname, ext = Path(name).stem, Path(name).suffix - if ext.lower() == ".exe": - # Path for the shortcut file in the start menu folder - shortcut_name = str(Path(create_winpython_start_menu_folder(current=current)) / bname) + '.lnk' - data.append( - ( - str(Path(wpdir) / name), # Target executable path - bname, # Description/Name - shortcut_name, # Shortcut file path - ) + for exe in launchers: + bname = exe.stem + data.append( + ( + str(exe), # Target executable path + bname, # Description/Name + str(Path(menu_folder) / bname) + '.lnk', # Shortcut file path ) + ) return data # --- PythonCore entries (PEP-0514 and WinPython specific) --- @@ -240,7 +263,7 @@ def register(target, current=True, reg_type=winreg.REG_SZ, verbose=True): # Create start menu entries if has_pywin32: if verbose: - print(f'Creating WinPython menu for all icons in {Path(target).parent}') + print(f'Creating "{get_start_menu_folder_name(target)}" start menu shortcuts') for path, desc, fname in _get_shortcut_data(target, current=current, has_pywin32=True): try: create_shortcut(path, desc, fname, verbose=verbose) @@ -269,7 +292,7 @@ def unregister(target, current=True, verbose=True): # Remove start menu shortcuts if has_pywin32: if verbose: - print(f'Removing WinPython menu for all icons in {Path(target).parent}') + print(f'Removing "{get_start_menu_folder_name(target)}" start menu shortcuts') _remove_start_menu_folder(target, current=current, has_pywin32=True) # The original code had commented out code to delete .lnk files individually. else: diff --git a/wppm/wppm.py b/wppm/wppm.py index abed2fc6..fa62e58d 100644 --- a/wppm/wppm.py +++ b/wppm/wppm.py @@ -284,8 +284,8 @@ def main(test=False): if sys.stdout and hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(errors="replace") - registerWinPythonHelp = f"Register the target Python: associate file extensions, icons and context menu with it (useful for portable distributions like WinPython)" - unregisterWinPythonHelp = f"Unregister the target Python: de-associate file extensions, icons and context menu from it" + registerWinPythonHelp = f"Register the target Python in Windows (file extensions, icons, context menu, start menu), under the 'WinPython' PEP-514 vendor key" + unregisterWinPythonHelp = f"Unregister the target Python from Windows: de-associate file extensions, icons and context menu, and remove its start menu folder" parser = ArgumentParser(prog="wppm", description=f"WinPython Package Manager: handle a Python distribution (WinPython or not) and its packages ({__version__})", formatter_class=RawTextHelpFormatter, @@ -295,7 +295,7 @@ def main(test=False): parser.add_argument( "--register", dest="registerWinPython", action="store_true", help=registerWinPythonHelp) parser.add_argument("--unregister", dest="unregisterWinPython", action="store_true", help=unregisterWinPythonHelp) parser.add_argument("--fix", action="store_true", help="make the target Python use absolute (fixed) paths in launchers and shebangs") - parser.add_argument("--movable", action="store_true", help="make the target Python movable/portable: relative paths in launchers and shebangs") + parser.add_argument("--movable", action="store_true", help="make the target Python (any Windows Python) movable/portable: relative paths in launchers and shebangs") parser.add_argument("-ws", dest="wheelsource", default=None, type=str, help="wheels location, ('.' = WheelHouse): wppm pylock.toml -ws source_of_wheels, wppm -ls -ws .") parser.add_argument("-wd", dest="wheeldrain" , default=None, type=str, help="wheels destination: wppm pylock.toml -wd destination_of_wheels") parser.add_argument("-ls", "--list", action="store_true", help="list installed packages matching [optional] expression: wppm -ls, wppm -ls pand") From 9130dd03cd5cdd01924830e84f0bb616e5d46e4c Mon Sep 17 00:00:00 2001 From: stonebig Date: Thu, 6 Aug 2026 09:05:14 +0200 Subject: [PATCH 2/3] bump wppm version to 17.9.20260805 Middle digit up: the start menu folder of a registered non-WinPython Python changes, so this is not a fixes-only release. Co-Authored-By: Claude Opus 5 --- wppm/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wppm/__init__.py b/wppm/__init__.py index 6584b249..161fcdda 100644 --- a/wppm/__init__.py +++ b/wppm/__init__.py @@ -28,6 +28,6 @@ OTHER DEALINGS IN THE SOFTWARE. """ -__version__ = '17.8.20260718' +__version__ = '17.9.20260805' __license__ = __doc__ __project_url__ = 'http://winpython.github.io/' From 78ce8d34df10d3d13ecb5bb6dc7a4f38345ee472 Mon Sep 17 00:00:00 2001 From: stonebig Date: Thu, 6 Aug 2026 09:06:19 +0200 Subject: [PATCH 3/3] README_PYPI: lead with real output, and make the package findable The page listed what wppm can do without ever showing a result, so a visitor had to imagine the output. Each section now asks a question and answers it with actual terminal output, all of it run and pasted verbatim: - wppm -p "flit![.]" as the opener: which extras of an installed package are unusable here, and exactly what is missing, - wppm -r "pytest[.]": who pulls a package in, through which extra -- the granularity pipdeptree does not have, - wppm -r "pluggy!": the handful of packages that cap it, ie what will actually fight the next upgrade. Adds a library section (piptree.PipData().down(..., format="json")), since the tree engine is importable and takes a target= -- no subprocess, no parsing of terminal output. pyproject: the PyPI search line read "WinPython Package Management" and the keywords were Portable/Windows, so nobody looking for a dependency tool could match it. Description and keywords now say what it does, plus console/build-tools classifiers. Usage block regenerated from --help; it had drifted, and now differs only in the -t default, kept generic instead of a local path. Co-Authored-By: Claude Opus 5 --- README_PYPI.md | 215 +++++++++++++++++++++++++++++++++++++------------ pyproject.toml | 9 ++- 2 files changed, 171 insertions(+), 53 deletions(-) diff --git a/README_PYPI.md b/README_PYPI.md index 9f7a3456..9fc25a28 100644 --- a/README_PYPI.md +++ b/README_PYPI.md @@ -1,72 +1,185 @@ -# wppm — dependency trees, offline wheelhouses, portable Pythons - -`wppm` complements `pip` on **any** Python environment (it was born in -[WinPython](https://winpython.github.io/), the portable distribution for Windows, -but does not require it). `pip` remains the recommended way to add or remove -packages; `wppm` covers what `pip` doesn't show or do: - -- **extras-aware dependency trees**: what does `pandas[test]` pull in? What does - *each* extra of a package pull in (`pandas[.]`)? Which installed packages use - `pytest`, through which extra? -- **constraint hunting**: `wppm -r numpy!` shows only the packages that *pin or cap* - numpy — the ones that will hurt when you upgrade, -- **missing-dependency detection**: trees flag requirements that are not installed - (`lxml==? >=5.3.0;extra==xml`), -- **JSON everywhere** (`-j`): dependency trees, package lists and environment - manifests as machine-readable output, for CI gates and diffing, -- **offline wheelhouse tooling**: install from a directory of wheels or a - `pylock.toml`, inventory a wheelhouse without installing anything (`-ls -ws`), -- **environment manifest** (`-md`): one document — Markdown or JSON — describing the - distribution, its tools, its packages and its wheelhouse; a lightweight SBOM, -- **portability housekeeping**: make any target Python movable (relative shebangs - and launchers) or fixed, register/unregister it in Windows. - -Compared with `pipdeptree`: `wppm` adds per-`[extra]` granularity in both -directions, the constraining-dependency filter (`!`), missing-dependency flags, -and it can inspect another environment (`-t`) or a plain directory of wheels -(`-ws`) — no need to install anything into it first. - -## Examples - -What each extra of `pandas` would pull in, one level deep: +# wppm — the dependency questions `pip` won't answer + +`wppm` is a small companion to `pip`, for **any** Python environment (it was born in +[WinPython](https://winpython.github.io/), the portable Windows distribution, but does +not require it). Keep using `pip` to install and remove things — use `wppm` to *see* +what is actually there. ```console -wppm -p pandas[.] -l1 +pip install wppm ``` -Which installed packages depend on `pytest` (through which extra), and which ones -constrain it hard (`!`): +## Which extras of a package are actually usable here? + +You installed `flit`. Its `[doc]` and `[test]` extras promise more. What is missing? ```console -wppm -r pytest[test] -wppm -r pytest![test] +$ wppm -p "flit![.]" +flit[doc]==3.12.0 , + pygments-github-lexers==? ;extra==doc + sphinx==? ;extra==doc + sphinxcontrib-github-alt==? ;extra==doc +flit[test]==3.12.0 , + pytest-cov==? ;extra==test + responses==? ;extra==test + testpath==? ;extra==test + tomli==? ;extra==test ``` -The full constraint web of your environment — every package, every extra, nine -levels deep: +`[.]` means *every extra*, `!` means *only show what is missing*, and `==?` marks a +requirement that is not installed. Extras with nothing missing are simply not printed — +so an empty answer means "everything this package offers is ready to use". + +Drop the `!` to see the whole picture instead, installed versions included: ```console -wppm -p .[.] -l9 +$ wppm -p "requests[.]" -l1 +requests==2.34.2 , + certifi==2026.6.17 >=2023.5.7 + charset-normalizer==3.4.9 <4,>=2 + idna==3.18 <4,>=2.5 + urllib3==2.7.0 <3,>=1.26 +requests[socks]==2.34.2 , + certifi==2026.6.17 >=2023.5.7 + charset-normalizer==3.4.9 <4,>=2 + idna==3.18 <4,>=2.5 + pysocks==? !=1.5.7,>=1.5.6;extra==socks + urllib3==2.7.0 <3,>=1.26 +requests[use-chardet-on-py3]==2.34.2 , + certifi==2026.6.17 >=2023.5.7 + chardet==? <8,>=3.0.2;extra==use-chardet-on-py3 + charset-normalizer==3.4.9 <4,>=2 + idna==3.18 <4,>=2.5 + urllib3==2.7.0 <3,>=1.26 ``` -A JSON inventory of an offline wheel bundle, without installing it: +## Who pulls in `pytest`, and through which extra? + +The reverse direction, `-r`, is extras-aware too — it tells you *why* something is in +your environment, down to the extra that asked for it: + +```console +$ wppm -r "pytest[.]" +pytest==9.0.3 +pytest[all]==9.0.3 , + idna[all]==3.18 [requires: pytest>=8.3.2;extra==all] + pandas[all]==3.0.3 [requires: pytest>=8.3.4;extra==all] +pytest[dev]==9.0.3 +pytest[test]==9.0.3 , + flit[test]==3.12.0 [requires: pytest>=2.7.3;extra==test] + pandas[test]==3.0.3 [requires: pytest>=8.3.4;extra==test] +pytest[testing]==9.0.3 , + pluggy[testing]==1.6.0 [requires: pytest;extra==testing] +pytest[tests]==9.0.3 , + pillow[tests]==12.3.0 [requires: pytest;extra==tests] +``` + +## What will break when I upgrade? + +With `-r`, the `!` filter keeps only the packages that *pin or cap* the one you name — +the handful that will actually fight your next upgrade, instead of the long list of +packages that merely depend on it: ```console -wppm -ls -ws .\wheelhouse\included.wheels --json +$ wppm -r "pluggy!" +pluggy==1.6.0 , + pytest==9.0.3 [requires: pluggy<2,>=1.5] ``` -A manifest of the current environment (distribution, tools, packages, wheelhouse): +An empty answer here is good news: nothing constrains it, upgrade away. + +And the whole constraint web of an environment — every package, every extra, nine +levels deep — is one command: ```console -wppm -md --json +$ wppm -p ".[.]" -l9 ``` -Fail a CI job if anything in the tree is missing: +## Everything is available as JSON + +Any of `-p`, `-r`, `-ls`, `-md` accepts `-j` / `--json`, so the same answers can gate a +CI job or be diffed between two environments: + +```console +$ wppm -p pluggy -j +[ + { + "package": "pluggy", + "extra": "", + "version": "1.6.0", + "installed": true, + "constraint": "", + "depends": [] + } +] +``` ```console -wppm -p myapp -j | python -c "import sys,json; s=json.load(sys.stdin); [s.extend(n['depends']) for n in s]; sys.exit(1 if any(not n['installed'] for n in s) else 0)" +$ wppm -p myapp -j | python -c "import sys,json; s=json.load(sys.stdin); [s.extend(n['depends']) for n in s]; sys.exit(1 if any(not n['installed'] for n in s) else 0)" ``` +## Or use it from Python + +The tree engine is a plain importable module — no subprocess, no parsing of terminal +output. `down()` walks dependencies, `up()` walks them backwards, and both return +indented text by default or a JSON string with `format="json"`: + +```python +import json +from wppm import piptree + +pip = piptree.PipData() # or PipData(target=r"D:\WPy64\python") + +tree = json.loads(pip.down("pandas", "mysql", format="json")) +missing = [d["package"] for d in tree[0]["depends"] if not d["installed"]] +print(f"pandas[mysql] needs: {missing}") +``` + +```console +pandas[mysql] needs: ['pymysql', 'sqlalchemy'] +``` + +```python +>>> print(pip.up("pluggy!")) # who caps pluggy? +pluggy==1.6.0 , + pytest==9.0.3 [requires: pluggy<2,>=1.5] +>>> pip.summary("pandas") +'Powerful data structures for data analysis, time series, and statistics' +``` + +## It also works on environments you have not installed anything into + +`-t` points `wppm` at *another* Python distribution, and `-ws` at a plain directory of +wheels — so you can inspect a portable distribution, or an offline bundle, without +installing it first: + +```console +$ wppm -ls -ws .\wheelhouse\included.wheels --json +$ wppm -p "pandas[.]" -t D:\WPy64\python +``` + +Beyond inspection, `wppm` installs from a wheelhouse or a `pylock.toml` (`-i`, `-ws`, +`-wd`), emits a one-document environment manifest — distribution, tools, packages, +wheelhouse — as Markdown or JSON (`-md`, a lightweight SBOM), and does portability +housekeeping: on any Windows Python, `--movable` / `--fix` rewrite the `Scripts\` +launchers and shebangs between relative and absolute paths, so a directory can be moved +(or pinned back down) without breaking its entry points. + +`--register` / `--unregister` associate file extensions, icons, context menu and start +menu entries with the target Python. Each distribution gets its own start menu folder, +so registering one never disturbs another — but note that the target is declared under +the `WinPython` PEP-514 vendor key. + +## Compared with `pipdeptree` + +`wppm` adds per-`[extra]` granularity in **both** directions, the `!` filter (missing +dependencies forward, constraining dependencies backward), and the ability to inspect +another environment (`-t`) or a bare directory of wheels (`-ws`) without installing +anything into it. + +> Quoting: `!` and `[` are shell metacharacters in POSIX shells, so quote the argument +> (`wppm -p "flit![.]"`). In `cmd.exe` the quotes are optional. + ## Command line ```text @@ -75,7 +188,7 @@ usage: wppm [-h] [-v] [--register] [--unregister] [--fix] [--movable] [-l LEVELS] [-j] [-t TARGET] [-i] [-u] [package(s) or lockfile ...] -WinPython Package Manager: handle a Python distribution (WinPython or not) and its packages +WinPython Package Manager: handle a Python distribution (WinPython or not) and its packages (17.9.20260805) positional arguments: package(s) or lockfile @@ -84,14 +197,14 @@ positional arguments: options: -h, --help show this help message and exit -v, --verbose show more details on packages and actions - --register Register the target Python: associate file extensions, icons and context menu with it (useful for portable distributions like WinPython) - --unregister Unregister the target Python: de-associate file extensions, icons and context menu from it + --register Register the target Python in Windows (file extensions, icons, context menu, start menu), under the 'WinPython' PEP-514 vendor key + --unregister Unregister the target Python from Windows: de-associate file extensions, icons and context menu, and remove its start menu folder --fix make the target Python use absolute (fixed) paths in launchers and shebangs - --movable make the target Python movable/portable: relative paths in launchers and shebangs + --movable make the target Python (any Windows Python) movable/portable: relative paths in launchers and shebangs -ws WHEELSOURCE wheels location, ('.' = WheelHouse): wppm pylock.toml -ws source_of_wheels, wppm -ls -ws . -wd WHEELDRAIN wheels destination: wppm pylock.toml -wd destination_of_wheels -ls, --list list installed packages matching [optional] expression: wppm -ls, wppm -ls pand - -lsa list details of packages matching [optional] expression: wppm -lsa pandas -l1 + -lsa list details of packages matching [optional] expression: wppm -lsa pandas -l1 -md markdown summary of the installation -p show Package (!= missing) dependencies of the given package[option], [.]=all: wppm -p pandas[.] -r show Reverse (!= constraining) dependancies of the given package[option]: wppm -r pytest![test] @@ -99,7 +212,7 @@ options: -j, --json machine-readable JSON output (with -p, -r, -ls, -md): wppm -p pandas[.] -j -t TARGET path to target Python distribution (default: current environment) -i, --install install a given package wheel or pylock file (use pip for more features) - -u, --uninstall uninstall package (use pip for more features) + -u, --uninstall uninstall package (use pip for more features) ``` ## Links diff --git a/pyproject.toml b/pyproject.toml index be1e4c6c..26477876 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,17 @@ classifiers=[ 'Operating System :: Unix', 'Programming Language :: Python :: 3', 'Development Status :: 5 - Production/Stable', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', 'Topic :: Scientific/Engineering', + 'Topic :: Software Development :: Build Tools', + 'Topic :: System :: Software Distribution', 'Topic :: Software Development :: Widget Sets', ] dynamic = ["version",] -description="WinPython Package Management" -keywords = ["Portable","Windows"] +description="pip companion: extras-aware dependency trees, offline wheelhouses, portable Pythons" +keywords = ["dependency","dependencies","dependency-tree","pipdeptree","extras","requirements","wheelhouse","pip","sbom","portable","windows"] [project.urls] Documentation = "https://winpython.github.io/"