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/" 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/' 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")