├── src ├── _native │ ├── __init__.py │ ├── helpers.h │ ├── paths.cpp │ ├── helpers.cpp │ ├── shortcut.cpp │ └── misc.cpp ├── pymanager │ ├── templates │ │ └── template.py │ ├── pyicon.rc │ ├── pywicon.rc │ ├── setup.ico │ ├── _resources │ │ ├── py.ico │ │ ├── pyc.ico │ │ ├── pyd.ico │ │ ├── python.ico │ │ ├── pyx256.png │ │ ├── pythonw.ico │ │ ├── pythonx44.png │ │ ├── pythonx50.png │ │ ├── setupx150.png │ │ ├── setupx44.png │ │ ├── pythonwx150.png │ │ ├── pythonwx44.png │ │ └── pythonx150.png │ ├── _launch.h │ ├── msi.wixproj │ ├── pymanager.appinstaller │ ├── default.manifest │ ├── MsixAppInstallerData.xml │ ├── resources.xml │ ├── _launch.cpp │ └── msi.wxs ├── manage │ ├── __main__.py │ ├── exceptions.py │ ├── __init__.py │ ├── verutils.py │ ├── startutils.py │ ├── arputils.py │ ├── fsutils.py │ ├── pathutils.py │ ├── uninstall_command.py │ └── config.py ├── pyshellext │ ├── idle.ico │ ├── py.ico │ ├── pyshellext.def │ ├── python.ico │ ├── pythonw.ico │ ├── pyshellext.rc │ ├── default.manifest │ ├── shellext.h │ └── shellext_test.cpp └── pymanager.json ├── pytest.ini ├── make-all.py ├── pyproject.toml ├── .gitignore ├── tests ├── test_shortcut.py ├── test_uninstall_command.py ├── test_pathutils.py ├── test_logging.py ├── test_fsutils.py ├── test_verutils.py ├── localserver.py ├── test_pep514utils.py ├── test_commands.py ├── test_arputils.py ├── test_shellext.py ├── test_tagutils.py ├── test_indexutils.py ├── test_installs.py ├── test_scriptutils.py └── test_list.py ├── .github ├── ISSUE_TEMPLATE │ ├── ask-a-question.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── build.yml ├── scripts └── test-firstrun.py ├── make-msi.py ├── make.py ├── LICENSE ├── ci ├── repartition-index.yml └── upload.py ├── _msbuild_test.py ├── README.md ├── make-msix.py └── _make_helper.py /src/_native/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pymanager/templates/template.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath=src 3 | testpaths=tests 4 | -------------------------------------------------------------------------------- /src/pymanager/pyicon.rc: -------------------------------------------------------------------------------- 1 | 1 ICON DISCARDABLE "_resources\python.ico" 2 | -------------------------------------------------------------------------------- /src/pymanager/pywicon.rc: -------------------------------------------------------------------------------- 1 | 1 ICON DISCARDABLE "_resources\pythonw.ico" 2 | -------------------------------------------------------------------------------- /src/manage/__main__.py: -------------------------------------------------------------------------------- 1 | import manage 2 | import sys 3 | 4 | sys.exit(manage.main(sys.argv)) 5 | -------------------------------------------------------------------------------- /src/pymanager/setup.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/setup.ico -------------------------------------------------------------------------------- /src/pyshellext/idle.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pyshellext/idle.ico -------------------------------------------------------------------------------- /src/pyshellext/py.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pyshellext/py.ico -------------------------------------------------------------------------------- /src/pyshellext/pyshellext.def: -------------------------------------------------------------------------------- 1 | EXPORTS 2 | DllGetClassObject PRIVATE 3 | DllCanUnloadNow PRIVATE 4 | -------------------------------------------------------------------------------- /src/pyshellext/python.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pyshellext/python.ico -------------------------------------------------------------------------------- /src/pyshellext/pythonw.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pyshellext/pythonw.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/py.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/py.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/pyc.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/pyc.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/pyd.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/pyd.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/python.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/python.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/pyx256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/pyx256.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonw.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/pythonw.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonx44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/pythonx44.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonx50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/pythonx50.png -------------------------------------------------------------------------------- /src/pymanager/_resources/setupx150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/setupx150.png -------------------------------------------------------------------------------- /src/pymanager/_resources/setupx44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/setupx44.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonwx150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/pythonwx150.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonwx44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/pythonwx44.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonx150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/HEAD/src/pymanager/_resources/pythonx150.png -------------------------------------------------------------------------------- /src/pyshellext/pyshellext.rc: -------------------------------------------------------------------------------- 1 | 1 ICON DISCARDABLE "python.ico" 2 | 2 ICON DISCARDABLE "pythonw.ico" 3 | 3 ICON DISCARDABLE "py.ico" 4 | 4 ICON DISCARDABLE "idle.ico" 5 | -------------------------------------------------------------------------------- /make-all.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from subprocess import check_call as run 3 | 4 | run([sys.executable, "make.py"]) 5 | run([sys.executable, "make-msix.py"]) 6 | run([sys.executable, "make-msi.py"]) 7 | -------------------------------------------------------------------------------- /src/pymanager/_launch.h: -------------------------------------------------------------------------------- 1 | int launch( 2 | const wchar_t *executable, 3 | const wchar_t *orig_cmd_line, 4 | const wchar_t *insert_args, 5 | int skip_argc, 6 | DWORD *exit_code 7 | ); 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['pymsbuild>=1.2.0b1,<2.0'] 3 | build-backend = "pymsbuild" 4 | 5 | [tool.coverage.run] 6 | branch = true 7 | disable_warnings = [ 8 | "no-sysmon", 9 | ] 10 | omit = [ 11 | "src/manage/__main__.py", 12 | ] 13 | 14 | [tool.coverage.html] 15 | show_contexts = true 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /build/ 3 | /dist/ 4 | /download/ 5 | /env*/ 6 | /python-manager/ 7 | /pythons/ 8 | 9 | # Can't seem to stop WiX from creating this directory... 10 | /src/pymanager/obj 11 | 12 | *.exe 13 | *.pyc 14 | *.pyd 15 | *.pdb 16 | *.so 17 | *.dylib 18 | *.msix 19 | *.appxsym 20 | *.zip 21 | *.msi 22 | __pycache__ 23 | -------------------------------------------------------------------------------- /src/pymanager/msi.wixproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | $(DefineConstants); 6 | version=$(Version); 7 | pythondllname=$(PythonDLLName); 8 | pydsuffix=$(PydSuffix); 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/test_shortcut.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import _native 4 | 5 | from pathlib import Path 6 | 7 | 8 | def test_simple_shortcut(tmp_path): 9 | open(tmp_path / "target.txt", "wb").close() 10 | _native.shortcut_create( 11 | tmp_path / "test.lnk", 12 | tmp_path / "target.txt", 13 | ) 14 | assert (tmp_path / "test.lnk").is_file() 15 | 16 | 17 | 18 | def test_start_path(): 19 | p = Path(_native.shortcut_get_start_programs()) 20 | assert p.is_dir() 21 | 22 | # Should be writable 23 | f = p / "__test_file.txt" 24 | try: 25 | open(f, "wb").close() 26 | finally: 27 | f.unlink() 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ask-a-question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Ask a question 3 | about: Not sure it's a bug? Ask us here 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Background 11 | 12 | Please let us know how you installed the Python install manager, and/or what you are trying to do. A lot of questions are answered at https://docs.python.org/dev/using/windows.html. 13 | 14 | # Details 15 | 16 | Any more details about what you've tried, what you expected to happen, or what you would like to see. 17 | 18 | Got logs? (Check your `%TEMP%` directory.) Drag-and-drop files to attach them here, or paste relevant output in a code block. 19 | -------------------------------------------------------------------------------- /src/pymanager/pymanager.appinstaller: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 13 | 14 | 15 | 16 | true 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /scripts/test-firstrun.py: -------------------------------------------------------------------------------- 1 | """Simple script to allow running manage/firstrun.py without rebuilding. 2 | 3 | You'll need to build the test module (_msbuild_test.py). 4 | """ 5 | 6 | import os 7 | import pathlib 8 | import sys 9 | 10 | 11 | ROOT = pathlib.Path(__file__).absolute().parent.parent / "src" 12 | sys.path.append(str(ROOT)) 13 | 14 | 15 | import _native 16 | if not hasattr(_native, "coinitialize"): 17 | import _native_test 18 | for k in dir(_native_test): 19 | if k[:1] not in ("", "_"): 20 | setattr(_native, k, getattr(_native_test, k)) 21 | 22 | 23 | import manage.commands 24 | cmd = manage.commands.FirstRun([], ROOT) 25 | sys.exit(cmd.execute() or 0) 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tests/test_uninstall_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import winreg 4 | 5 | from pathlib import Path 6 | 7 | from manage import uninstall_command as UC 8 | 9 | 10 | def test_purge_global_dir(monkeypatch, registry, tmp_path): 11 | registry.setup(Path=rf"C:\A;{tmp_path}\X;{tmp_path};C:\B;%PTH%;C:\%D%\E") 12 | (tmp_path / "test.txt").write_bytes(b"") 13 | (tmp_path / "test2.txt").write_bytes(b"") 14 | 15 | monkeypatch.setitem(os.environ, "PTH", str(tmp_path)) 16 | UC._do_purge_global_dir(tmp_path, "SLOW WARNING", hive=registry.hive, subkey=registry.root) 17 | assert registry.getvalueandkind("", "Path") == ( 18 | rf"C:\A;{tmp_path}\X;C:\B;%PTH%;C:\%D%\E", winreg.REG_SZ) 19 | assert not list(tmp_path.iterdir()) 20 | -------------------------------------------------------------------------------- /src/pymanager/default.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | true 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/pyshellext/default.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | true 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/test_pathutils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from manage.pathutils import Path, PurePath 4 | 5 | def test_path_match(): 6 | p = Path("python3.12.exe") 7 | assert p.match("*.exe") 8 | assert p.match("python*") 9 | assert p.match("python*.exe") 10 | assert p.match("python3.12*.exe") 11 | assert p.match("*hon3.*") 12 | assert p.match("p*3.*.exe") 13 | 14 | assert not p.match("*.com") 15 | assert not p.match("example*") 16 | assert not p.match("example*.com") 17 | assert not p.match("*ple*") 18 | 19 | 20 | def test_path_stem(): 21 | p = Path("python3.12.exe") 22 | assert p.stem == "python3.12" 23 | assert p.suffix == ".exe" 24 | p = Path("python3.12") 25 | assert p.stem == "python3" 26 | assert p.suffix == ".12" 27 | p = Path("python3") 28 | assert p.stem == "python3" 29 | assert p.suffix == "" 30 | p = Path(".exe") 31 | assert p.stem == "" 32 | assert p.suffix == ".exe" 33 | -------------------------------------------------------------------------------- /src/_native/helpers.h: -------------------------------------------------------------------------------- 1 | template 2 | static int from_capsule(PyObject *obj, T **address) { 3 | T *p = (T *)PyCapsule_GetPointer(obj, typeid(T).name()); 4 | if (!p) { 5 | return 0; 6 | } 7 | *address = p; 8 | return 1; 9 | } 10 | 11 | template 12 | static void Capsule_Release(PyObject *capsule) { 13 | T *p; 14 | if (from_capsule(capsule, &p)) { 15 | p->Release(); 16 | } else { 17 | PyErr_Clear(); 18 | } 19 | } 20 | 21 | template 22 | static PyObject *make_capsule(T *p) { 23 | PyObject *r = PyCapsule_New(p, typeid(T).name(), Capsule_Release); 24 | if (!r) { 25 | p->Release(); 26 | } 27 | return r; 28 | } 29 | 30 | 31 | extern int as_utf16(PyObject *obj, wchar_t **address); 32 | 33 | extern void err_SetFromWindowsErrWithMessage( 34 | int error, 35 | const char *message=NULL, 36 | const wchar_t *os_message=NULL, 37 | void *hModule=NULL 38 | ); 39 | -------------------------------------------------------------------------------- /src/pyshellext/shellext.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | #include 10 | 11 | LRESULT RegReadStr(HKEY key, LPCWSTR valueName, std::wstring& result); 12 | 13 | struct IdleData { 14 | std::wstring title; 15 | std::wstring exe; 16 | std::wstring idle; 17 | }; 18 | 19 | HRESULT ReadIdleInstalls(std::vector &idles, HKEY hkPython, LPCWSTR company, REGSAM flags); 20 | HRESULT ReadAllIdleInstalls(std::vector &idles, HKEY hive, LPCWSTR root, REGSAM flags); 21 | 22 | IExplorerCommand *MakeIdleCommand(HKEY hive, LPCWSTR root); 23 | IExplorerCommand *MakeLaunchCommand(std::wstring title, std::wstring exe, std::wstring idle); 24 | HRESULT GetDropArgumentsW(LPCWSTR args, std::wstring &parsed); 25 | HRESULT GetDropArgumentsA(LPCSTR args, std::wstring &parsed); 26 | HRESULT GetDropDescription(LPCOLESTR pszFileName, DWORD dwMode, std::wstring &message, std::wstring &insert); 27 | -------------------------------------------------------------------------------- /src/pymanager/MsixAppInstallerData.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /make-msi.py: -------------------------------------------------------------------------------- 1 | from subprocess import check_call as run 2 | 3 | from _make_helper import ( 4 | get_dirs, 5 | get_msbuild, 6 | get_msix_version, 7 | get_output_name, 8 | ) 9 | 10 | MSBUILD_CMD = get_msbuild() 11 | 12 | DIRS = get_dirs() 13 | BUILD = DIRS["build"] 14 | TEMP = DIRS["temp"] 15 | LAYOUT = DIRS["out"] 16 | SRC = DIRS["src"] 17 | DIST = DIRS["dist"] 18 | 19 | # Calculate output names (must be after building) 20 | NAME = get_output_name(DIRS) 21 | VERSION = get_msix_version(DIRS) 22 | 23 | # Package into MSI 24 | pydllname = [p.stem for p in (LAYOUT / "runtime").glob("python*.dll")][0] 25 | pydsuffix = [p.name.partition(".")[-1] for p in (LAYOUT / "runtime").glob("manage*.pyd")][0] 26 | 27 | run([ 28 | *MSBUILD_CMD, 29 | "-Restore", 30 | SRC / "pymanager/msi.wixproj", 31 | "/p:Platform=x64", 32 | "/p:Configuration=Release", 33 | f"/p:OutputPath={DIST}", 34 | f"/p:IntermediateOutputPath={TEMP}\\", 35 | f"/p:TargetName={NAME}", 36 | f"/p:Version={VERSION}", 37 | f"/p:PythonDLLName={pydllname}", 38 | f"/p:PydSuffix={pydsuffix}", 39 | f"/p:LayoutDir={LAYOUT}", 40 | ]) 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Install source and version** 11 | - [ ] Installed from the Windows Store 12 | - [ ] Installed with the MSIX from python\.org 13 | - [ ] Installed with the MSI from python\.org 14 | - [ ] Installed with `winget install 9NQ7512CXL7T` 15 | 16 | Version: 17 | 18 | 21 | 22 | **Describe the bug** 23 | A clear and concise description of what the bug is. 24 | 25 | **To Reproduce** 26 | Steps to reproduce the behavior: 27 | 1. Go to '...' 28 | 2. Click on '....' 29 | 3. Scroll down to '....' 30 | 4. See error 31 | 32 | **Expected behavior** 33 | A clear and concise description of what you expected to happen. 34 | 35 | **Additional context** 36 | If you have log files (check your `%TEMP%` directory!), drag-and-drop them here to include them. 37 | -------------------------------------------------------------------------------- /src/pymanager.json: -------------------------------------------------------------------------------- 1 | { 2 | "install": { 3 | "source": "%PYTHON_MANAGER_SOURCE_URL%", 4 | "fallback_source": "./bundled/fallback-index.json", 5 | "default_install_tag": "3" 6 | }, 7 | "list": { 8 | "format": "%PYTHON_MANAGER_LIST_FORMAT%" 9 | }, 10 | "registry_override_key": "HKEY_LOCAL_MACHINE\\Software\\Policies\\Python\\PyManager", 11 | 12 | "confirm": "%PYTHON_MANAGER_CONFIRM%", 13 | "automatic_install": "%PYTHON_MANAGER_AUTOMATIC_INSTALL%", 14 | "include_unmanaged": "%PYTHON_MANAGER_INCLUDE_UNMANAGED%", 15 | "virtual_env": "%VIRTUAL_ENV%", 16 | "shebang_can_run_anything": "%PYTHON_MANAGER_SHEBANG_CAN_RUN_ANYTHING%", 17 | "shebang_can_run_anything_silently": false, 18 | 19 | "install_dir": "%LocalAppData%\\Python", 20 | "download_dir": "%LocalAppData%\\Python\\_cache", 21 | "global_dir": "%LocalAppData%\\Python\\bin", 22 | "bundled_dir": "./bundled", 23 | "logs_dir": "%PYTHON_MANAGER_LOGS%", 24 | 25 | "default_tag": "%PYTHON_MANAGER_DEFAULT%", 26 | "default_platform": "%PYTHON_MANAGER_DEFAULT_PLATFORM%", 27 | "user_config": "%AppData%\\Python\\PyManager.json", 28 | "additional_config": "%PYTHON_MANAGER_CONFIG%", 29 | 30 | "pep514_root": "HKEY_CURRENT_USER\\Software\\Python", 31 | "start_folder": "Python", 32 | 33 | "launcher_exe": "./templates/launcher.exe", 34 | "launcherw_exe": "./templates/launcherw.exe", 35 | "welcome_on_update": true 36 | } -------------------------------------------------------------------------------- /src/pymanager/resources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /make.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | from subprocess import check_call as run 5 | from _make_helper import get_dirs, rmtree 6 | 7 | # Clean DEBUG flag in case it affects build 8 | os.environ["PYMANAGER_DEBUG"] = "" 9 | 10 | DIRS = get_dirs() 11 | BUILD = DIRS["build"] 12 | TEMP = DIRS["temp"] 13 | LAYOUT = DIRS["out"] 14 | SRC = DIRS["src"] 15 | DIST = DIRS["dist"] 16 | 17 | if "-i" not in sys.argv: 18 | rmtree(BUILD) 19 | rmtree(TEMP) 20 | rmtree(LAYOUT) 21 | 22 | ref = "none" 23 | try: 24 | ref = os.getenv("BUILD_SOURCEBRANCH", os.getenv("GITHUB_REF", "")) 25 | if not ref: 26 | with subprocess.Popen( 27 | ["git", "describe", "HEAD", "--tags"], 28 | stdout=subprocess.PIPE, 29 | stderr=subprocess.PIPE 30 | ) as p: 31 | out, err = p.communicate() 32 | if out: 33 | ref = "refs/tags/" + out.decode().strip() 34 | ref = os.getenv("OVERRIDE_REF", ref) 35 | print("Building for tag", ref) 36 | except subprocess.CalledProcessError: 37 | pass 38 | 39 | # Run main build - this fills in BUILD and LAYOUT 40 | run([sys.executable, "-m", "pymsbuild", "wheel"], 41 | cwd=DIRS["root"], 42 | env={**os.environ, "BUILD_SOURCEBRANCH": ref}) 43 | 44 | # Bundle current latest release 45 | run([LAYOUT / "py-manager.exe", "install", "-v", "-f", "--download", TEMP / "bundle", "default"]) 46 | (LAYOUT / "bundled").mkdir(parents=True, exist_ok=True) 47 | (TEMP / "bundle" / "index.json").rename(LAYOUT / "bundled" / "fallback-index.json") 48 | for f in (TEMP / "bundle").iterdir(): 49 | f.rename(LAYOUT / "bundled" / f.name) 50 | -------------------------------------------------------------------------------- /tests/test_logging.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from manage import logging 4 | 5 | 6 | def test_wrap_and_indent(): 7 | r = list(logging.wrap_and_indent("12345678 12345 123 1 123456 1234567890", 8 | width=8)) 9 | assert r == [ 10 | "12345678", 11 | "12345", 12 | "123 1", 13 | "123456", 14 | "1234567890", 15 | ] 16 | 17 | r = list(logging.wrap_and_indent("12345678 12345 123 1 123456 1234567890", 18 | indent=4, width=8)) 19 | assert r == [ 20 | " 12345678", 21 | " 12345", 22 | " 123", 23 | " 1", 24 | " 123456", 25 | " 1234567890", 26 | ] 27 | 28 | r = list(logging.wrap_and_indent("12345678 12345 123 1 123456 1234567890", 29 | indent=4, width=8, hang="AB")) 30 | assert r == [ 31 | "AB 12345678", 32 | " 12345", 33 | " 123", 34 | " 1", 35 | " 123456", 36 | " 1234567890", 37 | ] 38 | 39 | r = list(logging.wrap_and_indent("12345678 12345 123 1 123456 1234567890", 40 | indent=4, width=8, hang="ABC")) 41 | assert r == [ 42 | "ABC 12345678", 43 | " 12345", 44 | " 123", 45 | " 1", 46 | " 123456", 47 | " 1234567890", 48 | ] 49 | 50 | r = list(logging.wrap_and_indent("12345678 12345 123 1 123456 1234567890", 51 | indent=3, width=8, hang="ABC")) 52 | assert r == [ 53 | "ABC", 54 | " 12345678", 55 | " 12345", 56 | " 123 1", 57 | " 123456", 58 | " 1234567890", 59 | ] 60 | -------------------------------------------------------------------------------- /tests/test_fsutils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import shutil 3 | 4 | from copy import copy 5 | 6 | from manage.exceptions import FilesInUseError 7 | from manage.fsutils import atomic_unlink, ensure_tree, rmtree, unlink 8 | 9 | @pytest.fixture 10 | def tree(tmp_path): 11 | a = tmp_path / "a" 12 | b = a / "b" 13 | c = a / "c" 14 | d = c / "d" 15 | a.mkdir() 16 | b.write_bytes(b"") 17 | c.mkdir() 18 | d.write_bytes(b"") 19 | try: 20 | yield a 21 | finally: 22 | if a.is_dir(): 23 | shutil.rmtree(a) 24 | 25 | 26 | def test_ensure_tree(tree): 27 | p = tree / "e" / "f" 28 | ensure_tree(p) 29 | assert (tree / "e").is_dir() 30 | assert not p.exists() 31 | ensure_tree(p) 32 | 33 | p2 = p / "g" 34 | ensure_tree(p2) 35 | assert p.is_dir() 36 | 37 | 38 | def test_unlink_success(tree): 39 | unlink(tree / "b") 40 | assert not (tree / "b").exists() 41 | 42 | 43 | def test_unlink_with_rename(tree, monkeypatch): 44 | b = tree / "b" 45 | orig_unlink = b.unlink 46 | def dont_unlink(p): 47 | if p.name == "b": 48 | raise PermissionError() 49 | orig_unlink(p) 50 | monkeypatch.setattr(type(b), "unlink", dont_unlink) 51 | 52 | unlink(b) 53 | assert not b.exists() 54 | 55 | 56 | def test_rmtree(tree): 57 | rmtree(tree) 58 | assert not tree.exists() 59 | 60 | 61 | def test_atomic_unlink(tree): 62 | files = [tree / "c/d", tree / "b"] 63 | assert all([f.is_file() for f in files]) 64 | 65 | with open(tree / "b", "rb") as f: 66 | with pytest.raises(FilesInUseError): 67 | atomic_unlink(files) 68 | 69 | assert all([f.is_file() for f in files]) 70 | 71 | atomic_unlink(files) 72 | 73 | assert not any([f.is_file() for f in files]) 74 | -------------------------------------------------------------------------------- /tests/test_verutils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from manage.verutils import Version 4 | 5 | 6 | @pytest.mark.parametrize("ver_str", [ 7 | "3", "3.1", "3.10", "3.1.2", "3.1.2.3", 8 | "3a1", "3.1a1", "3.1.2a2", "3.1.2-a2", "3.1.a.4", 9 | "3.1b1", "3.1c1", "3.1rc1", 10 | "3.*", "3*", 11 | "3.1dev0", "3.2-dev", 12 | ]) 13 | def test_version(ver_str): 14 | ver = Version(ver_str) 15 | assert ver 16 | assert str(ver) == ver_str 17 | assert ver == ver_str 18 | 19 | 20 | def test_long_version(assert_log): 21 | v = "3.1.2.3.4.5.6.7.8.9" 22 | v2 = "3.1.2.3.4.5.6.7" 23 | Version(v) 24 | assert_log( 25 | (".*is too long.*", (v, v2)), 26 | ) 27 | 28 | 29 | def test_sort_versions(): 30 | import random 31 | cases = ["3", "3.0", "3.1", "3.1.0", "3.2-a0", "3.2.0.1-a0", "3.2.0.1-b1"] 32 | expect = list(cases) 33 | random.shuffle(cases) 34 | actual = sorted(cases, key=Version) 35 | assert actual == expect 36 | 37 | 38 | def test_version_prerelease(): 39 | assert Version("3.14a0").is_prerelease 40 | assert Version("3.14-dev").is_prerelease 41 | assert not Version("3.14").is_prerelease 42 | 43 | 44 | def test_version_prerelease_order(): 45 | assert Version("3.14.0-a1") < "3.14.0-a2" 46 | assert Version("3.14.0-a2") > "3.14.0-a1" 47 | assert Version("3.14.0-a1") == "3.14.0-a1" 48 | assert Version("3.14.0-b1") > "3.14.0-a1" 49 | 50 | 51 | @pytest.mark.parametrize("ver_str", ["3.14.0", "3.14.0-a1", "3.14.20-dev", "3.14.0.0.0.0"]) 52 | def test_version_wildcard(ver_str): 53 | wild = Version("3.14.*") 54 | ver = Version(ver_str) 55 | assert wild == ver 56 | assert ver == wild 57 | 58 | 59 | @pytest.mark.parametrize("ver_str", ["3.14.0", "3.14.0-a1", "3.14.0.0.0-dev"]) 60 | def test_version_dev(ver_str): 61 | wild = Version("3.14-dev") 62 | ver = Version(ver_str) 63 | assert wild == ver 64 | 65 | 66 | def test_version_startswith(): 67 | assert Version("3.13.0").startswith(Version("3.13")) 68 | assert Version("3.13.0").startswith(Version("3.13.0")) 69 | assert not Version("3.13").startswith(Version("3.13.0")) 70 | -------------------------------------------------------------------------------- /tests/localserver.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | 5 | from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler 6 | 7 | class Handler(BaseHTTPRequestHandler): 8 | def do_GET(self, header_only=False): 9 | if self.path == "/stop": 10 | self.send_response(200) 11 | self.end_headers() 12 | self.server.shutdown() 13 | return 14 | if self.path == "/alive": 15 | self.send_response(200) 16 | self.end_headers() 17 | return 18 | if self.path == "/1kb": 19 | self.send_response(200) 20 | self.send_header("Content-Length", 1024) 21 | self.end_headers() 22 | if not header_only: 23 | self.wfile.write(os.urandom(1024)) 24 | return 25 | if self.path == "/128kb": 26 | self.send_response(200) 27 | self.send_header("Content-Length", 128*1024) 28 | self.end_headers() 29 | if not header_only: 30 | for _ in range(128): 31 | self.wfile.write(os.urandom(1024)) 32 | time.sleep(0.05) 33 | return 34 | if self.path == "/withauth": 35 | if "Authorization" not in self.headers: 36 | self.send_response(401) 37 | self.send_header("WWW-Authenticate", "Basic") 38 | self.end_headers() 39 | return 40 | from base64 import b64decode 41 | kind, _, auth = self.headers["Authorization"].partition(" ") 42 | resp = kind.encode() + b" " + b64decode(auth.encode("ascii")) 43 | self.send_response(200) 44 | self.send_header("Content-Length", len(resp)) 45 | self.end_headers() 46 | if not header_only: 47 | self.wfile.write(resp) 48 | return 49 | self.send_error(404) 50 | 51 | def do_HEAD(self): 52 | return self.do_GET(header_only=True) 53 | 54 | SERVER_ADDR = "localhost", int(sys.argv[1]) 55 | HTTPD = ThreadingHTTPServer(SERVER_ADDR, Handler) 56 | HTTPD.serve_forever() 57 | 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 2 | -------------------------------------------- 3 | 4 | 1. This LICENSE AGREEMENT is between the Python Software Foundation 5 | ("PSF"), and the Individual or Organization ("Licensee") accessing and 6 | otherwise using this software ("Python") in source or binary form and 7 | its associated documentation. 8 | 9 | 2. Subject to the terms and conditions of this License Agreement, PSF hereby 10 | grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, 11 | analyze, test, perform and/or display publicly, prepare derivative works, 12 | distribute, and otherwise use Python alone or in any derivative version, 13 | provided, however, that PSF's License Agreement and PSF's notice of copyright, 14 | i.e., "Copyright (c) 2001 Python Software Foundation; All Rights Reserved" 15 | are retained in Python alone or in any derivative version prepared by Licensee. 16 | 17 | 3. In the event Licensee prepares a derivative work that is based on 18 | or incorporates Python or any part thereof, and wants to make 19 | the derivative work available to others as provided herein, then 20 | Licensee hereby agrees to include in any such work a brief summary of 21 | the changes made to Python. 22 | 23 | 4. PSF is making Python available to Licensee on an "AS IS" 24 | basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 25 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND 26 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 27 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT 28 | INFRINGE ANY THIRD PARTY RIGHTS. 29 | 30 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 31 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 32 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, 33 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 34 | 35 | 6. This License Agreement will automatically terminate upon a material 36 | breach of its terms and conditions. 37 | 38 | 7. Nothing in this License Agreement shall be deemed to create any 39 | relationship of agency, partnership, or joint venture between PSF and 40 | Licensee. This License Agreement does not grant permission to use PSF 41 | trademarks or trade name in a trademark sense to endorse or promote 42 | products or services of Licensee, or any third party. 43 | 44 | 8. By copying, installing or otherwise using Python, Licensee 45 | agrees to be bound by the terms and conditions of this License 46 | Agreement. 47 | -------------------------------------------------------------------------------- /tests/test_pep514utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import winreg 3 | 4 | from manage import pep514utils 5 | from manage import tagutils 6 | 7 | def test_is_tag_managed(registry, tmp_path): 8 | registry.setup(Company={ 9 | "1.0": {"InstallPath": {"": str(tmp_path)}}, 10 | "2.0": {"InstallPath": {"": str(tmp_path)}, "ManagedByPyManager": 0}, 11 | "2.1": {"InstallPath": {"": str(tmp_path)}, "ManagedByPyManager": 1}, 12 | "3.0": {"InstallPath": {"": str(tmp_path / "missing")}}, 13 | "3.0.0": {"": "Just in the way here"}, 14 | "3.0.1": {"": "Also in the way here"}, 15 | }) 16 | 17 | assert not pep514utils._is_tag_managed(registry.key, r"Company\1.0") 18 | assert not pep514utils._is_tag_managed(registry.key, r"Company\2.0") 19 | assert pep514utils._is_tag_managed(registry.key, r"Company\2.1") 20 | 21 | assert not pep514utils._is_tag_managed(registry.key, r"Company\3.0") 22 | with pytest.raises(FileNotFoundError): 23 | winreg.OpenKey(registry.key, r"Company\3.0.2") 24 | assert pep514utils._is_tag_managed(registry.key, r"Company\3.0", creating=True) 25 | with winreg.OpenKey(registry.key, r"Company\3.0.2"): 26 | pass 27 | 28 | 29 | def test_is_tag_managed_warning_suppressed(registry, tmp_path, assert_log): 30 | registry.setup(Company={ 31 | "3.0.0": {"": "Just in the way here"}, 32 | "3.0.1": {"": "Also in the way here"}, 33 | }) 34 | pep514utils.update_registry( 35 | rf"HKEY_CURRENT_USER\{registry.root}", 36 | dict(company="Company", tag="3.0.0"), 37 | dict(kind="pep514", Key="Company\\3.0.0", InstallPath=dict(_="dir")), 38 | warn_for=[tagutils.tag_or_range(r"Company\3.0.1")], 39 | ) 40 | assert_log( 41 | "Registry key %s appears invalid.+", 42 | assert_log.not_logged("An existing runtime is registered at %s"), 43 | ) 44 | 45 | 46 | def test_is_tag_managed_warning(registry, tmp_path, assert_log): 47 | registry.setup(Company={ 48 | "3.0.0": {"": "Just in the way here"}, 49 | "3.0.1": {"": "Also in the way here"}, 50 | }) 51 | pep514utils.update_registry( 52 | rf"HKEY_CURRENT_USER\{registry.root}", 53 | dict(company="Company", tag="3.0.0"), 54 | dict(kind="pep514", Key="Company\\3.0.0", InstallPath=dict(_="dir")), 55 | warn_for=[tagutils.tag_or_range(r"Company\3.0.0")], 56 | ) 57 | assert_log( 58 | "Registry key %s appears invalid.+", 59 | assert_log.skip_until("An existing registry key for %s"), 60 | ) 61 | -------------------------------------------------------------------------------- /ci/repartition-index.yml: -------------------------------------------------------------------------------- 1 | # Repartitioning runs on Azure Pipelines, because that's where we have SSH 2 | # access to the download server. 3 | 4 | name: $(Date:yyyyMMdd).$(Rev:r) 5 | 6 | # Do not run automatically 7 | trigger: none 8 | 9 | 10 | parameters: 11 | - name: Publish 12 | displayName: "Publish" 13 | type: boolean 14 | default: false 15 | - name: TestPublish 16 | displayName: "Run all steps without publishing" 17 | type: boolean 18 | default: false 19 | 20 | stages: 21 | - stage: PyManagerIndexPartition 22 | displayName: 'Repartition PyManager Index' 23 | 24 | jobs: 25 | - job: Repartition 26 | 27 | pool: 28 | vmImage: 'windows-latest' 29 | 30 | variables: 31 | - group: PythonOrgPublish 32 | 33 | steps: 34 | - checkout: self 35 | 36 | - task: NugetToolInstaller@0 37 | displayName: 'Install Nuget' 38 | 39 | - powershell: | 40 | nuget install -o host_python -x -noninteractive -prerelease python 41 | Write-Host "##vso[task.prependpath]$(gi host_python\python\tools)" 42 | displayName: 'Install host Python' 43 | workingDirectory: $(Build.BinariesDirectory) 44 | 45 | - powershell: | 46 | cd (mkdir -Force index) 47 | python "$(Build.SourcesDirectory)\scripts\repartition-index.py" --windows-default 48 | # Show the report 49 | cat index-windows.txt 50 | displayName: 'Repartition index' 51 | workingDirectory: $(Build.BinariesDirectory) 52 | 53 | - publish: $(Build.BinariesDirectory)\index 54 | artifact: index 55 | displayName: Publish index artifact 56 | 57 | - ${{ if or(eq(parameters.Publish, 'true'), eq(parameters.TestPublish, 'true')) }}: 58 | - ${{ if ne(parameters.TestPublish, 'true') }}: 59 | - task: DownloadSecureFile@1 60 | name: sshkey 61 | inputs: 62 | secureFile: pydotorg-ssh.ppk 63 | displayName: 'Download PuTTY key' 64 | 65 | - powershell: | 66 | git clone https://github.com/python/cpython-bin-deps --branch putty --single-branch --depth 1 --progress -v "putty" 67 | "##vso[task.prependpath]$(gi putty)" 68 | workingDirectory: $(Pipeline.Workspace) 69 | displayName: 'Download PuTTY binaries' 70 | 71 | - powershell: | 72 | python ci\upload.py 73 | displayName: 'Publish packages' 74 | env: 75 | UPLOAD_URL: $(PyDotOrgUrlPrefix)python/ 76 | UPLOAD_DIR: $(Build.BinariesDirectory)\index 77 | UPLOAD_URL_PREFIX: $(PyDotOrgUrlPrefix) 78 | UPLOAD_PATH_PREFIX: $(PyDotOrgUploadPathPrefix) 79 | UPLOAD_HOST: $(PyDotOrgServer) 80 | UPLOAD_HOST_KEY: $(PyDotOrgHostKey) 81 | UPLOAD_USER: $(PyDotOrgUsername) 82 | UPLOADING_INDEX: true 83 | ${{ if eq(parameters.TestPublish, 'true') }}: 84 | NO_UPLOAD: 1 85 | ${{ else }}: 86 | UPLOAD_KEYFILE: $(sshkey.secureFilePath) 87 | -------------------------------------------------------------------------------- /src/manage/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class ArgumentError(Exception): 3 | def __init__(self, message): 4 | super().__init__(message) 5 | 6 | 7 | class HashMismatchError(Exception): 8 | def __init__(self, message=None): 9 | super().__init__(message or 10 | "The downloaded file could not be verified and has been deleted. Please try again.") 11 | 12 | 13 | class NoInstallsError(Exception): 14 | def __init__(self): 15 | super().__init__("""No runtimes are installed. Try running "py install default" first.""") 16 | 17 | 18 | class NoInstallFoundError(Exception): 19 | def __init__(self, tag=None, script=None): 20 | self.tag = tag 21 | self.script = script 22 | if script: 23 | msg = f"No runtime installed that can launch {script}" 24 | elif tag: 25 | msg = f"""No runtime installed that matches {tag}. Try running "py install {tag}".""" 26 | else: 27 | msg = """No suitable runtime installed. Try running "py install default".""" 28 | super().__init__(msg) 29 | 30 | 31 | class InvalidFeedError(Exception): 32 | def __init__(self, message=None, *, feed_url=None): 33 | from .urlutils import sanitise_url 34 | if feed_url: 35 | feed_url = sanitise_url(feed_url) 36 | if not message: 37 | if feed_url: 38 | message = f"There is an issue with the feed at {feed_url}. Please check your settings and try again." 39 | else: 40 | message = "There is an issue with the feed. Please check your settings and try again." 41 | super().__init__(message) 42 | self.feed_url = feed_url 43 | 44 | 45 | class InvalidInstallError(Exception): 46 | def __init__(self, message, prefix=None): 47 | super().__init__(message) 48 | self.prefix = prefix 49 | 50 | 51 | class InvalidConfigurationError(ValueError): 52 | def __init__(self, file=None, argument=None, value=None): 53 | if value: 54 | msg = f"Invalid configuration value {value!r} for key {argument} in {file}" 55 | elif argument: 56 | msg = f"Invalid configuration key {argument} in {file}" 57 | elif file: 58 | msg = f"Invalid configuration file {file}" 59 | else: 60 | msg = "Invalid configuration" 61 | super().__init__(msg) 62 | self.file = file 63 | self.argument = argument 64 | self.value = value 65 | 66 | 67 | class AutomaticInstallDisabledError(Exception): 68 | exitcode = 0xA0000006 # ERROR_AUTO_INSTALL_DISABLED 69 | 70 | def __init__(self): 71 | super().__init__("Automatic installation has been disabled. " 72 | 'Please run "py install" directly.') 73 | 74 | 75 | class FilesInUseError(Exception): 76 | def __init__(self, files): 77 | self.files = files 78 | 79 | 80 | class NoLauncherTemplateError(Exception): 81 | def __init__(self): 82 | super().__init__("No suitable launcher template was found.") 83 | -------------------------------------------------------------------------------- /_msbuild_test.py: -------------------------------------------------------------------------------- 1 | # Build script for test module. This is needed to run tests in-tree. 2 | # 3 | # python -m pymsbuild -c _msbuild_test.py 4 | # python -m pytest 5 | # 6 | from pymsbuild import * 7 | from pymsbuild.dllpack import * 8 | 9 | METADATA = { 10 | "Metadata-Version": "2.2", 11 | "Name": "manage", 12 | "Version": "1.0.0.0", 13 | "Author": "Steve Dower", 14 | "Author-email": "steve.dower@python.org", 15 | "Summary": "Test build", 16 | } 17 | 18 | 19 | PACKAGE = Package('src', 20 | DllPackage('_native_test', 21 | PyFile('__init__.py'), 22 | ItemDefinition("ClCompile", 23 | PreprocessorDefinitions=Prepend("ERROR_LOCATIONS=1;BITS_INJECT_ERROR=1;"), 24 | LanguageStandard="stdcpp20", 25 | ), 26 | IncludeFile('*.h'), 27 | CSourceFile('*.cpp'), 28 | CFunction('coinitialize'), 29 | CFunction('bits_connect'), 30 | CFunction('bits_begin'), 31 | CFunction('bits_cancel'), 32 | CFunction('bits_get_progress'), 33 | CFunction('bits_retry_with_auth'), 34 | CFunction('bits_find_job'), 35 | CFunction('bits_serialize_job'), 36 | CFunction('bits_inject_error'), # only in tests 37 | CFunction('winhttp_urlopen'), 38 | CFunction('winhttp_isconnected'), 39 | CFunction('winhttp_urlsplit'), 40 | CFunction('winhttp_urlunsplit'), 41 | CFunction('file_url_to_path'), 42 | CFunction('file_lock_for_delete'), 43 | CFunction('file_unlock_for_delete'), 44 | CFunction('file_locked_delete'), 45 | CFunction('package_get_root'), 46 | CFunction('shortcut_create'), 47 | CFunction('shortcut_default_cwd'), 48 | CFunction('shortcut_get_start_programs'), 49 | CFunction('hide_file'), 50 | CFunction('fd_supports_vt100'), 51 | CFunction('date_as_str'), 52 | CFunction('datetime_as_str'), 53 | CFunction('reg_rename_key'), 54 | CFunction('get_current_package'), 55 | CFunction('read_alias_package'), 56 | CFunction('broadcast_settings_change'), 57 | CFunction('get_processor_architecture'), 58 | source='src/_native', 59 | ), 60 | DllPackage('_shellext_test', 61 | PyFile('_native/__init__.py'), 62 | ItemDefinition('ClCompile', 63 | PreprocessorDefinitions=Prepend("PYSHELLEXT_TEST=1;"), 64 | LanguageStandard='stdcpp20', 65 | ), 66 | ItemDefinition('Link', AdditionalDependencies=Prepend("RuntimeObject.lib;pathcch.lib;")), 67 | CSourceFile('pyshellext/shellext.cpp'), 68 | CSourceFile('pyshellext/shellext_test.cpp'), 69 | IncludeFile('pyshellext/shellext.h'), 70 | CSourceFile('_native/helpers.cpp'), 71 | IncludeFile('_native/helpers.h'), 72 | CFunction('shellext_RegReadStr'), 73 | CFunction('shellext_ReadIdleInstalls'), 74 | CFunction('shellext_ReadAllIdleInstalls'), 75 | CFunction('shellext_PassthroughTitle'), 76 | CFunction('shellext_IdleCommand'), 77 | CFunction('shellext_GetDropArgumentsW'), 78 | CFunction('shellext_GetDropArgumentsA'), 79 | CFunction('shellext_GetDropDescription'), 80 | source='src', 81 | ) 82 | ) 83 | -------------------------------------------------------------------------------- /src/_native/paths.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "helpers.h" 7 | 8 | #pragma comment(lib, "Shlwapi.lib") 9 | 10 | extern "C" { 11 | 12 | PyObject * 13 | package_get_root(PyObject *, PyObject *, PyObject *) 14 | { 15 | // Assume current process is running in the package root 16 | wchar_t buffer[MAX_PATH]; 17 | DWORD cch = GetModuleFileName(NULL, buffer, MAX_PATH); 18 | if (!cch) { 19 | PyErr_SetFromWindowsErr(GetLastError()); 20 | return NULL; 21 | } 22 | while (cch > 0 && buffer[--cch] != L'\\') { } 23 | return PyUnicode_FromWideChar(buffer, cch); 24 | } 25 | 26 | 27 | PyObject * 28 | file_url_to_path(PyObject *, PyObject *args, PyObject *kwargs) 29 | { 30 | static const char * keywords[] = {"url", NULL}; 31 | wchar_t *url = NULL; 32 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&:file_url_to_path", keywords, 33 | as_utf16, &url)) { 34 | return NULL; 35 | } 36 | 37 | PyObject *r = NULL; 38 | wchar_t path[32768]; 39 | DWORD path_len = 32767; 40 | HRESULT hr = PathCreateFromUrlW(url, path, &path_len, 0); 41 | if (SUCCEEDED(hr)) { 42 | r = PyUnicode_FromWideChar(path, -1); 43 | } else { 44 | PyErr_SetFromWindowsErr(hr); 45 | } 46 | PyMem_Free(url); 47 | return r; 48 | } 49 | 50 | 51 | PyObject * 52 | file_lock_for_delete(PyObject *, PyObject *args, PyObject *kwargs) 53 | { 54 | static const char * keywords[] = {"path", NULL}; 55 | wchar_t *path = NULL; 56 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&:file_lock_for_delete", keywords, 57 | as_utf16, &path)) { 58 | return NULL; 59 | } 60 | 61 | HANDLE h = CreateFileW(path, FILE_GENERIC_WRITE | DELETE, 0, 62 | NULL, OPEN_EXISTING, 0, 0); 63 | if (h == INVALID_HANDLE_VALUE) { 64 | PyErr_SetFromWindowsErr(0); 65 | return NULL; 66 | } 67 | return PyLong_FromNativeBytes(&h, sizeof(h), -1); 68 | } 69 | 70 | 71 | PyObject * 72 | file_unlock_for_delete(PyObject *, PyObject *args, PyObject *kwargs) 73 | { 74 | static const char * keywords[] = {"handle", NULL}; 75 | PyObject *handle = NULL; 76 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O:file_unlock_for_delete", keywords, 77 | &handle)) { 78 | return NULL; 79 | } 80 | HANDLE h; 81 | if (PyLong_AsNativeBytes(handle, &h, sizeof(h), -1) < 0) { 82 | return NULL; 83 | } 84 | CloseHandle(h); 85 | return Py_GetConstant(Py_CONSTANT_NONE); 86 | } 87 | 88 | 89 | PyObject * 90 | file_locked_delete(PyObject *, PyObject *args, PyObject *kwargs) 91 | { 92 | static const char * keywords[] = {"handle", NULL}; 93 | PyObject *handle = NULL; 94 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O:file_locked_delete", keywords, 95 | &handle)) { 96 | return NULL; 97 | } 98 | HANDLE h; 99 | if (PyLong_AsNativeBytes(handle, &h, sizeof(h), -1) < 0) { 100 | return NULL; 101 | } 102 | DWORD cch = 0; 103 | std::wstring buf; 104 | cch = GetFinalPathNameByHandleW(h, NULL, 0, FILE_NAME_OPENED); 105 | if (cch) { 106 | buf.resize(cch); 107 | cch = GetFinalPathNameByHandleW(h, buf.data(), cch, FILE_NAME_OPENED); 108 | } 109 | if (!cch) { 110 | PyErr_SetFromWindowsErr(0); 111 | CloseHandle(h); 112 | return NULL; 113 | } 114 | CloseHandle(h); 115 | if (!DeleteFileW(buf.c_str())) { 116 | PyErr_SetFromWindowsErr(0); 117 | return NULL; 118 | } 119 | return Py_GetConstant(Py_CONSTANT_NONE); 120 | } 121 | 122 | 123 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Install Manager 2 | 3 | [![Codecov](https://codecov.io/gh/python/pymanager/graph/badge.svg)](https://codecov.io/gh/python/pymanager) 4 | 5 | This is the source code for the Python Install Manager app. 6 | 7 | For information about how to use the Python install manager, 8 | including troubleshooting steps, 9 | please refer to the documentation at 10 | [docs.python.org/using/windows](https://docs.python.org/3.14/using/windows.html). 11 | 12 | The original PEP leading to this tool was 13 | [PEP 773](https://peps.python.org/pep-0773/). 14 | 15 | 16 | # Build 17 | 18 | To build and run locally requires [`pymsbuild`](https://pypi.org/project/pymsbuild) 19 | and a Visual Studio installation that includes the C/C++ compilers. 20 | 21 | ``` 22 | > python -m pip install pymsbuild 23 | > python -m pymsbuild 24 | > python-manager\py.exe ... 25 | ``` 26 | 27 | Any modification to a source file requires rebuilding. 28 | The `.py` files are packaged into an extension module. 29 | However, see the following section on tests, as test runs do not require a full 30 | build. 31 | 32 | For additional output, set `%PYMANAGER_DEBUG%` to force debug-level output. 33 | This is the equivalent of passing `-vv`, though it also works for contexts that 34 | do not accept options (such as launching a runtime). 35 | 36 | # Tests 37 | 38 | To run the test suite locally: 39 | 40 | ``` 41 | > python -m pip install pymsbuild pytest 42 | > python -m pymsbuild -c _msbuild_test.py 43 | > python -m pytest 44 | ``` 45 | 46 | This builds the native components separately so that you can quickly iterate on 47 | the Python code. Any updates to the C++ files will require running the 48 | ``pymsbuild`` step again. 49 | 50 | # Package 51 | 52 | To produce an (almost) installer app package: 53 | 54 | ``` 55 | > python -m pip install pymsbuild 56 | > python make-all.py 57 | ``` 58 | 59 | This will rebuild the project and produce MSIX, APPXSYM and MSI packages. 60 | 61 | You will need to sign the MSIX package before you can install it. This can be a 62 | self-signed certificate, but it must be added to your Trusted Publishers. 63 | Alternatively, rename the file to ``.zip`` and extract it to a directory, and 64 | run ``Add-AppxPackage -Register \appxmanifest.xml`` to do a 65 | development install. This should add the global aliases and allow you to test 66 | as if it was properly installed. 67 | 68 | # Contributions 69 | 70 | Contributions are welcome under all the same conditions as for CPython, see 71 | the [Python Developer's Guide](https://devguide.python.org/) for more information. 72 | 73 | # Release Schedule 74 | 75 | The release manager for the Python Install Manager on Windows is whoever is the 76 | build manager for Windows for CPython. Currently, this is @zooba. 77 | 78 | Releases are made as needed, with prereleases made at the release manager's 79 | judgement. Due to the broad user base of PyManager, we have to avoid significant 80 | changes to its interface, which means feature development is heavily restricted. 81 | 82 | ## Versioning 83 | 84 | PyManager uses the two digit year as the first part of the version, 85 | with the second part incrementing for each release. 86 | This is to avoid any sense of features being tied to the version number, 87 | and to avoid any direct association with Python releases. 88 | 89 | The two digit year is used because MSI does not support major version fields 90 | over 256. If/when we completely drop the MSI, we could switch to four digit 91 | years, but as long as it exists we have to handle its compatibility constraints. 92 | 93 | 94 | # Copyright and License Information 95 | 96 | Copyright © 2025 Python Software Foundation. All rights reserved. 97 | 98 | See the [LICENSE](https://github.com/python/pymanager/blob/main/LICENSE) for 99 | information on the terms & conditions for usage, and a DISCLAIMER OF ALL 100 | WARRANTIES. 101 | 102 | All trademarks referenced herein are property of their respective holders. 103 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import secrets 3 | from manage import commands, logging 4 | from manage.exceptions import ArgumentError, NoInstallsError 5 | 6 | 7 | def test_pymanager_help_command(assert_log): 8 | cmd = commands.HelpCommand([commands.HelpCommand.CMD], None) 9 | cmd.execute() 10 | assert_log( 11 | assert_log.skip_until(r"Python installation manager \d+\.\d+.*"), 12 | assert_log.skip_until(".*pymanager-pytest exec -V.*"), 13 | assert_log.skip_until(".*pymanager-pytest exec -3.*"), 14 | assert_log.skip_until(".*pymanager-pytest install.*"), 15 | assert_log.skip_until(".*pymanager-pytest list.*"), 16 | assert_log.skip_until(".*pymanager-pytest uninstall.*"), 17 | ) 18 | 19 | 20 | def test_py_help_command(assert_log, monkeypatch): 21 | monkeypatch.setattr(commands, "EXE_NAME", "py") 22 | cmd = commands.HelpCommand([commands.HelpCommand.CMD], None) 23 | cmd.execute() 24 | assert_log( 25 | assert_log.skip_until(r"Python installation manager \d+\.\d+.*"), 26 | assert_log.skip_until(".*pymanager-pytest -V.*"), 27 | assert_log.skip_until(".*pymanager-pytest -3.*"), 28 | assert_log.skip_until(".*py install.*"), 29 | assert_log.skip_until(".*py list.*"), 30 | assert_log.skip_until(".*py uninstall.*"), 31 | ) 32 | 33 | 34 | def test_help_with_error_command(assert_log): 35 | expect = secrets.token_hex(16) 36 | cmd = commands.HelpWithErrorCommand( 37 | [commands.HelpWithErrorCommand.CMD, expect, "-v", "-q"], 38 | None 39 | ) 40 | cmd.execute() 41 | assert_log( 42 | assert_log.skip_until(f".*Unknown command: pymanager-pytest {expect} -v -q.*"), 43 | r"Python installation manager \d+\.\d+.*", 44 | assert_log.skip_until(f"The command .*?pymanager-pytest {expect} -v -q.*"), 45 | ) 46 | 47 | 48 | def test_exec_with_literal_default(): 49 | cmd = commands.load_default_config(None) 50 | try: 51 | assert cmd.get_install_to_run("default", None) 52 | except ValueError: 53 | # This is our failure case! 54 | raise 55 | except NoInstallsError: 56 | # This is also an okay result, if the default runtime isn't installed 57 | pass 58 | 59 | 60 | def test_legacy_list_command(assert_log, patched_installs): 61 | cmd = commands.ListLegacyCommand(["--list"]) 62 | cmd.show_welcome() 63 | cmd.execute() 64 | assert_log( 65 | # Ensure welcome message is suppressed 66 | assert_log.not_logged(r"Python installation manager.+"), 67 | # Should have a range of values shown, we don't care too much 68 | assert_log.skip_until(r" -V:2\.0\[-64\]\s+Python.*"), 69 | assert_log.skip_until(r" -V:3\.0a1-32\s+Python.*"), 70 | ) 71 | 72 | 73 | def test_legacy_list_command_args(): 74 | with pytest.raises(ArgumentError): 75 | commands.ListLegacyCommand(["--list", "--help"]) 76 | 77 | 78 | def test_legacy_listpaths_command_help(assert_log, patched_installs): 79 | cmd = commands.ListPathsLegacyCommand(["--list-paths"]) 80 | cmd.help() 81 | assert_log( 82 | assert_log.skip_until(r".*List command.+py list.+"), 83 | ) 84 | 85 | 86 | def test_install_command_args(): 87 | # This is not meant to be exhaustive test of every possible option, but 88 | # should cover all of the code paths in BaseCommand.__init__. 89 | for args in [ 90 | ["-v", "-y"], 91 | ["--v", "--y"], 92 | ["/v", "/y"], 93 | ]: 94 | cmd = commands.InstallCommand(args) 95 | assert cmd.log_level == logging.VERBOSE 96 | assert not cmd.confirm 97 | 98 | for args in [ 99 | ["--log", "C:\\LOG.txt"], 100 | ["/log", "C:\\LOG.txt"], 101 | ["--log:C:\\LOG.txt"], 102 | ["--log=C:\\LOG.txt"], 103 | ]: 104 | cmd = commands.InstallCommand(args) 105 | assert cmd.log_file == "C:\\LOG.txt" 106 | -------------------------------------------------------------------------------- /tests/test_arputils.py: -------------------------------------------------------------------------------- 1 | import winreg 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from manage import arputils 7 | from manage.pathutils import Path 8 | 9 | 10 | def test_size_empty_directory(tmp_path): 11 | result = arputils._size(tmp_path) 12 | assert result == 0 13 | 14 | 15 | def test_size_with_files(tmp_path): 16 | (tmp_path / "file1.txt").write_bytes(b"x" * 1024) 17 | (tmp_path / "file2.txt").write_bytes(b"y" * 2048) 18 | 19 | result = arputils._size(tmp_path) 20 | assert result == 3 21 | 22 | 23 | def test_size_ignores_oserror(tmp_path): 24 | (tmp_path / "file.txt").write_bytes(b"test") 25 | 26 | with mock.patch("manage.arputils.rglob") as mock_rglob: 27 | mock_file = mock.Mock() 28 | mock_file.lstat.side_effect = OSError("Access denied") 29 | mock_rglob.return_value = [mock_file] 30 | 31 | result = arputils._size(tmp_path) 32 | assert result == 0 33 | 34 | 35 | def test_set_int_value(): 36 | mock_key = mock.Mock() 37 | 38 | with mock.patch("winreg.SetValueEx") as mock_set: 39 | arputils._set_value(mock_key, "TestInt", 42) 40 | mock_set.assert_called_once_with( 41 | mock_key, "TestInt", None, winreg.REG_DWORD, 42 42 | ) 43 | 44 | 45 | def test_set_string_value(): 46 | mock_key = mock.Mock() 47 | 48 | with mock.patch("winreg.SetValueEx") as mock_set: 49 | arputils._set_value(mock_key, "TestStr", "hello") 50 | mock_set.assert_called_once_with( 51 | mock_key, "TestStr", None, winreg.REG_SZ, "hello" 52 | ) 53 | 54 | 55 | def test_set_path_value_converts_to_string(): 56 | mock_key = mock.Mock() 57 | test_path = Path("C:/test/path") 58 | 59 | with mock.patch("winreg.SetValueEx") as mock_set: 60 | arputils._set_value(mock_key, "TestPath", test_path) 61 | mock_set.assert_called_once() 62 | assert isinstance(mock_set.call_args[0][4], str) 63 | 64 | 65 | def test_self_cmd_uses_cache(): 66 | arputils._self_cmd_cache = Path("C:/cached/pymanager.exe") 67 | 68 | result = arputils._self_cmd() 69 | assert result == Path("C:/cached/pymanager.exe") 70 | 71 | arputils._self_cmd_cache = None 72 | 73 | 74 | def test_self_cmd_raises_when_not_found(monkeypatch, tmp_path): 75 | arputils._self_cmd_cache = None 76 | 77 | monkeypatch.setenv("LocalAppData", str(tmp_path)) 78 | 79 | windows_apps = tmp_path / "Microsoft" / "WindowsApps" 80 | windows_apps.mkdir(parents=True) 81 | 82 | with mock.patch.dict("sys.modules", {"_winapi": None}): 83 | with pytest.raises(FileNotFoundError, match="Cannot determine uninstall command"): 84 | arputils._self_cmd() 85 | 86 | arputils._self_cmd_cache = None 87 | 88 | 89 | def test_iter_keys_with_none(): 90 | result = list(arputils._iter_keys(None)) 91 | assert result == [] 92 | 93 | 94 | def test_iter_keys_stops_on_oserror(): 95 | mock_key = mock.Mock() 96 | 97 | with mock.patch("winreg.EnumKey") as mock_enum: 98 | mock_enum.side_effect = ["key1", OSError()] 99 | 100 | result = list(arputils._iter_keys(mock_key)) 101 | assert result == ["key1"] 102 | 103 | 104 | def test_delete_key_retries_on_permission_error(): 105 | mock_key = mock.Mock() 106 | 107 | with mock.patch("winreg.DeleteKey") as mock_delete: 108 | with mock.patch("time.sleep"): 109 | mock_delete.side_effect = [ 110 | PermissionError(), 111 | PermissionError(), 112 | None 113 | ] 114 | 115 | arputils._delete_key(mock_key, "test_key") 116 | 117 | assert mock_delete.call_count == 3 118 | 119 | 120 | def test_delete_key_ignores_filenotfound(): 121 | mock_key = mock.Mock() 122 | 123 | with mock.patch("winreg.DeleteKey") as mock_delete: 124 | mock_delete.side_effect = FileNotFoundError() 125 | 126 | arputils._delete_key(mock_key, "test_key") 127 | -------------------------------------------------------------------------------- /src/manage/__init__.py: -------------------------------------------------------------------------------- 1 | from .exceptions import ( 2 | ArgumentError, 3 | AutomaticInstallDisabledError, 4 | NoInstallFoundError, 5 | NoInstallsError, 6 | ) 7 | from .logging import LOGGER 8 | 9 | try: 10 | from ._version import __version__ 11 | except ImportError: 12 | __version__ = "0.0" 13 | 14 | 15 | # Will be overwritten by main.cpp on import 16 | EXE_NAME = "py" 17 | 18 | def _set_exe_name(name): 19 | global EXE_NAME 20 | EXE_NAME = name 21 | 22 | 23 | __all__ = ["main", "NoInstallFoundError", "NoInstallsError", "find_one"] 24 | 25 | 26 | def main(args, root=None): 27 | cmd = None 28 | delete_log = None 29 | try: 30 | from .commands import find_command, show_help 31 | args = list(args) 32 | if not root: 33 | from .pathutils import Path 34 | root = Path(args[0]).parent 35 | 36 | try: 37 | cmd = find_command(args[1:], root) 38 | except LookupError as ex: 39 | raise ArgumentError("Unrecognized command") from ex 40 | 41 | if cmd.show_help: 42 | cmd.help() 43 | return 0 44 | 45 | log_file = cmd.get_log_file() 46 | if log_file: 47 | LOGGER.file = open(log_file, "w", encoding="utf-8", errors="replace") 48 | LOGGER.verbose("Writing logs to %s", log_file) 49 | 50 | cmd.execute() 51 | if not cmd.keep_log: 52 | delete_log = log_file 53 | except AutomaticInstallDisabledError as ex: 54 | LOGGER.error("%s", ex) 55 | return ex.exitcode 56 | except ArgumentError as ex: 57 | if cmd: 58 | cmd.help() 59 | else: 60 | show_help(args[1:2]) 61 | LOGGER.error("%s", ex) 62 | return 1 63 | except Exception as ex: 64 | LOGGER.error("INTERNAL ERROR: %s: %s", type(ex).__name__, ex) 65 | LOGGER.debug("TRACEBACK:", exc_info=True) 66 | return getattr(ex, "winerror", 0) or getattr(ex, "errno", 1) 67 | except SystemExit as ex: 68 | LOGGER.debug("SILENCED ERROR", exc_info=True) 69 | return ex.code 70 | finally: 71 | f, LOGGER.file = LOGGER.file, None 72 | if f: 73 | f.flush() 74 | f.close() 75 | if delete_log: 76 | try: 77 | delete_log.unlink() 78 | except OSError: 79 | pass 80 | return 0 81 | 82 | 83 | def find_one(root, tag, script, windowed, allow_autoinstall, show_not_found_error): 84 | autoinstall_permitted = False 85 | try: 86 | from .commands import load_default_config 87 | from .scriptutils import quote_args 88 | i = None 89 | cmd = load_default_config(root) 90 | autoinstall_permitted = cmd.automatic_install 91 | LOGGER.debug("Finding runtime for '%s' or '%s'%s", tag, script, " (windowed)" if windowed else "") 92 | try: 93 | i = cmd.get_install_to_run(tag, script, windowed=windowed) 94 | except NoInstallsError: 95 | # We always allow autoinstall when there are no runtimes at all 96 | # (Noting that user preference may still prevent it) 97 | allow_autoinstall = True 98 | raise 99 | exe = str(i["executable"]) 100 | args = quote_args(i.get("executable_args", ())) 101 | LOGGER.debug("Selected %s %s", exe, args) 102 | return exe, args 103 | except (NoInstallFoundError, NoInstallsError) as ex: 104 | if not autoinstall_permitted or not allow_autoinstall: 105 | LOGGER.error("%s", ex) 106 | raise AutomaticInstallDisabledError() from ex 107 | if show_not_found_error: 108 | LOGGER.error("%s", ex) 109 | LOGGER.debug("TRACEBACK:", exc_info=True) 110 | raise 111 | except Exception as ex: 112 | LOGGER.error("INTERNAL ERROR: %s: %s", type(ex).__name__, ex) 113 | LOGGER.debug("TRACEBACK:", exc_info=True) 114 | raise 115 | except SystemExit: 116 | LOGGER.debug("SILENCED ERROR", exc_info=True) 117 | raise 118 | -------------------------------------------------------------------------------- /src/_native/helpers.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "helpers.h" 5 | 6 | int as_utf16(PyObject *obj, wchar_t **address) { 7 | if (obj == NULL) { 8 | // Automatic cleanup 9 | PyMem_Free(*address); 10 | return 1; 11 | } 12 | if (!PyObject_IsTrue(obj)) { 13 | if (Py_Is(obj, Py_GetConstantBorrowed(Py_CONSTANT_NONE))) { 14 | *address = NULL; 15 | return 1; 16 | } 17 | } 18 | PyObject *wobj = PyObject_Str(obj); 19 | if (!wobj) { 20 | return 0; 21 | } 22 | PyObject *b = PyObject_CallMethod(wobj, "encode", "ss", "utf-16-le", "strict"); 23 | Py_DECREF(wobj); 24 | if (!b) { 25 | return 0; 26 | } 27 | char *src; 28 | Py_ssize_t len; 29 | if (PyBytes_AsStringAndSize(b, &src, &len) < 0) { 30 | Py_DECREF(b); 31 | return 0; 32 | } 33 | Py_ssize_t wlen = len / sizeof(wchar_t); 34 | wchar_t *result = (wchar_t *)PyMem_Malloc((wlen + 1) * sizeof(wchar_t)); 35 | if (!result) { 36 | Py_DECREF(b); 37 | PyErr_NoMemory(); 38 | return 0; 39 | } 40 | wcsncpy_s(result, wlen + 1, (wchar_t *)src, wlen); 41 | Py_DECREF(b); 42 | *address = result; 43 | return Py_CLEANUP_SUPPORTED; 44 | } 45 | 46 | 47 | void err_SetFromWindowsErrWithMessage(int error, const char *message, const wchar_t *os_message, void *hModule) { 48 | LPWSTR os_message_buffer = NULL; 49 | PyObject *cause = NULL; 50 | if (PyErr_Occurred()) { 51 | cause = PyErr_GetRaisedException(); 52 | } 53 | 54 | if ((error & 0xFFFF0000) == 0x80070000) { 55 | // Error code is an HRESULT containing a regular Windows error 56 | error &= 0xFFFF; 57 | } 58 | if (!hModule && error >= 12000 && error <= 12184) { 59 | // Error codes are from WinHTTP, which means we need a module 60 | hModule = GetModuleHandleW(L"winhttp"); 61 | } 62 | 63 | if (!os_message) { 64 | DWORD len = FormatMessageW( 65 | /* Error API error */ 66 | FORMAT_MESSAGE_ALLOCATE_BUFFER 67 | | FORMAT_MESSAGE_FROM_SYSTEM 68 | | (hModule ? FORMAT_MESSAGE_FROM_HMODULE : 0) 69 | | FORMAT_MESSAGE_IGNORE_INSERTS, 70 | hModule, 71 | error, 72 | MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), 73 | (LPWSTR)&os_message_buffer, 74 | 0, 75 | NULL 76 | ); 77 | if (len) { 78 | while (len > 0 && isspace(os_message_buffer[--len])) { 79 | os_message_buffer[len] = L'\0'; 80 | } 81 | os_message = os_message_buffer; 82 | } 83 | } 84 | 85 | PyObject *msg; 86 | if (message && os_message) { 87 | msg = PyUnicode_FromFormat("%s: %ls", message, os_message); 88 | } else if (os_message) { 89 | msg = PyUnicode_FromWideChar(os_message, -1); 90 | } else if (message) { 91 | msg = PyUnicode_FromString(message); 92 | } else { 93 | msg = PyUnicode_FromString("Unknown error"); 94 | } 95 | 96 | if (msg) { 97 | // Hacky way to get OSError without a direct data reference 98 | // This allows us to delay load the Python DLL 99 | PyObject *builtins = PyEval_GetFrameBuiltins(); 100 | PyObject *oserr = builtins ? PyDict_GetItemString(builtins, "OSError") : NULL; 101 | if (oserr) { 102 | PyObject *exc_args = Py_BuildValue( 103 | "(iOOiO)", 104 | (int)0, 105 | msg, 106 | Py_GetConstantBorrowed(Py_CONSTANT_NONE), 107 | error, 108 | Py_GetConstantBorrowed(Py_CONSTANT_NONE) 109 | ); 110 | if (exc_args) { 111 | PyErr_SetObject(oserr, exc_args); 112 | Py_DECREF(exc_args); 113 | } 114 | } 115 | Py_XDECREF(builtins); 116 | Py_DECREF(msg); 117 | } 118 | 119 | if (os_message_buffer) { 120 | LocalFree((void *)os_message_buffer); 121 | } 122 | 123 | if (cause) { 124 | // References are all stolen here, so no DECREF required 125 | PyObject *chained = PyErr_GetRaisedException(); 126 | PyException_SetContext(chained, cause); 127 | PyErr_SetRaisedException(chained); 128 | } 129 | } 130 | 131 | -------------------------------------------------------------------------------- /tests/test_shellext.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sys 3 | import winreg 4 | 5 | import _shellext_test as SE 6 | 7 | def test_RegReadStr(): 8 | with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Volatile Environment") as key: 9 | assert SE.shellext_RegReadStr(key.handle, "USERPROFILE") 10 | with pytest.raises(FileNotFoundError): 11 | assert SE.shellext_RegReadStr(key.handle, "a made up name that hopefully doesn't exist") 12 | with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key: 13 | # PATH should be REG_EXPAND_SZ, which is not supported 14 | with pytest.raises(OSError) as ex: 15 | SE.shellext_RegReadStr(key.handle, "PATH") 16 | assert ex.value.winerror == 13 17 | 18 | 19 | class IdleReg: 20 | def __init__(self, registry, tmp_path): 21 | self.registry = registry 22 | self.hkey = registry.key.handle 23 | self.tmp_path = tmp_path 24 | python_exe = tmp_path / "python.exe" 25 | idle_pyw = tmp_path / "Lib/idlelib/idle.pyw" 26 | self.python_exe = str(python_exe) 27 | self.idle_pyw = str(idle_pyw) 28 | 29 | python_exe.parent.mkdir(parents=True, exist_ok=True) 30 | idle_pyw.parent.mkdir(parents=True, exist_ok=True) 31 | python_exe.write_bytes(b"") 32 | idle_pyw.write_bytes(b"") 33 | 34 | registry.setup( 35 | PythonCore={ 36 | "1.0": { 37 | "DisplayName": "PythonCore-1.0", 38 | "InstallPath": { 39 | "": str(tmp_path), 40 | } 41 | }, 42 | }, 43 | # Even if all the pieces are there, we won't pick up non-PythonCore 44 | # unless they specify IdlePath 45 | NotPythonCore={ 46 | "1.0": { 47 | "DisplayName": "NotPythonCore-1.0", 48 | "InstallPath": { 49 | "": str(tmp_path), 50 | } 51 | }, 52 | "2.0": { 53 | "DisplayName": "NotPythonCore-2.0", 54 | "InstallPath": { 55 | "": str(tmp_path), 56 | "WindowedExecutablePath": str(python_exe), 57 | "IdlePath": str(idle_pyw), 58 | } 59 | }, 60 | }, 61 | ) 62 | 63 | self.pythoncore_1_0 = ("PythonCore-1.0", self.python_exe, self.idle_pyw) 64 | self.pythoncore = [self.pythoncore_1_0] 65 | # NotPythonCore-1.0 should never get returned 66 | self.notpythoncore_1_0 = ("NotPythonCore-1.0", self.python_exe, self.idle_pyw) 67 | self.notpythoncore_2_0 = ("NotPythonCore-2.0", self.python_exe, self.idle_pyw) 68 | self.notpythoncore = [self.notpythoncore_2_0] 69 | self.all = [*self.notpythoncore, *self.pythoncore] 70 | 71 | 72 | @pytest.fixture(scope='function') 73 | def idle_reg(registry, tmp_path): 74 | return IdleReg(registry, tmp_path) 75 | 76 | 77 | def test_ReadIdleInstalls(idle_reg): 78 | inst = SE.shellext_ReadIdleInstalls(idle_reg.hkey, "PythonCore", 0) 79 | assert inst == idle_reg.pythoncore 80 | inst = SE.shellext_ReadIdleInstalls(idle_reg.hkey, "NotPythonCore", 0) 81 | assert inst == idle_reg.notpythoncore 82 | 83 | 84 | def test_ReadAllIdleInstalls(idle_reg): 85 | inst = SE.shellext_ReadAllIdleInstalls(idle_reg.hkey, 0) 86 | assert inst == [ 87 | *idle_reg.notpythoncore, 88 | *idle_reg.pythoncore, 89 | ] 90 | 91 | 92 | def test_PassthroughTitle(): 93 | assert "Test" == SE.shellext_PassthroughTitle("Test") 94 | assert "Test \u0ABC" == SE.shellext_PassthroughTitle("Test \u0ABC") 95 | 96 | 97 | def test_IdleCommand(idle_reg): 98 | data = SE.shellext_IdleCommand(idle_reg.hkey) 99 | assert data == [ 100 | "Edit in &IDLE", 101 | f"{sys._base_executable},-4", 102 | *(i[0] for i in reversed(idle_reg.all)), 103 | ] 104 | 105 | 106 | def test_DragDropDescription(): 107 | assert ("Open with %1", "test.exe") == SE.shellext_GetDropDescription( 108 | r"C:\Fake\Path\test.exe", 0 109 | ) 110 | 111 | 112 | def test_GetDropArgumentsW(): 113 | actual = SE.shellext_GetDropArgumentsW("arg 1\0arg2\0arg 3\0\0".encode("utf-16-le")) 114 | assert actual == '"arg 1" arg2 "arg 3"' 115 | 116 | 117 | def test_GetDropArgumentsA(): 118 | actual = SE.shellext_GetDropArgumentsA("arg 1\0arg2\0arg 3\0\0".encode("ascii")) 119 | assert actual == '"arg 1" arg2 "arg 3"' 120 | -------------------------------------------------------------------------------- /make-msix.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import zipfile 4 | 5 | from pathlib import Path 6 | from subprocess import check_call as run 7 | 8 | from _make_helper import ( 9 | copyfile, 10 | copytree, 11 | get_dirs, 12 | get_msix_version, 13 | get_output_name, 14 | get_sdk_bins, 15 | rmtree, 16 | unlink, 17 | ) 18 | 19 | SDK_BINS = get_sdk_bins() 20 | 21 | MAKEAPPX = SDK_BINS / "makeappx.exe" 22 | MAKEPRI = SDK_BINS / "makepri.exe" 23 | 24 | for tool in [MAKEAPPX, MAKEPRI]: 25 | if not tool.is_file(): 26 | print("Unable to locate Windows Kit tool", tool.name, file=sys.stderr) 27 | sys.exit(3) 28 | 29 | DIRS = get_dirs() 30 | BUILD = DIRS["build"] 31 | TEMP = DIRS["temp"] 32 | LAYOUT = DIRS["out"] 33 | LAYOUT2 = TEMP / "store-layout" 34 | SRC = DIRS["src"] 35 | DIST = DIRS["dist"] 36 | 37 | # Calculate output names (must be after building) 38 | NAME = get_output_name(DIRS) 39 | VERSION = get_msix_version(DIRS) 40 | DIST_MSIX = DIST / f"{NAME}.msix" 41 | DIST_STORE_MSIX = DIST / f"{NAME}-store.msix" 42 | DIST_APPXSYM = DIST / f"{NAME}-store.appxsym" 43 | DIST_MSIXUPLOAD = DIST / f"{NAME}-store.msixupload" 44 | 45 | unlink(DIST_MSIX, DIST_STORE_MSIX, DIST_APPXSYM, DIST_MSIXUPLOAD) 46 | 47 | # Generate resources info in LAYOUT 48 | if not (LAYOUT / "_resources.pri").is_file(): 49 | run([MAKEPRI, "new", "/o", 50 | "/pr", LAYOUT, 51 | "/cf", SRC / "pymanager/resources.xml", 52 | "/of", LAYOUT / "_resources.pri", 53 | "/mf", "appx"]) 54 | 55 | # Clean up non-shipping files from LAYOUT 56 | preserved = [ 57 | *LAYOUT.glob("pyshellext*.dll"), 58 | ] 59 | 60 | for f in preserved: 61 | print("Preserving", f, "as", TEMP / f.name) 62 | copyfile(f, TEMP / f.name) 63 | 64 | unlink( 65 | *LAYOUT.rglob("*.pdb"), 66 | *LAYOUT.rglob("*.pyc"), 67 | *LAYOUT.rglob("__pycache__"), 68 | *preserved, 69 | ) 70 | 71 | # Package into DIST 72 | run([MAKEAPPX, "pack", "/o", "/d", LAYOUT, "/p", DIST_MSIX]) 73 | 74 | print("Copying appinstaller file to", DIST) 75 | copyfile(LAYOUT / "pymanager.appinstaller", DIST / "pymanager.appinstaller") 76 | 77 | 78 | if os.getenv("PYMANAGER_APPX_STORE_PUBLISHER"): 79 | # Clone and update layout for Store build 80 | rmtree(LAYOUT2) 81 | copytree(LAYOUT, LAYOUT2) 82 | unlink(*LAYOUT2.glob("*.appinstaller")) 83 | 84 | def patch_appx(source): 85 | from xml.etree import ElementTree as ET 86 | NS = {} 87 | with open(source, "r", encoding="utf-8") as f: 88 | NS = dict(e for _, e in ET.iterparse(f, events=("start-ns",))) 89 | for k, v in NS.items(): 90 | ET.register_namespace(k, v) 91 | NS["x"] = NS[""] 92 | 93 | with open(source, "r", encoding="utf-8") as f: 94 | xml = ET.parse(f) 95 | 96 | identity = xml.find("x:Identity", NS) 97 | identity.set("Publisher", os.getenv("PYMANAGER_APPX_STORE_PUBLISHER")) 98 | p = xml.find("x:Properties", NS) 99 | e = p.find("uap13:AutoUpdate", NS) 100 | p.remove(e) 101 | e = p.find(f"uap17:UpdateWhileInUse", NS) 102 | p.remove(e) 103 | 104 | with open(source, "wb") as f: 105 | xml.write(f, "utf-8") 106 | 107 | # We need to remove unused namespaces from IgnorableNamespaces. 108 | # The easiest way to get this right is to read the file back in, see 109 | # which namespaces were silently left out by etree, and remove those. 110 | with open(source, "r", encoding="utf-8") as f: 111 | NS = dict(e for _, e in ET.iterparse(f, events=("start-ns",))) 112 | with open(source, "r", encoding="utf-8") as f: 113 | xml = ET.parse(f) 114 | p = xml.getroot() 115 | p.set("IgnorableNamespaces", " ".join(s for s in p.get("IgnorableNamespaces").split() if s in NS)) 116 | with open(source, "wb") as f: 117 | xml.write(f, "utf-8") 118 | 119 | patch_appx(LAYOUT2 / "appxmanifest.xml") 120 | 121 | run([MAKEAPPX, "pack", "/o", "/d", LAYOUT2, "/p", DIST_STORE_MSIX]) 122 | 123 | # Pack symbols 124 | print("Packing symbols to", DIST_APPXSYM) 125 | with zipfile.ZipFile(DIST_APPXSYM, "w") as zf: 126 | for f in BUILD.rglob("*.pdb"): 127 | zf.write(f, arcname=f.name) 128 | 129 | # Pack upload MSIX for Store 130 | print("Packing Store upload to", DIST_MSIXUPLOAD) 131 | with zipfile.ZipFile(DIST_MSIXUPLOAD, "w") as zf: 132 | zf.write(DIST_STORE_MSIX, arcname=DIST_STORE_MSIX.name) 133 | zf.write(DIST_APPXSYM, arcname=DIST_APPXSYM.name) 134 | 135 | 136 | for f in preserved: 137 | print("Restoring", f, "from", TEMP / f.name) 138 | copyfile(TEMP / f.name, f) 139 | unlink(TEMP / f.name) 140 | -------------------------------------------------------------------------------- /src/pymanager/_launch.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | 6 | static BOOL WINAPI 7 | ctrl_c_handler(DWORD code) 8 | { 9 | // just ignore control events 10 | return TRUE; 11 | } 12 | 13 | 14 | static int 15 | dup_handle(HANDLE input, HANDLE *output) 16 | { 17 | static HANDLE self = NULL; 18 | if (self == NULL) { 19 | self = GetCurrentProcess(); 20 | } 21 | if (input == NULL || input == INVALID_HANDLE_VALUE) { 22 | *output = input; 23 | return 0; 24 | } 25 | if (!DuplicateHandle(self, input, self, output, 0, TRUE, DUPLICATE_SAME_ACCESS)) { 26 | if (GetLastError() == ERROR_INVALID_HANDLE) { 27 | *output = NULL; 28 | return 0; 29 | } 30 | return HRESULT_FROM_WIN32(GetLastError()); 31 | } 32 | return 0; 33 | } 34 | 35 | 36 | int 37 | launch( 38 | const wchar_t *executable, 39 | const wchar_t *orig_cmd_line, 40 | const wchar_t *insert_args, 41 | int skip_argc, 42 | DWORD *exit_code 43 | ) { 44 | HANDLE job; 45 | JOBOBJECT_EXTENDED_LIMIT_INFORMATION info; 46 | DWORD info_len; 47 | STARTUPINFOW si; 48 | PROCESS_INFORMATION pi; 49 | int lastError = 0; 50 | const wchar_t *cmd_line = NULL; 51 | 52 | if (orig_cmd_line[0] == L'"') { 53 | cmd_line = wcschr(orig_cmd_line + 1, L'"'); 54 | } else { 55 | cmd_line = wcschr(orig_cmd_line, L' '); 56 | } 57 | 58 | size_t n = wcslen(executable) + wcslen(orig_cmd_line) + (insert_args ? wcslen(insert_args) : 0) + 6; 59 | wchar_t *new_cmd_line = (wchar_t *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, n * sizeof(wchar_t)); 60 | if (!new_cmd_line) { 61 | lastError = GetLastError(); 62 | goto exit; 63 | } 64 | 65 | // Skip any requested args, deliberately leaving any trailing spaces 66 | // (we'll skip one later on and add our own space, and preserve multiple) 67 | while (cmd_line && *cmd_line && skip_argc-- > 0) { 68 | wchar_t c; 69 | while (*++cmd_line && *cmd_line == L' ') { } 70 | while (*++cmd_line && *cmd_line != L' ') { } 71 | } 72 | 73 | swprintf_s(new_cmd_line, n, L"\"%s\"%s%s%s%s", 74 | executable, 75 | (insert_args && *insert_args) ? L" ": L"", 76 | (insert_args && *insert_args) ? insert_args : L"", 77 | (cmd_line && *cmd_line) ? L" " : L"", 78 | (cmd_line && *cmd_line) ? cmd_line + 1 : L""); 79 | 80 | #if defined(_WINDOWS) 81 | /* 82 | When explorer launches a Windows (GUI) application, it displays 83 | the "app starting" (the "pointer + hourglass") cursor for a number 84 | of seconds, or until the app does something UI-ish (eg, creating a 85 | window, or fetching a message). As this launcher doesn't do this 86 | directly, that cursor remains even after the child process does these 87 | things. We avoid that by doing a simple post+get message. 88 | See http://bugs.python.org/issue17290 89 | */ 90 | MSG msg; 91 | 92 | PostMessage(0, 0, 0, 0); 93 | GetMessage(&msg, 0, 0, 0); 94 | #endif 95 | 96 | job = CreateJobObject(NULL, NULL); 97 | if (!job 98 | || !QueryInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info), &info_len) 99 | || info_len != sizeof(info) 100 | ) { 101 | lastError = GetLastError(); 102 | goto exit; 103 | } 104 | info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | 105 | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK; 106 | if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info))) { 107 | lastError = GetLastError(); 108 | goto exit; 109 | } 110 | 111 | memset(&si, 0, sizeof(si)); 112 | GetStartupInfoW(&si); 113 | if ((lastError = dup_handle(GetStdHandle(STD_INPUT_HANDLE), &si.hStdInput)) 114 | || (lastError = dup_handle(GetStdHandle(STD_OUTPUT_HANDLE), &si.hStdOutput)) 115 | || (lastError = dup_handle(GetStdHandle(STD_ERROR_HANDLE), &si.hStdError)) 116 | ) { 117 | goto exit; 118 | } 119 | if (!SetConsoleCtrlHandler(ctrl_c_handler, TRUE)) { 120 | lastError = GetLastError(); 121 | goto exit; 122 | } 123 | 124 | si.dwFlags |= STARTF_USESTDHANDLES; 125 | if (!CreateProcessW(executable, new_cmd_line, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) { 126 | lastError = GetLastError(); 127 | goto exit; 128 | } 129 | 130 | AssignProcessToJobObject(job, pi.hProcess); 131 | CloseHandle(pi.hThread); 132 | WaitForSingleObjectEx(pi.hProcess, INFINITE, FALSE); 133 | if (!GetExitCodeProcess(pi.hProcess, exit_code)) { 134 | lastError = GetLastError(); 135 | } 136 | exit: 137 | if (new_cmd_line) { 138 | HeapFree(GetProcessHeap(), 0, new_cmd_line); 139 | } 140 | return lastError ? HRESULT_FROM_WIN32(lastError) : 0; 141 | } 142 | -------------------------------------------------------------------------------- /src/manage/verutils.py: -------------------------------------------------------------------------------- 1 | from .logging import LOGGER 2 | 3 | 4 | class Version: 5 | TEXT_MAP = { 6 | "*": 0, 7 | "dev": 1, 8 | "a": 2, 9 | "b": 3, 10 | "c": 4, 11 | "rc": 4, 12 | "": 1000, 13 | } 14 | 15 | _TEXT_UNMAP = {v: k for k, v in TEXT_MAP.items()} 16 | _LEVELS = None 17 | 18 | # Versions with more fields than this will be truncated. 19 | MAX_FIELDS = 8 20 | 21 | def __init__(self, s): 22 | import re 23 | if isinstance(s, Version): 24 | s = s.s 25 | if not Version._LEVELS: 26 | Version._LEVELS = "|".join(re.escape(k) for k in self.TEXT_MAP if k) 27 | m = re.match( 28 | r"^(?P\d+(\.\d+)*)([\.\-]?(?P" + Version._LEVELS + r")[\.]?(?P\d*))?$", 29 | s, 30 | re.I, 31 | ) 32 | if not m: 33 | raise ValueError("Failed to parse version %s", s) 34 | bits = [int(v) for v in m.group("numbers").split(".")] 35 | try: 36 | dev = self.TEXT_MAP[(m.group("level") or "").lower()] 37 | except LookupError: 38 | dev = 0 39 | LOGGER.warn("Version %s has invalid development level specified which will be ignored", s) 40 | self.s = s 41 | if len(bits) > self.MAX_FIELDS: 42 | LOGGER.warn("Version %s is too long and will be truncated to %s for ordering purposes", 43 | s, ".".join(map(str, bits[:self.MAX_FIELDS]))) 44 | self.sortkey = ( 45 | *bits[:self.MAX_FIELDS], 46 | *([0] * (self.MAX_FIELDS - len(bits))), 47 | len(bits), # for sort stability 48 | dev, 49 | int(m.group("serial") or 0) 50 | ) 51 | self.prefix_match = dev == self.TEXT_MAP["*"] 52 | self.prerelease_match = dev == self.TEXT_MAP["dev"] 53 | 54 | def __str__(self): 55 | return self.s 56 | 57 | def __repr__(self): 58 | return self.s 59 | 60 | def __hash__(self): 61 | return hash(self.sortkey) 62 | 63 | def _are_equal(self, other, prefix_match=None, other_prefix_match=None, prerelease_match=None): 64 | if other is None: 65 | return False 66 | if isinstance(other, str): 67 | return self.s.casefold() == other.casefold() 68 | if not isinstance(other, type(self)): 69 | return False 70 | if self.sortkey == other.sortkey: 71 | return True 72 | if prefix_match is not None and prefix_match or self.prefix_match: 73 | if (self.sortkey[-3] <= other.sortkey[-3] 74 | and self.sortkey[:self.sortkey[-3]] == other.sortkey[:self.sortkey[-3]]): 75 | return True 76 | elif other_prefix_match is not None and other_prefix_match or other.prefix_match: 77 | if (self.sortkey[-3] >= other.sortkey[-3] 78 | and self.sortkey[:other.sortkey[-3]] == other.sortkey[:other.sortkey[-3]]): 79 | return True 80 | if prerelease_match is not None and prerelease_match or self.prerelease_match: 81 | if self.sortkey[:-3] == other.sortkey[:-3]: 82 | return True 83 | return False 84 | 85 | def startswith(self, other): 86 | return self._are_equal(other, other_prefix_match=True) 87 | 88 | def above_lower_bound(self, other): 89 | if other is None: 90 | return True 91 | if self.sortkey[:other.sortkey[-3]] > other.sortkey[:other.sortkey[-3]]: 92 | return True 93 | return False 94 | 95 | def below_upper_bound(self, other): 96 | if other is None: 97 | return True 98 | if self.sortkey[:other.sortkey[-3]] < other.sortkey[:other.sortkey[-3]]: 99 | return True 100 | return False 101 | 102 | def __eq__(self, other): 103 | return self._are_equal(other) 104 | 105 | def __gt__(self, other): 106 | if other is None: 107 | return True 108 | if isinstance(other, str): 109 | other = type(self)(other) 110 | return self.sortkey > other.sortkey 111 | 112 | def __lt__(self, other): 113 | if other is None: 114 | return False 115 | if isinstance(other, str): 116 | other = type(self)(other) 117 | return self.sortkey < other.sortkey 118 | 119 | def __le__(self, other): 120 | return self < other or self == other 121 | 122 | def __ge__(self, other): 123 | return self > other or self == other 124 | 125 | @property 126 | def is_prerelease(self): 127 | return self.sortkey[-2] < self.TEXT_MAP[""] 128 | 129 | def to_python_style(self, n=3, with_dev=True): 130 | v = ".".join(str(i) for i in self.sortkey[:min(n, self.MAX_FIELDS)]) 131 | if with_dev: 132 | try: 133 | dev = self._TEXT_UNMAP[self.sortkey[-2]] 134 | if dev: 135 | v += f"{dev}{self.sortkey[-1]}" 136 | except LookupError: 137 | pass 138 | return v 139 | -------------------------------------------------------------------------------- /_make_helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | import sys 5 | import time 6 | import winreg 7 | 8 | from pathlib import Path 9 | 10 | def get_msbuild(): 11 | exe = os.getenv("MSBUILD", "") 12 | if exe: 13 | if Path(exe).is_file(): 14 | return [exe] 15 | return _split_args(exe) 16 | 17 | for part in os.getenv("PATH", "").split(os.path.pathsep): 18 | p = Path(part) 19 | if p.is_dir(): 20 | exe = p / "msbuild.exe" 21 | if exe.is_file(): 22 | return [str(exe)] 23 | 24 | vswhere = Path(os.getenv("ProgramFiles(x86)"), "Microsoft Visual Studio", "Installer", "vswhere.exe") 25 | if vswhere.is_file(): 26 | out = Path(subprocess.check_output([ 27 | str(vswhere), 28 | "-nologo", 29 | "-property", "installationPath", 30 | "-latest", 31 | "-prerelease", 32 | "-products", "*", 33 | "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", 34 | "-utf8", 35 | ], encoding="utf-8", errors="strict").strip()) 36 | if out.is_dir(): 37 | exe = out / "MSBuild" / "Current" / "Bin" / "msbuild.exe" 38 | if exe.is_file(): 39 | return [str(exe)] 40 | 41 | raise FileNotFoundError("msbuild.exe") 42 | 43 | 44 | def get_sdk_bins(): 45 | sdk = os.getenv("WindowsSdkDir") 46 | if not sdk: 47 | with winreg.OpenKey( 48 | winreg.HKEY_LOCAL_MACHINE, 49 | r"SOFTWARE\Microsoft\Windows Kits\Installed Roots", 50 | access=winreg.KEY_READ | winreg.KEY_WOW64_32KEY, 51 | ) as key: 52 | sdk, keytype = winreg.QueryValueEx(key, "KitsRoot10") 53 | 54 | if keytype != winreg.REG_SZ: 55 | print("Unexpected registry value for Windows Kits root.", file=sys.stderr) 56 | print("Try setting %WindowsSdkDir%", file=sys.stderr) 57 | sys.exit(1) 58 | 59 | sdk = Path(sdk) 60 | 61 | sdk_ver = os.getenv("WindowsSDKVersion", "10.*") 62 | 63 | bins = list((sdk / "bin").glob(sdk_ver))[-1] / "x64" 64 | if not bins.is_dir(): 65 | print("Unable to locate Windows Kits binaries.", file=sys.stderr) 66 | sys.exit(2) 67 | 68 | return bins 69 | 70 | 71 | def _envpath_or(var, default): 72 | p = os.getenv(var) 73 | if p: 74 | return Path(p) 75 | return default 76 | 77 | 78 | def get_dirs(): 79 | root = Path.cwd() 80 | src = root / "src" 81 | dist = _envpath_or("PYMSBUILD_DIST_DIR", root / "dist") 82 | _temp = _envpath_or("PYMSBUILD_TEMP_DIR", Path.cwd() / "build") 83 | build = _temp / "bin" 84 | temp = _temp / "temp" 85 | _layout = _envpath_or("PYMSBUILD_LAYOUT_DIR", None) 86 | if not _layout: 87 | _layout = _temp / "layout" 88 | os.environ["PYMSBUILD_LAYOUT_DIR"] = str(_layout) 89 | out = _layout / "python-manager" 90 | 91 | return dict( 92 | root=root, 93 | out=out, 94 | src=src, 95 | dist=dist, 96 | build=build, 97 | temp=temp, 98 | ) 99 | 100 | 101 | def get_msix_version(dirs): 102 | from io import StringIO 103 | from xml.etree import ElementTree as ET 104 | appx = (dirs["out"] / "appxmanifest.xml").read_text("utf-8") 105 | NS = dict(e for _, e in ET.iterparse(StringIO(appx), events=("start-ns",))) 106 | for k, v in NS.items(): 107 | ET.register_namespace(k, v) 108 | xml = ET.parse(StringIO(appx)) 109 | identity = xml.find(f"x:Identity", {"x": NS[""]}) 110 | return identity.attrib['Version'] 111 | 112 | 113 | def get_output_name(dirs): 114 | with open(dirs["out"] / "version.txt", "r", encoding="utf-8") as f: 115 | version = f.read().strip() 116 | return f"python-manager-{version}" 117 | 118 | 119 | copyfile = shutil.copyfile 120 | copytree = shutil.copytree 121 | 122 | 123 | def rmtree(path): 124 | print("Removing", path) 125 | if not path.is_dir(): 126 | return 127 | try: 128 | shutil.rmtree(path) 129 | except OSError: 130 | time.sleep(1.0) 131 | shutil.rmtree(path) 132 | 133 | 134 | def unlink(*paths): 135 | for p in paths: 136 | try: 137 | print("Removing", p) 138 | try: 139 | p.unlink() 140 | except IsADirectoryError: 141 | rmtree(p) 142 | except FileNotFoundError: 143 | pass 144 | except OSError as ex: 145 | print("Failed to remove", p, ex) 146 | 147 | 148 | def download_zip_into(url, path): 149 | from urllib.request import urlretrieve 150 | import zipfile 151 | 152 | path.mkdir(exist_ok=True, parents=True) 153 | name = url.rpartition("/")[-1] 154 | if not name.casefold().endswith(".zip".casefold()): 155 | name += ".zip" 156 | zip_file = path.parent / name 157 | if not zip_file.exists(): 158 | print("Downloading from", url) 159 | urlretrieve(url, zip_file) 160 | print("Extracting", zip_file) 161 | with zipfile.ZipFile(zip_file) as zf: 162 | prefix = os.path.commonprefix(zf.namelist()) 163 | for m in zf.infolist(): 164 | fn = m.filename.removeprefix(prefix).lstrip("/\\") 165 | if not fn or fn.endswith(("/", "\\")): 166 | continue 167 | dest = path / fn 168 | assert dest.relative_to(path) 169 | dest.parent.mkdir(exist_ok=True, parents=True) 170 | with open(dest, "wb") as f: 171 | f.write(zf.read(m)) 172 | -------------------------------------------------------------------------------- /src/_native/shortcut.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include "helpers.h" 7 | 8 | extern "C" { 9 | 10 | PyObject * 11 | shortcut_create(PyObject *, PyObject *args, PyObject *kwargs) 12 | { 13 | static const char *keywords[] = { 14 | "path", "target", "arguments", "working_directory", 15 | "icon", "icon_index", 16 | NULL 17 | }; 18 | wchar_t *path = NULL; 19 | wchar_t *target = NULL; 20 | wchar_t *arguments = NULL; 21 | wchar_t *workingDirectory = NULL; 22 | wchar_t *iconPath = NULL; 23 | int iconIndex = 0; 24 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, 25 | "O&O&|O&O&O&i:shortcut_create", keywords, 26 | as_utf16, &path, as_utf16, &target, as_utf16, &arguments, as_utf16, &workingDirectory, 27 | as_utf16, &iconPath, &iconIndex)) { 28 | return NULL; 29 | } 30 | 31 | PyObject *r = NULL; 32 | IShellLinkW *lnk = NULL; 33 | IPersistFile *persist = NULL; 34 | HRESULT hr; 35 | 36 | hr = CoCreateInstance(CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER, 37 | IID_IShellLinkW, (void **)&lnk); 38 | 39 | if (FAILED(hr)) { 40 | err_SetFromWindowsErrWithMessage(hr, "Creating system shortcut"); 41 | goto done; 42 | } 43 | if (FAILED(hr = lnk->SetPath(target))) { 44 | err_SetFromWindowsErrWithMessage(hr, "Setting shortcut target"); 45 | goto done; 46 | } 47 | if (arguments && *arguments && FAILED(hr = lnk->SetArguments(arguments))) { 48 | err_SetFromWindowsErrWithMessage(hr, "Setting shortcut arguments"); 49 | goto done; 50 | } 51 | if (workingDirectory && *workingDirectory && FAILED(hr = lnk->SetWorkingDirectory(workingDirectory))) { 52 | err_SetFromWindowsErrWithMessage(hr, "Setting shortcut working directory"); 53 | goto done; 54 | } 55 | if (iconPath && *iconPath && FAILED(hr = lnk->SetIconLocation(iconPath, iconIndex))) { 56 | err_SetFromWindowsErrWithMessage(hr, "Setting shortcut icon"); 57 | goto done; 58 | } 59 | if (FAILED(hr = lnk->QueryInterface(&persist))) { 60 | err_SetFromWindowsErrWithMessage(hr, "Getting persist interface"); 61 | goto done; 62 | } 63 | // gh-15: Apparently ERROR_USER_MAPPED_FILE can occur here, which suggests 64 | // contention on the file. We should be able to sleep and retry to handle it 65 | // in most cases. 66 | for (int retries = 5; retries; --retries) { 67 | if (SUCCEEDED(hr = persist->Save(path, 0))) { 68 | break; 69 | } 70 | if (retries == 1 || hr != HRESULT_FROM_WIN32(ERROR_USER_MAPPED_FILE)) { 71 | err_SetFromWindowsErrWithMessage(hr, "Writing shortcut"); 72 | goto done; 73 | } 74 | Sleep(10); 75 | } 76 | 77 | r = Py_NewRef(Py_None); 78 | 79 | done: 80 | if (persist) { 81 | persist->Release(); 82 | } 83 | if (lnk) { 84 | lnk->Release(); 85 | } 86 | if (path) PyMem_Free(path); 87 | if (target) PyMem_Free(target); 88 | if (arguments) PyMem_Free(arguments); 89 | if (workingDirectory) PyMem_Free(workingDirectory); 90 | if (iconPath) PyMem_Free(iconPath); 91 | return r; 92 | } 93 | 94 | 95 | PyObject * 96 | shortcut_get_start_programs(PyObject *, PyObject *, PyObject *) 97 | { 98 | wchar_t *path; 99 | HRESULT hr = SHGetKnownFolderPath( 100 | FOLDERID_Programs, 101 | KF_FLAG_NO_PACKAGE_REDIRECTION | KF_FLAG_CREATE, 102 | NULL, 103 | &path 104 | ); 105 | if (FAILED(hr)) { 106 | err_SetFromWindowsErrWithMessage(hr, "Obtaining Start Menu location"); 107 | return NULL; 108 | } 109 | PyObject *r = PyUnicode_FromWideChar(path, -1); 110 | CoTaskMemFree(path); 111 | return r; 112 | } 113 | 114 | 115 | PyObject * 116 | shortcut_default_cwd(PyObject *, PyObject *, PyObject *) 117 | { 118 | static const KNOWNFOLDERID fids[] = { FOLDERID_Documents, FOLDERID_Profile }; 119 | for (auto i = std::begin(fids); i != std::end(fids); ++i) { 120 | wchar_t *path; 121 | if (SUCCEEDED(SHGetKnownFolderPath( 122 | *i, 123 | KF_FLAG_NO_PACKAGE_REDIRECTION, 124 | NULL, 125 | &path 126 | ))) { 127 | PyObject *r = PyUnicode_FromWideChar(path, -1); 128 | CoTaskMemFree(path); 129 | return r; 130 | } 131 | } 132 | return Py_GetConstant(Py_CONSTANT_NONE); 133 | } 134 | 135 | 136 | PyObject * 137 | hide_file(PyObject *, PyObject *args, PyObject *kwargs) 138 | { 139 | static const char *keywords[] = {"path", "hidden", NULL}; 140 | wchar_t *path; 141 | int hidden = 1; 142 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&|b:hide_file", keywords, as_utf16, &path, &hidden)) { 143 | return NULL; 144 | } 145 | PyObject *r = NULL; 146 | DWORD attr = GetFileAttributesW(path); 147 | if (attr == INVALID_FILE_ATTRIBUTES) { 148 | err_SetFromWindowsErrWithMessage(GetLastError(), "Reading file attributes"); 149 | goto done; 150 | } 151 | if (hidden) { 152 | attr |= FILE_ATTRIBUTE_HIDDEN; 153 | } else { 154 | attr &= ~FILE_ATTRIBUTE_HIDDEN; 155 | } 156 | if (!SetFileAttributesW(path, attr)) { 157 | err_SetFromWindowsErrWithMessage(GetLastError(), "Setting file attributes"); 158 | goto done; 159 | } 160 | 161 | r = Py_NewRef(Py_None); 162 | 163 | done: 164 | PyMem_Free(path); 165 | return r; 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /src/manage/startutils.py: -------------------------------------------------------------------------------- 1 | import _native 2 | 3 | from .fsutils import rmtree, unlink 4 | from .logging import LOGGER 5 | from .pathutils import Path 6 | from .tagutils import install_matches_any 7 | 8 | 9 | def _unprefix(p, prefix): 10 | if p is None: 11 | return None 12 | if p.startswith("%PREFIX%"): 13 | return prefix / p[8:] 14 | if p.startswith('"%PREFIX%'): 15 | p1, sep, p2 = p[9:].partition('"') 16 | if sep == '"': 17 | return f'"{prefix / p1}"{p2}' 18 | return prefix / p[9:] 19 | if p.startswith("%WINDIR%"): 20 | import os 21 | windir = os.getenv("WINDIR") 22 | if windir: 23 | return Path(windir) / p[8:] 24 | # If the variable is missing, we should be able to rely on PATH 25 | return p[8:] 26 | return p 27 | 28 | 29 | def _make(root, prefix, item, allow_warn=True): 30 | n = item["Name"] 31 | try: 32 | _make_directory(root, n, prefix, item["Items"]) 33 | return 34 | except LookupError: 35 | pass 36 | 37 | lnk = root / (n + ".lnk") 38 | target = _unprefix(item["Target"], prefix) 39 | LOGGER.debug("Creating shortcut %s to %s", lnk, target) 40 | try: 41 | lnk.relative_to(root) 42 | except ValueError: 43 | if allow_warn: 44 | LOGGER.warn("Package attempted to create shortcut outside of its directory") 45 | else: 46 | LOGGER.debug("Package attempted to create shortcut outside of its directory") 47 | LOGGER.debug("Path: %s", lnk) 48 | LOGGER.debug("Directory: %s", root) 49 | return None 50 | _native.shortcut_create( 51 | lnk, 52 | target, 53 | arguments=_unprefix(item.get("Arguments"), prefix), 54 | working_directory=_unprefix(item.get("WorkingDirectory"), prefix) 55 | or _native.shortcut_default_cwd(), 56 | icon=_unprefix(item.get("Icon"), prefix), 57 | icon_index=item.get("IconIndex", 0), 58 | ) 59 | return lnk 60 | 61 | 62 | def _make_directory(root, name, prefix, items): 63 | cleanup_dir = True 64 | subdir = root / name 65 | try: 66 | subdir.mkdir(parents=True, exist_ok=False) 67 | except FileExistsError: 68 | cleanup_dir = False 69 | 70 | cleanup_items = [] 71 | try: 72 | for i in items: 73 | cleanup_items.append(_make(subdir, prefix, i)) 74 | except Exception: 75 | if cleanup_dir: 76 | rmtree(subdir) 77 | else: 78 | for i in cleanup_items: 79 | if i: 80 | unlink(i) 81 | raise 82 | 83 | ini = subdir / "pymanager.ini" 84 | with open(ini, "a", encoding="utf-8") as f: 85 | for i in cleanup_items: 86 | if i: 87 | try: 88 | print(i.relative_to(subdir), file=f) 89 | except ValueError: 90 | LOGGER.warn("Package attempted to create shortcut outside of its directory") 91 | LOGGER.debug("Path: %s", i) 92 | LOGGER.debug("Directory: %s", subdir) 93 | _native.hide_file(ini, True) 94 | 95 | return subdir 96 | 97 | 98 | def _cleanup(root, keep): 99 | if root in keep: 100 | return 101 | 102 | ini = root / "pymanager.ini" 103 | try: 104 | with open(ini, "r", encoding="utf-8-sig") as f: 105 | files = {root / s.strip() for s in f if s.strip()} 106 | except FileNotFoundError: 107 | return 108 | _native.hide_file(ini, False) 109 | unlink(ini) 110 | 111 | retained = [] 112 | for f in files: 113 | if f in keep: 114 | retained.append(f) 115 | continue 116 | LOGGER.debug("Removing %s", f) 117 | try: 118 | unlink(f) 119 | except IsADirectoryError: 120 | _cleanup(f, keep) 121 | 122 | if retained: 123 | with open(ini, "w", encoding="utf-8") as f: 124 | for i in retained: 125 | try: 126 | print(i.relative_to(root), file=f) 127 | except ValueError: 128 | LOGGER.debug("Ignoring file outside of current directory %s", i) 129 | _native.hide_file(ini, True) 130 | elif not any(root.iterdir()): 131 | LOGGER.debug("Removing %s", root) 132 | rmtree(root) 133 | 134 | 135 | def _get_to_keep(keep, root, item): 136 | keep.add(root / item["Name"]) 137 | for i in item.get("Items", ()): 138 | try: 139 | _get_to_keep(keep, root / item["Name"], i) 140 | except LookupError: 141 | pass 142 | 143 | 144 | def create_one(root, install, shortcut, warn_for=[]): 145 | root = Path(_native.shortcut_get_start_programs()) / root 146 | _make(root, install["prefix"], shortcut, allow_warn=install_matches_any(install, warn_for)) 147 | 148 | 149 | def cleanup(root, preserve, warn_for=[]): 150 | root = Path(_native.shortcut_get_start_programs()) / root 151 | 152 | if not root.is_dir(): 153 | if root.is_file(): 154 | unlink(root) 155 | return 156 | 157 | keep = set() 158 | for item in preserve: 159 | _get_to_keep(keep, root, item) 160 | 161 | LOGGER.debug("Cleaning up Start menu shortcuts") 162 | for item in keep: 163 | try: 164 | LOGGER.debug("Except: %s", item.relative_to(root)) 165 | except ValueError: 166 | LOGGER.debug("Except: %s", item) 167 | 168 | for entry in root.iterdir(): 169 | _cleanup(entry, keep) 170 | 171 | if not any(root.iterdir()): 172 | LOGGER.debug("Removing %s", root) 173 | rmtree(root) 174 | -------------------------------------------------------------------------------- /src/manage/arputils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import winreg 4 | 5 | from .fsutils import rglob 6 | from .logging import LOGGER 7 | from .pathutils import Path 8 | from .tagutils import install_matches_any 9 | 10 | 11 | def _root(): 12 | return winreg.CreateKey( 13 | winreg.HKEY_CURRENT_USER, 14 | r"Software\Microsoft\Windows\CurrentVersion\Uninstall" 15 | ) 16 | 17 | _self_cmd_cache = None 18 | 19 | 20 | def _self_cmd(): 21 | global _self_cmd_cache 22 | if _self_cmd_cache: 23 | return _self_cmd_cache 24 | appdata = os.getenv("LocalAppData") 25 | if not appdata: 26 | appdata = os.path.expanduser(r"~\AppData\Local") 27 | apps = Path(appdata) / r"Microsoft\WindowsApps" 28 | LOGGER.debug("Searching %s for pymanager.exe for ARP entries", apps) 29 | for d in apps.iterdir(): 30 | if not d.match("PythonSoftwareFoundation.PythonManager_*"): 31 | continue 32 | cmd = d / "pymanager.exe" 33 | LOGGER.debug("Checking %s", cmd) 34 | if cmd.exists(): 35 | _self_cmd_cache = cmd 36 | return cmd 37 | try: 38 | import _winapi 39 | except ImportError: 40 | pass 41 | else: 42 | return _winapi.GetModuleFileName(0) 43 | raise FileNotFoundError("Cannot determine uninstall command.") 44 | 45 | 46 | def _size(root): 47 | total = 0 48 | for f in rglob(root, dirs=False, files=True): 49 | try: 50 | total += f.lstat().st_size 51 | except OSError: 52 | pass 53 | return total // 1024 54 | 55 | 56 | # Mainly refactored out for patching during tests 57 | def _set_value(key, name, value): 58 | if isinstance(value, int): 59 | winreg.SetValueEx(key, name, None, winreg.REG_DWORD, value) 60 | else: 61 | winreg.SetValueEx(key, name, None, winreg.REG_SZ, str(value)) 62 | 63 | 64 | def _make(key, item, shortcut, *, allow_warn=True): 65 | prefix = Path(item["prefix"]) 66 | 67 | for value, from_dict, value_name, relative_to in [ 68 | (1, None, "ManagedByPyManager", None), 69 | (1, None, "NoModify", None), 70 | (1, None, "NoRepair", None), 71 | ("prefix", item, "InstallLocation", None), 72 | ("executable", item, "DisplayIcon", prefix), 73 | ("display-name", item, "DisplayName", None), 74 | ("company", item, "Publisher", None), 75 | ("tag", item, "DisplayVersion", None), 76 | ("DisplayIcon", shortcut, "DisplayIcon", prefix), 77 | ("DisplayName", shortcut, "DisplayName", None), 78 | ("Publisher", shortcut, "Publisher", None), 79 | ("DisplayVersion", shortcut, "DisplayVersion", None), 80 | ("HelpLink", shortcut, "HelpLink", None), 81 | ]: 82 | if from_dict is not None: 83 | try: 84 | value = from_dict[value] 85 | except LookupError: 86 | continue 87 | if relative_to: 88 | value = relative_to / value 89 | _set_value(key, value_name, value) 90 | 91 | try: 92 | from _native import date_as_str 93 | _set_value(key, "InstallDate", date_as_str()) 94 | except Exception: 95 | LOGGER.debug("Unexpected error writing InstallDate", exc_info=True) 96 | try: 97 | _set_value(key, "EstimatedSize", _size(prefix)) 98 | except Exception: 99 | LOGGER.debug("Unexpected error writing EstimatedSize", exc_info=True) 100 | 101 | item_id = item["id"] 102 | _set_value(key, "UninstallString", f'"{_self_cmd()}" uninstall --yes --by-id "{item_id}"') 103 | 104 | 105 | def _delete_key(key, name): 106 | for retries in range(5): 107 | try: 108 | winreg.DeleteKey(key, name) 109 | break 110 | except PermissionError: 111 | time.sleep(0.01) 112 | except FileNotFoundError: 113 | pass 114 | except OSError as ex: 115 | LOGGER.debug("Unexpected error deleting registry key %s: %s", name, ex) 116 | 117 | def _iter_keys(key): 118 | if not key: 119 | return 120 | for i in range(0, 32768): 121 | try: 122 | yield winreg.EnumKey(key, i) 123 | except OSError: 124 | return 125 | 126 | 127 | def create_one(install, shortcut, warn_for=[]): 128 | allow_warn = install_matches_any(install, warn_for) 129 | with _root() as root: 130 | install_id = f"pymanager-{install['id']}" 131 | LOGGER.debug("Creating ARP entry for %s", install_id) 132 | try: 133 | with winreg.CreateKey(root, install_id) as key: 134 | _make(key, install, shortcut, allow_warn=allow_warn) 135 | except OSError: 136 | LOGGER.debug("Failed to create entry for %s", install_id) 137 | LOGGER.debug("TRACEBACK:", exc_info=True) 138 | _delete_key(root, install_id) 139 | raise 140 | 141 | 142 | def cleanup(preserve_installs, warn_for=[]): 143 | keep = {f"pymanager-{i['id']}".casefold() for i in preserve_installs} 144 | to_delete = [] 145 | with _root() as root: 146 | for key in _iter_keys(root): 147 | if not key.startswith("pymanager-") or key.casefold() in keep: 148 | continue 149 | try: 150 | with winreg.OpenKey(root, key) as subkey: 151 | if winreg.QueryValueEx(subkey, "ManagedByPyManager")[0]: 152 | to_delete.append(key) 153 | except FileNotFoundError: 154 | pass 155 | except OSError: 156 | LOGGER.verbose("Failed to clean up entry for %s", key) 157 | LOGGER.debug("TRACEBACK:", exc_info=True) 158 | for key in to_delete: 159 | try: 160 | LOGGER.debug("Removing ARP registration for %s", key) 161 | _delete_key(root, key) 162 | except FileNotFoundError: 163 | pass 164 | except OSError: 165 | LOGGER.verbose("Failed to clean up entry for %s", key) 166 | LOGGER.debug("TRACEBACK:", exc_info=True) 167 | -------------------------------------------------------------------------------- /tests/test_tagutils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from manage.tagutils import CompanyTag, TagRange 4 | 5 | 6 | @pytest.mark.parametrize("tag_str", [ 7 | "3.13", "3.13-32", "3.13-arm64", 8 | "cpython/3.13", "PythonCore\\3.13", 9 | "\\Tag", 10 | ]) 11 | def test_core_tag(tag_str): 12 | tag = CompanyTag(tag_str) 13 | 14 | assert tag.is_core 15 | 16 | @pytest.mark.parametrize("tag_str", [ 17 | "Company/Tag", "Company\\Tag", "Company/" 18 | ]) 19 | def test_company_tag(tag_str): 20 | tag = CompanyTag(tag_str) 21 | 22 | assert not tag.is_core 23 | 24 | 25 | def test_tag_equality(): 26 | assert CompanyTag("3.13") == CompanyTag("PythonCore\\3.13") 27 | assert CompanyTag("3.13") != CompanyTag("Company\\3.13") 28 | assert CompanyTag("3.13.0") != CompanyTag("3.13") 29 | 30 | 31 | def test_tag_match(): 32 | assert not CompanyTag("3.13").match(CompanyTag("Company\\3.13")) 33 | assert CompanyTag("3.13").match(CompanyTag("3.13")) 34 | assert CompanyTag("3.13.0").match(CompanyTag("3.13")) 35 | assert CompanyTag("3.13.2").match(CompanyTag("3.13")) 36 | assert not CompanyTag("3.13").match(CompanyTag("3.13.0")) 37 | assert not CompanyTag("3.13").match(CompanyTag("3.13.2")) 38 | 39 | assert CompanyTag("PythonCore\\3.13").match(CompanyTag("", "")) 40 | assert CompanyTag("PythonCore\\4.56").match(CompanyTag("", "")) 41 | assert CompanyTag("PythonCore\\3.13").match(CompanyTag("", "3")) 42 | assert not CompanyTag("PythonCore\\4.56").match(CompanyTag("", "3")) 43 | 44 | 45 | def test_tag_platform_match(): 46 | assert CompanyTag("3.10-64").match(CompanyTag("3.10")) 47 | assert CompanyTag("3.10-64").match(CompanyTag("3.10-64")) 48 | assert not CompanyTag("3.10").match(CompanyTag("3.10-64")) 49 | assert not CompanyTag("3.10-arm64").match(CompanyTag("3.10-64")) 50 | 51 | 52 | def test_tag_order(): 53 | assert CompanyTag("3.13.2") < CompanyTag("3.13") 54 | assert CompanyTag("3.13.1") < CompanyTag("3.13.1-32") 55 | assert CompanyTag("3.13.1") < CompanyTag("3.13.1-arm64") 56 | assert CompanyTag("3.13.1") < CompanyTag("a3.13.1") 57 | assert CompanyTag("a3.13.1") < CompanyTag("b3.13.1") 58 | assert CompanyTag("3.13.1a") < CompanyTag("3.13.1b") 59 | assert CompanyTag("3.13.1a") < CompanyTag("3.13.1b") 60 | 61 | 62 | def test_tag_sort(): 63 | import random 64 | tags = list(map(CompanyTag, [ 65 | "3.11-64", "3.10.4", "3.10", "3.9", "3.9-32", 66 | "Company/Version10", "Company/Version9", 67 | "OtherCompany/3.9.2", "OtherCompany/3.9-32" 68 | ])) 69 | expected = list(tags) 70 | random.shuffle(tags) 71 | actual = sorted(tags) 72 | assert actual == expected, actual 73 | 74 | 75 | def test_simple_tag_range(): 76 | assert TagRange(">=3.10").satisfied_by(CompanyTag("3.10")) 77 | assert TagRange(">=3.10").satisfied_by(CompanyTag("3.10.1")) 78 | assert TagRange(">=3.10").satisfied_by(CompanyTag("3.11")) 79 | assert TagRange(">=3.10").satisfied_by(CompanyTag("4.0")) 80 | assert not TagRange(">=3.10").satisfied_by(CompanyTag("3.9")) 81 | assert not TagRange(">=3.10").satisfied_by(CompanyTag("2.0")) 82 | 83 | assert not TagRange(">3.10").satisfied_by(CompanyTag("3.9")) 84 | assert not TagRange(">3.10").satisfied_by(CompanyTag("3.10")) 85 | assert not TagRange(">3.10").satisfied_by(CompanyTag("3.10.0")) 86 | assert not TagRange(">3.10").satisfied_by(CompanyTag("3.10.1")) 87 | assert TagRange(">3.10").satisfied_by(CompanyTag("3.11")) 88 | 89 | assert TagRange("<=3.10").satisfied_by(CompanyTag("3.10")) 90 | assert TagRange("<=3.10").satisfied_by(CompanyTag("3.9")) 91 | assert TagRange("<=3.10").satisfied_by(CompanyTag("2.0")) 92 | assert not TagRange("<=3.10").satisfied_by(CompanyTag("3.11")) 93 | assert not TagRange("<=3.10").satisfied_by(CompanyTag("4.0")) 94 | 95 | assert not TagRange("<3.10").satisfied_by(CompanyTag("3.11")) 96 | assert not TagRange("<3.10").satisfied_by(CompanyTag("3.10")) 97 | assert not TagRange("<3.10").satisfied_by(CompanyTag("3.10.0")) 98 | assert not TagRange("<3.10").satisfied_by(CompanyTag("3.10.1")) 99 | assert TagRange("<3.10").satisfied_by(CompanyTag("3.9")) 100 | 101 | assert TagRange("=3.10").satisfied_by(CompanyTag("3.10")) 102 | assert TagRange("=3.10").satisfied_by(CompanyTag("3.10.1")) 103 | assert not TagRange("=3.10").satisfied_by(CompanyTag("3.9")) 104 | assert not TagRange("=3.10").satisfied_by(CompanyTag("3.11")) 105 | assert TagRange("~=3.10").satisfied_by(CompanyTag("3.10")) 106 | assert TagRange("~=3.10").satisfied_by(CompanyTag("3.10.1")) 107 | assert not TagRange("~=3.10").satisfied_by(CompanyTag("3.9")) 108 | assert not TagRange("~=3.10").satisfied_by(CompanyTag("3.11")) 109 | 110 | 111 | def test_tag_range_platforms(): 112 | assert TagRange(">=3.10-32").satisfied_by(CompanyTag("3.10-32")) 113 | assert TagRange(">=3.10-32").satisfied_by(CompanyTag("3.10.1-32")) 114 | assert TagRange(">=3.10").satisfied_by(CompanyTag("3.10-32")) 115 | assert not TagRange(">=3.10-32").satisfied_by(CompanyTag("3.10-64")) 116 | assert not TagRange(">=3.10-32").satisfied_by(CompanyTag("3.10.1")) 117 | 118 | 119 | def test_tag_range_suffixes(): 120 | assert TagRange(">=3.10").satisfied_by(CompanyTag("3.10-embed")) 121 | assert not TagRange(">=3.10-embed").satisfied_by(CompanyTag("3.10")) 122 | assert TagRange(">=3.10-embed").satisfied_by(CompanyTag("3.10-embed")) 123 | 124 | 125 | def test_tag_range_company(): 126 | assert TagRange(r">=Company\3.10").satisfied_by(CompanyTag("Company", "3.10")) 127 | assert TagRange(r">=Company\3.10").satisfied_by(CompanyTag("Company", "3.11")) 128 | assert not TagRange(r">=Company\3.10").satisfied_by(CompanyTag("Company", "3.9")) 129 | assert not TagRange(r">=Company\3.10").satisfied_by(CompanyTag("OtherCompany", "3.10")) 130 | 131 | assert TagRange("=Company\\").satisfied_by(CompanyTag("Company", "3.11")) 132 | 133 | 134 | def test_tag_concatenate(): 135 | assert CompanyTag("3.13") + "-64" == CompanyTag("3.13-64") 136 | assert CompanyTag("3.13-64") + "-64" == CompanyTag("3.13-64-64") 137 | assert CompanyTag("3.13-arm64") + "-64" == CompanyTag("3.13-arm64-64") 138 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 'Build and Test' 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | env: 6 | COVERAGE_CORE: sysmon 7 | FORCE_COLOR: 1 8 | PIP_DISABLE_PIP_VERSION_CHECK: true 9 | PIP_NO_INPUT: true 10 | PIP_PROGRESS_BAR: off 11 | PIP_REQUIRE_VIRTUALENV: false 12 | PIP_VERBOSE: true 13 | PYMSBUILD_VERBOSE: true 14 | 15 | 16 | jobs: 17 | build: 18 | runs-on: windows-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: 'Remove existing PyManager install' 24 | run: | 25 | # Ensure we aren't currently installed 26 | $msix = Get-AppxPackage PythonSoftwareFoundation.PythonManager -EA SilentlyContinue 27 | if ($msix) { 28 | "Removing $($msix.Name)" 29 | Remove-AppxPackage $msix 30 | } 31 | shell: powershell 32 | 33 | #- name: Set up Python 3.14 34 | # uses: actions/setup-python@v5 35 | # with: 36 | # python-version: 3.14-dev 37 | 38 | # We move faster than GitHub's Python runtimes, so use NuGet instead 39 | # One day we can use ourselves to download Python, but not yet... 40 | - name: Set up NuGet 41 | uses: nuget/setup-nuget@v2.0.1 42 | 43 | - name: Set up Python 3.14.2 44 | run: | 45 | nuget install python -Version 3.14.2 -x -o . 46 | $py = Get-Item python\tools 47 | Write-Host "Adding $py to PATH" 48 | "$py" | Out-File $env:GITHUB_PATH -Encoding UTF8 -Append 49 | working-directory: ${{ runner.temp }} 50 | 51 | - name: Check Python version is 3.14.2 52 | run: > 53 | python -c "import sys; 54 | print(sys.version); 55 | print(sys.executable); 56 | sys.exit(0 if sys.version_info[:5] == (3, 14, 2, 'final', 0) else 1)" 57 | 58 | - name: Install build dependencies 59 | run: python -m pip install "pymsbuild>=1.2.0b1" 60 | 61 | - name: 'Install test runner' 62 | run: python -m pip install pytest pytest-cov 63 | 64 | - name: 'Build test module' 65 | run: python -m pymsbuild -c _msbuild_test.py 66 | 67 | - name: 'Run pre-test' 68 | shell: bash 69 | run: | 70 | python -m pytest -vv \ 71 | --cov src \ 72 | --cov tests \ 73 | --cov-report term \ 74 | --cov-report xml 75 | 76 | - name: 'Upload coverage' 77 | uses: codecov/codecov-action@v5 78 | with: 79 | token: ${{ secrets.CODECOV_ORG_TOKEN }} 80 | 81 | - name: 'Build package' 82 | run: python make.py 83 | env: 84 | PYMSBUILD_TEMP_DIR: ${{ runner.temp }}/bin 85 | PYMSBUILD_DIST_DIR: ${{ runner.temp }}/dist 86 | PYMSBUILD_LAYOUT_DIR: ${{ runner.temp }}/layout 87 | TEST_MSIX_DIR: ${{ runner.temp }}/test_msix 88 | 89 | - name: 'Build MSIX package' 90 | run: python make-msix.py 91 | env: 92 | PYMSBUILD_TEMP_DIR: ${{ runner.temp }}/bin 93 | PYMSBUILD_DIST_DIR: ${{ runner.temp }}/dist 94 | PYMSBUILD_LAYOUT_DIR: ${{ runner.temp }}/layout 95 | TEST_MSIX_DIR: ${{ runner.temp }}/test_msix 96 | 97 | - name: 'Build MSI package' 98 | run: python make-msi.py 99 | env: 100 | PYMSBUILD_TEMP_DIR: ${{ runner.temp }}/bin 101 | PYMSBUILD_DIST_DIR: ${{ runner.temp }}/dist 102 | PYMSBUILD_LAYOUT_DIR: ${{ runner.temp }}/layout 103 | TEST_MSIX_DIR: ${{ runner.temp }}/test_msix 104 | 105 | - name: 'Register unsigned MSIX' 106 | run: | 107 | $msix = dir "${env:PYMSBUILD_DIST_DIR}\*.msix" ` 108 | | ?{ -not ($_.BaseName -match '.+-store') } ` 109 | | select -first 1 110 | cp $msix "${msix}.zip" 111 | Expand-Archive "${msix}.zip" (mkdir -Force $env:TEST_MSIX_DIR) 112 | Add-AppxPackage -Register "${env:TEST_MSIX_DIR}\appxmanifest.xml" 113 | Get-AppxPackage PythonSoftwareFoundation.PythonManager 114 | env: 115 | PYMSBUILD_TEMP_DIR: ${{ runner.temp }}/bin 116 | PYMSBUILD_DIST_DIR: ${{ runner.temp }}/dist 117 | PYMSBUILD_LAYOUT_DIR: ${{ runner.temp }}/layout 118 | TEST_MSIX_DIR: ${{ runner.temp }}/test_msix 119 | shell: powershell 120 | 121 | - name: 'Ensure global commands are present' 122 | run: | 123 | gcm pymanager 124 | gcm pywmanager 125 | # These are likely present due to the machine configuration, 126 | # but we'll check for them anyway. 127 | gcm py 128 | gcm python 129 | gcm pyw 130 | gcm pythonw 131 | 132 | - name: 'Show help output' 133 | run: pymanager help 134 | 135 | - name: 'Install default runtime' 136 | run: pymanager install default 137 | env: 138 | PYMANAGER_DEBUG: true 139 | 140 | - name: 'List installed runtimes' 141 | run: pymanager list 142 | env: 143 | PYMANAGER_DEBUG: true 144 | 145 | - name: 'List installed runtimes (legacy)' 146 | run: pymanager --list-paths 147 | env: 148 | PYMANAGER_DEBUG: true 149 | 150 | - name: 'Launch default runtime' 151 | run: pymanager exec -m site 152 | env: 153 | PYMANAGER_DEBUG: true 154 | 155 | - name: 'Uninstall runtime' 156 | run: pymanager uninstall -y default 157 | env: 158 | PYMANAGER_DEBUG: true 159 | 160 | - name: 'Emulate first launch' 161 | run: | 162 | $i = (mkdir -force test_installs) 163 | ConvertTo-Json @{ 164 | install_dir="$i"; 165 | download_dir="$i\_cache"; 166 | global_dir="$i\_bin"; 167 | } | Out-File $env:PYTHON_MANAGER_CONFIG -Encoding utf8 168 | pymanager install --configure -y 169 | if ($?) { pymanager list } 170 | env: 171 | PYTHON_MANAGER_INCLUDE_UNMANAGED: false 172 | PYTHON_MANAGER_CONFIG: .\test-config.json 173 | PYMANAGER_DEBUG: true 174 | 175 | - name: 'Validate entrypoint script' 176 | run: | 177 | $env:PYTHON_MANAGER_CONFIG = (gi $env:PYTHON_MANAGER_CONFIG).FullName 178 | cd .\test_installs\_bin 179 | del pip* -Verbose 180 | pymanager install --refresh 181 | dir pip* 182 | Get-Item .\pip.exe 183 | Get-Item .\pip.exe.__target__ 184 | Get-Content .\pip.exe.__target__ 185 | Get-Item .\pip.exe.__script__.py 186 | Get-Content .\pip.exe.__script__.py 187 | .\pip.exe --version 188 | env: 189 | PYTHON_MANAGER_INCLUDE_UNMANAGED: false 190 | PYTHON_MANAGER_CONFIG: .\test-config.json 191 | PYMANAGER_DEBUG: true 192 | shell: powershell 193 | 194 | - name: 'Offline bundle download and install' 195 | run: | 196 | pymanager list --online 3 3-32 3-64 3-arm64 197 | pymanager install --download .\bundle 3 3-32 3-64 3-arm64 198 | pymanager list --source .\bundle 199 | pymanager install --source .\bundle 3 3-32 3-64 3-arm64 200 | env: 201 | PYMANAGER_DEBUG: true 202 | 203 | - name: 'Remove MSIX' 204 | run: | 205 | Get-AppxPackage PythonSoftwareFoundation.PythonManager | Remove-AppxPackage 206 | shell: powershell 207 | -------------------------------------------------------------------------------- /tests/test_indexutils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from manage import indexutils as iu 4 | from manage.exceptions import InvalidFeedError 5 | 6 | 7 | TEST_SCHEMA = { 8 | "int": int, 9 | "float": float, 10 | "str": str, 11 | "any": ..., 12 | "anyprops": {...: ...}, 13 | "anystrprops": {...: str}, 14 | "intlist": [int], 15 | "strlist": [str], 16 | "list": [ 17 | {"key": ...}, 18 | {"id": ...}, 19 | ], 20 | "versionlist": [ 21 | {"version": 1, ...: ...}, 22 | {"version": 2, "x": str, "y": str}, 23 | ], 24 | } 25 | 26 | 27 | EXAMPLE_V1_PACKAGE = { 28 | "versions": [ 29 | { 30 | "schema": 1, 31 | "id": "pythoncore-3.13.0", 32 | "sort-version": "3.13.0", 33 | "company": "PythonCore", 34 | "tag": "3.13", 35 | "install-for": ["3.13.0", "3.13"], 36 | "run-for": [ 37 | { "tag": "3.13", "target": "python.exe" }, 38 | { "tag": "3", "target": "python.exe" } 39 | ], 40 | "alias": [ 41 | { "name": "python3.13.exe", "target": "python.exe" }, 42 | { "name": "python3.exe", "target": "python.exe" }, 43 | { "name": "pythonw3.13.exe", "target": "pythonw.exe", "windowed": 1 }, 44 | { "name": "pythonw3.exe", "target": "pythonw.exe", "windowed": 1 } 45 | ], 46 | "shortcuts": [ 47 | { 48 | "kind": "pep514", 49 | "DisplayName": "Python 3.13", 50 | "SupportUrl": "https://www.python.org/", 51 | "SysArchitecture": "64bit", 52 | "SysVersion": "3.13", 53 | "Version": "3.13.0", 54 | "InstallPath": { 55 | "_": "%PREFIX%", 56 | "ExecutablePath": "%PREFIX%\\python.exe", 57 | "WindowedExecutablePath": "%PREFIX%\\pythonw.exe", 58 | }, 59 | "Help": { 60 | "Online Python Documentation": { 61 | "_": "https://docs.python.org/3.13/" 62 | }, 63 | }, 64 | }, 65 | ], 66 | "display-name": "Python 3.13.0", 67 | "executable": "./python.exe", 68 | "url": "https://api.nuget.org/v3-flatcontainer/python/3.13.0/python.3.13.0.nupkg" 69 | }, 70 | ], 71 | } 72 | 73 | 74 | def fake_install_data(v, company="PythonCore", exe="python.exe", sort_version=None): 75 | assert len(v.split(".")) > 2, "Expect at least x.y.z" 76 | return { 77 | "schema": 1, 78 | "id": f"{company}-{v}", 79 | "sort-version": sort_version or v, 80 | "company": company, 81 | "tag": v, 82 | "install-for": [v, v.rpartition(".")[0], v.partition(".")[0]], 83 | "run-for": [ 84 | {"tag": v, "target": exe}, 85 | {"tag": v.rpartition(".")[0], "target": exe}, 86 | {"tag": v.partition(".")[0], "target": exe}, 87 | ], 88 | "display-name": f"{company} {v}", 89 | "executable": exe, 90 | } 91 | 92 | 93 | class Unstringable: 94 | def __str__(self): 95 | raise TypeError("I am unstringable") 96 | 97 | 98 | @pytest.mark.parametrize("value", [ 99 | {"int": 1}, 100 | {"float": 1.0}, 101 | {"str": "abc"}, 102 | {"any": 1}, {"any": 1.0}, {"any": "abc"}, 103 | {"anyprops": {}}, {"anyprops": {"x": 1}}, {"anyprops": {"y": 2}}, 104 | {"anystrprops": {}}, {"anystrprops": {"x": "abc"}}, 105 | {"intlist": []}, {"intlist": [1, 2, 3]}, 106 | {"strlist": []}, {"strlist": ["x", "y", "z"]}, 107 | {"list": []}, {"list": [{"key": 1}, {"key": 2}, {"id": 3}]}, 108 | {"versionlist": []}, {"versionlist": [{"version": 1, "y": 2}]}, 109 | ]) 110 | def test_schema_parse_valid(value): 111 | assert iu._validate_one(value, TEST_SCHEMA) == value 112 | 113 | 114 | @pytest.mark.parametrize("value, expect", [ 115 | ({"int": "1"}, {"int": 1}), 116 | ({"float": "1"}, {"float": 1.0}), 117 | ({"str": 1}, {"str": "1"}), 118 | ({"intlist": 1}, {"intlist": [1]}), 119 | ({"strlist": "abc"}, {"strlist": ["abc"]}), 120 | ({"list": {"key": 1}}, {"list": [{"key": 1}]}), 121 | ({"list": {"id": 1}}, {"list": [{"id": 1}]}), 122 | ({"versionlist": {"version": 1, "x": 1}}, {"versionlist": [{"version": 1, "x": 1}]}), 123 | ({"versionlist": {"version": 2, "x": 1, "y": 2}}, {"versionlist": [{"version": 2, "x": "1", "y": "2"}]}), 124 | ]) 125 | def test_schema_parse_valid_2(value, expect): 126 | assert iu._validate_one(value, TEST_SCHEMA) == expect 127 | 128 | 129 | @pytest.mark.parametrize("value, key", [ 130 | ({"int": "xyz"}, "'int'"), 131 | ({"float": "xyz"}, "'float'"), 132 | ({"str": Unstringable()}, "'str'"), 133 | ({"list": {"neither": 1}}, "at list.[0]"), 134 | ({"list": [{"key": ...}, {"neither": 1}]}, "at list.[1]"), 135 | ({"versionlist": {"version": 3}}, "at versionlist.[0]"), 136 | ({"unknown": 1}, "key unknown"), 137 | ]) 138 | def test_schema_parse_invalid(value, key): 139 | with pytest.raises(InvalidFeedError) as ex: 140 | iu._validate_one(value, TEST_SCHEMA) 141 | assert key in str(ex.value) 142 | 143 | 144 | def test_v1_package(): 145 | # Ensure we don't change the schema for v1 packages 146 | iu._validate_one(EXAMPLE_V1_PACKAGE, iu.SCHEMA) 147 | 148 | 149 | def test_install_lookup(): 150 | index = iu.Index("https://localhost/", { 151 | "versions": [ 152 | fake_install_data("3.13.1"), 153 | fake_install_data("3.12.2"), 154 | fake_install_data("3.11.3"), 155 | fake_install_data("3.10.4"), 156 | ], 157 | }) 158 | assert index.find_to_install("3.13.1")["tag"] == "3.13.1" 159 | assert index.find_to_install("3.13")["tag"] == "3.13.1" 160 | assert index.find_to_install("3")["tag"] == "3.13.1" 161 | assert index.find_to_install("PythonCore/3")["tag"] == "3.13.1" 162 | assert index.find_to_install("Python/3")["tag"] == "3.13.1" 163 | assert index.find_to_install("cpy/3")["tag"] == "3.13.1" 164 | assert index.find_to_install("3.12")["tag"] == "3.12.2" 165 | 166 | assert index.find_to_install("!=3.13")["tag"] == "3.12.2" 167 | assert index.find_to_install("<=3.12")["tag"] == "3.12.2" 168 | assert index.find_to_install("<3.12")["tag"] == "3.11.3" 169 | assert index.find_to_install("<3.12,!=3.11")["tag"] == "3.10.4" 170 | assert index.find_to_install("<3.12,!=3.10")["tag"] == "3.11.3" 171 | 172 | 173 | def test_select_package(): 174 | from manage.install_command import select_package 175 | 176 | index = iu.Index("https://localhost/", { 177 | "versions": [ 178 | fake_install_data("3.13.0-32", sort_version="3.13.0"), 179 | fake_install_data("3.13.0-64", sort_version="3.13.0"), 180 | ], 181 | }) 182 | assert select_package([index], "3.13", "-64")["tag"] == "3.13.0-64" 183 | assert select_package([index], "3.13-32", "-64")["tag"] == "3.13.0-32" 184 | assert select_package([index], "3.13", "-32")["tag"] == "3.13.0-32" 185 | assert select_package([index], "3.13-64", "-32")["tag"] == "3.13.0-64" 186 | -------------------------------------------------------------------------------- /src/manage/fsutils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from .exceptions import FilesInUseError 5 | from .logging import LOGGER 6 | from .pathutils import Path 7 | 8 | 9 | def ensure_tree(path, overwrite_files=True): 10 | if isinstance(path, (str, bytes)): 11 | path = Path(path) 12 | try: 13 | path.parent.mkdir(parents=True, exist_ok=True) 14 | except FileExistsError: 15 | if not overwrite_files: 16 | raise 17 | for p in path.parents: 18 | if p.is_file(): 19 | unlink(p) 20 | break 21 | path.parent.mkdir(parents=True, exist_ok=True) 22 | 23 | 24 | def _rglob(root): 25 | q = [root] 26 | while q: 27 | r = q.pop(0) 28 | for f in os.scandir(r): 29 | p = r / f.name 30 | if f.is_dir(): 31 | q.append(p) 32 | yield p, None 33 | else: 34 | yield None, p 35 | 36 | 37 | def rglob(root, files=True, dirs=True): 38 | for d, f in _rglob(root): 39 | if d and dirs: 40 | yield d 41 | if f and files: 42 | yield f 43 | 44 | 45 | def _unlink(f, on_missing=None, on_fail=None, on_isdir=None): 46 | try: 47 | f.unlink() 48 | except FileNotFoundError: 49 | if on_missing: 50 | on_missing(f) 51 | else: 52 | pass 53 | except PermissionError: 54 | if f.is_dir(): 55 | if on_isdir: 56 | on_isdir(f) 57 | elif on_fail: 58 | on_fail(f) 59 | else: 60 | raise IsADirectoryError() from None 61 | except OSError: 62 | if on_fail: 63 | on_fail(f) 64 | else: 65 | raise 66 | 67 | 68 | def _rmdir(d, on_missing=None, on_fail=None, on_isfile=None): 69 | try: 70 | d.rmdir() 71 | except FileNotFoundError: 72 | if on_missing: 73 | on_missing(d) 74 | else: 75 | pass 76 | except NotADirectoryError: 77 | if on_isfile: 78 | on_isfile(d) 79 | elif on_fail: 80 | on_fail(d) 81 | else: 82 | raise 83 | except OSError: 84 | if on_fail: 85 | on_fail(d) 86 | else: 87 | raise 88 | 89 | 90 | def rmtree(path, after_5s_warning=None, remove_ext_first=()): 91 | start = time.monotonic() 92 | 93 | if isinstance(path, (str, bytes)): 94 | path = Path(path) 95 | if not path.is_dir(): 96 | if path.is_file(): 97 | unlink(path) 98 | return 99 | 100 | if remove_ext_first: 101 | exts = {e.strip(" .").casefold() for e in remove_ext_first} 102 | files = [f.path for f in os.scandir(path) 103 | if f.is_file() and f.name.rpartition(".")[2].casefold() in exts] 104 | if files: 105 | LOGGER.debug("Atomically removing these files first: %s", 106 | ", ".join(Path(f).name for f in files)) 107 | try: 108 | atomic_unlink(files) 109 | except FilesInUseError as ex: 110 | LOGGER.debug("No files removed because these are in use: %s", 111 | ", ".join(Path(f).name for f in ex.files)) 112 | raise 113 | else: 114 | LOGGER.debug("Files successfully removed") 115 | 116 | for i in range(1000): 117 | if after_5s_warning and (time.monotonic() - start) > 5: 118 | LOGGER.warn(after_5s_warning) 119 | after_5s_warning = None 120 | new_path = path.with_name(f"{path.name}.{i}.deleteme") 121 | if new_path.exists(): 122 | continue 123 | try: 124 | path = path.rename(new_path) 125 | break 126 | except OSError as ex: 127 | LOGGER.debug("Failed to rename to %s: %s", new_path, ex) 128 | time.sleep(0.01) 129 | else: 130 | raise FileExistsError(str(path)) 131 | 132 | to_rmdir = [path] 133 | to_unlink = [] 134 | for d, f in _rglob(path): 135 | if after_5s_warning and (time.monotonic() - start) > 5: 136 | LOGGER.warn(after_5s_warning) 137 | after_5s_warning = None 138 | 139 | if d: 140 | to_rmdir.append(d) 141 | else: 142 | _unlink(f, on_fail=to_unlink.append, on_isdir=to_rmdir.append) 143 | 144 | to_warn = [] 145 | retries = 0 146 | while retries < 3 and (to_rmdir or to_unlink): 147 | retries += 1 148 | for f in to_unlink: 149 | _unlink(f, on_fail=to_warn.append, on_isdir=to_rmdir.append) 150 | to_unlink.clear() 151 | 152 | for d in sorted(to_rmdir, key=lambda p: len(p.parts), reverse=True): 153 | _rmdir(d, on_fail=to_warn.append, on_isfile=to_unlink) 154 | 155 | if to_warn: 156 | f = os.path.commonprefix(to_warn) 157 | if f: 158 | LOGGER.warn("Failed to remove %s", f) 159 | else: 160 | for f in to_warn: 161 | LOGGER.warn("Failed to remove %s", f) 162 | 163 | 164 | def unlink(path, after_5s_warning=None): 165 | start = time.monotonic() 166 | 167 | if isinstance(path, (str, bytes)): 168 | path = Path(path) 169 | try: 170 | path.unlink() 171 | return 172 | except FileNotFoundError: 173 | return 174 | except PermissionError: 175 | if path.is_dir(): 176 | raise IsADirectoryError() from None 177 | except OSError: 178 | pass 179 | 180 | orig_path = path 181 | for i in range(1000): 182 | if after_5s_warning and (time.monotonic() - start) > 5: 183 | LOGGER.warn(after_5s_warning) 184 | after_5s_warning = None 185 | 186 | try: 187 | path = path.rename(path.with_name(f"{path.name}.{i}.deleteme")) 188 | 189 | try: 190 | path.unlink() 191 | except OSError: 192 | pass 193 | break 194 | except OSError: 195 | time.sleep(0.01) 196 | else: 197 | LOGGER.warn("Failed to remove %s", orig_path) 198 | return 199 | 200 | 201 | def atomic_unlink(paths): 202 | "Removes all of 'paths' or none, raising if an error occurs." 203 | from _native import file_lock_for_delete, file_unlock_for_delete, file_locked_delete 204 | 205 | handles = [] 206 | files_in_use = [] 207 | try: 208 | for p in map(str, paths): 209 | try: 210 | handles.append((p, file_lock_for_delete(p))) 211 | except FileNotFoundError: 212 | pass 213 | except PermissionError: 214 | files_in_use.append(p) 215 | 216 | if files_in_use: 217 | raise FilesInUseError(files_in_use) 218 | 219 | handles.reverse() 220 | while handles: 221 | p, h = handles.pop() 222 | try: 223 | file_locked_delete(h) 224 | except FileNotFoundError: 225 | pass 226 | except PermissionError: 227 | files_in_use.append(p) 228 | 229 | if files_in_use: 230 | raise FilesInUseError(files_in_use) 231 | finally: 232 | while handles: 233 | p, h = handles.pop() 234 | file_unlock_for_delete(h) 235 | -------------------------------------------------------------------------------- /src/pymanager/msi.wxs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 66 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 79 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /src/manage/pathutils.py: -------------------------------------------------------------------------------- 1 | """Minimal reimplementations of Path and PurePath. 2 | 3 | This is primarily focused on avoiding the expensive imports that come with 4 | pathlib for functionality that we don't need. This module now gets loaded on 5 | every Python launch through PyManager 6 | """ 7 | import os 8 | 9 | 10 | class PurePath: 11 | def __init__(self, *parts): 12 | total = "" 13 | for p in parts: 14 | try: 15 | p = p.__fspath__().replace("/", "\\") 16 | except AttributeError: 17 | p = str(p).replace("/", "\\") 18 | p = p.replace("\\\\", "\\") 19 | if p == ".": 20 | continue 21 | if p.startswith(".\\"): 22 | p = p[2:] 23 | if total: 24 | total += "\\" + p 25 | else: 26 | total += p 27 | self._parent, _, self.name = total.rpartition("\\") 28 | self._p = total.rstrip("\\") 29 | 30 | def __fspath__(self): 31 | return self._p 32 | 33 | def __repr__(self): 34 | return self._p 35 | 36 | def __str__(self): 37 | return self._p 38 | 39 | def __hash__(self): 40 | return hash(self._p.casefold()) 41 | 42 | def __bool__(self): 43 | return bool(self._p) 44 | 45 | @property 46 | def stem(self): 47 | stem, dot, suffix = self.name.rpartition(".") 48 | if not dot: 49 | return suffix 50 | return stem 51 | 52 | @property 53 | def suffix(self): 54 | stem, dot, suffix = self.name.rpartition(".") 55 | if not dot: 56 | return "" 57 | return dot + suffix 58 | 59 | @property 60 | def parent(self): 61 | return type(self)(self._parent) 62 | 63 | @property 64 | def parts(self): 65 | drive, root, tail = os.path.splitroot(self._p) 66 | bits = [] 67 | if drive or root: 68 | bits.append(drive + root) 69 | if tail: 70 | bits.extend(tail.split("\\")) 71 | while "." in bits: 72 | bits.remove(".") 73 | while ".." in bits: 74 | i = bits.index("..") 75 | bits.pop(i) 76 | bits.pop(i - 1) 77 | return bits 78 | 79 | def __truediv__(self, other): 80 | other = str(other) 81 | # Quick hack to hide leading ".\" on paths. We don't fully normalise 82 | # here because it can change the meaning of paths. 83 | while other.startswith(("./", ".\\")): 84 | other = other[2:] 85 | return type(self)(os.path.join(self._p, other)) 86 | 87 | def __eq__(self, other): 88 | if isinstance(other, PurePath): 89 | return self._p.casefold() == other._p.casefold() 90 | return self._p.casefold() == str(other).casefold() 91 | 92 | def __ne__(self, other): 93 | if isinstance(other, PurePath): 94 | return self._p.casefold() != other._p.casefold() 95 | return self._p.casefold() != str(other).casefold() 96 | 97 | def with_name(self, name): 98 | return type(self)(os.path.join(self._parent, name)) 99 | 100 | def with_suffix(self, suffix): 101 | if suffix and suffix[:1] != ".": 102 | suffix = f".{suffix}" 103 | return type(self)(os.path.join(self._parent, self.stem + suffix)) 104 | 105 | def relative_to(self, base): 106 | base = PurePath(base).parts 107 | parts = self.parts 108 | if not all(x.casefold() == y.casefold() for x, y in zip(base, parts)): 109 | raise ValueError("path not relative to base") 110 | return type(self)("\\".join(parts[len(base):])) 111 | 112 | def as_uri(self): 113 | drive, root, tail = os.path.splitroot(self._p) 114 | if drive[1:2] == ":" and root: 115 | return "file:///" + self._p.replace("\\", "/") 116 | if drive[:2] == "\\\\": 117 | return "file:" + self._p.replace("\\", "/") 118 | return "file://" + self._p.replace("\\", "/") 119 | 120 | def full_match(self, pattern): 121 | return self.match(pattern, full_match=True) 122 | 123 | def match(self, pattern, full_match=False): 124 | p = str(pattern).casefold().replace("/", "\\") 125 | assert "?" not in p 126 | 127 | m = self._p if full_match or "\\" in p else self.name 128 | m = m.casefold() 129 | 130 | if "*" not in p: 131 | return m.casefold() == p 132 | 133 | must_start_with = True 134 | for bit in p.split("*"): 135 | if bit: 136 | try: 137 | i = m.index(bit) 138 | except ValueError: 139 | return False 140 | if must_start_with and i != 0: 141 | return False 142 | m = m[i + len(bit):] 143 | must_start_with = False 144 | return not m or p.endswith("*") 145 | 146 | 147 | class Path(PurePath): 148 | @classmethod 149 | def cwd(cls): 150 | return cls(os.getcwd()) 151 | 152 | def absolute(self): 153 | return Path.cwd() / self 154 | 155 | def exists(self): 156 | return os.path.exists(self._p) 157 | 158 | def is_dir(self): 159 | return os.path.isdir(self._p) 160 | 161 | def is_file(self): 162 | return os.path.isfile(self._p) 163 | 164 | def iterdir(self): 165 | try: 166 | return (self / n for n in os.listdir(self._p)) 167 | except FileNotFoundError: 168 | return () 169 | 170 | def glob(self, pattern): 171 | return (f for f in self.iterdir() if f.match(pattern)) 172 | 173 | def lstat(self): 174 | return os.lstat(self._p) 175 | 176 | def mkdir(self, mode=0o777, parents=False, exist_ok=False): 177 | try: 178 | os.mkdir(self._p, mode) 179 | except FileNotFoundError: 180 | if not parents or self.parent == self: 181 | raise 182 | self.parent.mkdir(parents=True, exist_ok=True) 183 | self.mkdir(mode, parents=False, exist_ok=exist_ok) 184 | except OSError: 185 | # Cannot rely on checking for EEXIST, since the operating system 186 | # could give priority to other errors like EACCES or EROFS 187 | if not exist_ok or not self.is_dir(): 188 | raise 189 | 190 | def rename(self, new_name): 191 | os.rename(self._p, new_name) 192 | return self.parent / PurePath(new_name) 193 | 194 | def rmdir(self): 195 | os.rmdir(self._p) 196 | 197 | def unlink(self): 198 | os.unlink(self._p) 199 | 200 | def open(self, mode="r", encoding=None, errors=None): 201 | if "b" in mode: 202 | return open(self._p, mode) 203 | if not encoding: 204 | encoding = "utf-8-sig" if "r" in mode else "utf-8" 205 | return open(self._p, mode, encoding=encoding, errors=errors or "strict") 206 | 207 | def read_bytes(self): 208 | with open(self._p, "rb") as f: 209 | return f.read() 210 | 211 | def read_text(self, encoding="utf-8-sig", errors="strict"): 212 | with open(self._p, "r", encoding=encoding, errors=errors) as f: 213 | return f.read() 214 | 215 | def write_bytes(self, data): 216 | with open(self._p, "wb") as f: 217 | f.write(data) 218 | 219 | def write_text(self, text, encoding="utf-8", errors="strict"): 220 | with open(self._p, "w", encoding=encoding, errors=errors) as f: 221 | f.write(text) 222 | -------------------------------------------------------------------------------- /tests/test_installs.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pathlib import PurePath 4 | 5 | from manage import installs 6 | 7 | 8 | def test_get_installs_in_order(patched_installs): 9 | ii = installs.get_installs("") 10 | assert [i["id"] for i in ii] == [ 11 | "PythonCore-2.0-64", 12 | "PythonCore-2.0-arm64", 13 | "PythonCore-1.0", 14 | "PythonCore-1.0-64", 15 | "PythonCore-1.0-32", 16 | # Note that the order is subtly different for non-PythonCore 17 | "Company-2.1", 18 | "Company-2.1-64", 19 | "Company-1.1", 20 | "Company-1.1-64", 21 | "Company-1.1-arm64", 22 | # Prereleases come last 23 | "PythonCore-3.0a1-64", 24 | "PythonCore-3.0a1-32", 25 | ] 26 | 27 | 28 | def test_get_default_install(patched_installs): 29 | assert installs.get_install_to_run("", "1.0", "")["id"] == "PythonCore-1.0" 30 | assert installs.get_install_to_run("", "2.0-64", "")["id"] == "PythonCore-2.0-64" 31 | 32 | assert installs.get_install_to_run("", "1.1", "")["id"] == "Company-1.1" 33 | assert installs.get_install_to_run("", "2.1-64", "")["id"] == "Company-2.1-64" 34 | 35 | 36 | def test_get_default_with_default_platform(patched_installs): 37 | i = installs.get_install_to_run("", "1", "", default_platform="-64") 38 | assert i["id"] == "PythonCore-1.0-64" 39 | i = installs.get_install_to_run("", "1", "", default_platform="-32") 40 | assert i["id"] == "PythonCore-1.0-32" 41 | 42 | 43 | def test_get_default_install_prerelease(patched_installs2): 44 | inst = list(installs._get_installs("")) 45 | m = installs.get_matching_install_tags(inst, "1.0", None, "-32", single_tag=True) 46 | assert m and m[0] 47 | assert m[0][0]["id"] == "PythonCore-1.0-32" 48 | 49 | m = installs.get_matching_install_tags(inst, "3.0", None, "-32", single_tag=True) 50 | assert m and m[0] 51 | assert m[0][0]["id"] == "PythonCore-3.0a1-32" 52 | 53 | 54 | def test_get_install_to_run(patched_installs): 55 | i = installs.get_install_to_run("", None, "1.0") 56 | assert i["id"] == "PythonCore-1.0" 57 | assert i["executable"].match("python.exe") 58 | i = installs.get_install_to_run("", None, "2.0") 59 | assert i["id"] == "PythonCore-2.0-64" 60 | assert i["executable"].match("python.exe") 61 | 62 | 63 | def test_get_install_to_run_with_platform(patched_installs): 64 | i = installs.get_install_to_run("", None, "1.0-32") 65 | assert i["id"] == "PythonCore-1.0-32" 66 | assert i["executable"].match("python.exe") 67 | i = installs.get_install_to_run("", None, "2.0-arm64") 68 | assert i["id"] == "PythonCore-2.0-arm64" 69 | assert i["executable"].match("python.exe") 70 | 71 | 72 | def test_get_install_to_run_with_platform_windowed(patched_installs): 73 | i = installs.get_install_to_run("", None, "1.0-32", windowed=True) 74 | assert i["id"] == "PythonCore-1.0-32" 75 | assert i["executable"].match("pythonw.exe") 76 | i = installs.get_install_to_run("", None, "2.0-arm64", windowed=True) 77 | assert i["id"] == "PythonCore-2.0-arm64" 78 | assert i["executable"].match("pythonw.exe") 79 | 80 | 81 | def test_get_install_to_run_with_default_platform(patched_installs): 82 | i = installs.get_install_to_run("", None, "1.0", default_platform="-32") 83 | assert i["id"] == "PythonCore-1.0-32" 84 | assert i["executable"].match("python.exe") 85 | i = installs.get_install_to_run("", None, "1.0", default_platform="-64") 86 | assert i["id"] == "PythonCore-1.0-64" 87 | assert i["executable"].match("python.exe") 88 | i = installs.get_install_to_run("", None, "2.0", default_platform="-arm64") 89 | assert i["id"] == "PythonCore-2.0-arm64" 90 | assert i["executable"].match("python.exe") 91 | 92 | i = installs.get_install_to_run("", None, "1.0-64", default_platform="-32") 93 | assert i["id"] == "PythonCore-1.0-64" 94 | assert i["executable"].match("python.exe") 95 | i = installs.get_install_to_run("", None, "2.0-64", default_platform="-arm64") 96 | assert i["id"] == "PythonCore-2.0-64" 97 | assert i["executable"].match("python.exe") 98 | 99 | 100 | def test_get_install_to_run_with_default_platform_prerelease(patched_installs2): 101 | # Specifically testing issue #25, where a native prerelease is preferred 102 | # over a non-native stable release. We should prefer the stable release 103 | # (e.g. for cases where an ARM64 user is relying on a stable x64 build, but 104 | # also wanting to test a prerelease ARM64 build.) 105 | i = installs.get_install_to_run("", None, None, default_platform="-32") 106 | assert i["id"] == "PythonCore-1.0-32" 107 | i = installs.get_install_to_run("", None, None, default_platform="-64") 108 | assert i["id"] == "PythonCore-1.0-32" 109 | i = installs.get_install_to_run("", None, None, default_platform="-arm64") 110 | assert i["id"] == "PythonCore-1.0-32" 111 | 112 | 113 | def test_get_install_to_run_with_platform_prerelease(patched_installs2): 114 | i = installs.get_install_to_run("", None, "3", default_platform="-32") 115 | assert i["id"] == "PythonCore-3.0a1-32" 116 | i = installs.get_install_to_run("", None, "3-32", default_platform="-64") 117 | assert i["id"] == "PythonCore-3.0a1-32" 118 | i = installs.get_install_to_run("", None, "3-32", default_platform="-arm64") 119 | assert i["id"] == "PythonCore-3.0a1-32" 120 | 121 | 122 | def test_get_install_to_run_with_range(patched_installs): 123 | i = installs.get_install_to_run("", None, "<=1.0") 124 | assert i["id"] == "PythonCore-1.0" 125 | assert i["executable"].match("python.exe") 126 | i = installs.get_install_to_run("", None, ">1.0") 127 | assert i["id"] == "PythonCore-2.0-64" 128 | assert i["executable"].match("python.exe") 129 | 130 | 131 | def test_install_alias_make_alias_sortkey(): 132 | assert ("pythonw00000000000000000003-00000000000000000064.exe" 133 | == installs._make_alias_name_sortkey("pythonw3-64.exe")) 134 | assert ("pythonw00000000000000000003-00000000000000000064.exe" 135 | == installs._make_alias_name_sortkey("python[w]3[-64].exe")) 136 | 137 | def test_install_alias_make_alias_key(): 138 | assert ("python", "w", "3", "-64", ".exe") == installs._make_alias_key("pythonw3-64.exe") 139 | assert ("python", "w", "3", "", ".exe") == installs._make_alias_key("pythonw3.exe") 140 | assert ("pythonw3-xyz", "", "", "", ".exe") == installs._make_alias_key("pythonw3-xyz.exe") 141 | assert ("python", "", "3", "-64", ".exe") == installs._make_alias_key("python3-64.exe") 142 | assert ("python", "", "3", "", ".exe") == installs._make_alias_key("python3.exe") 143 | assert ("python3-xyz", "", "", "", ".exe") == installs._make_alias_key("python3-xyz.exe") 144 | 145 | 146 | def test_install_alias_opt_part(): 147 | assert "" == installs._make_opt_part([]) 148 | assert "x" == installs._make_opt_part(["x"]) 149 | assert "[x]" == installs._make_opt_part(["x", ""]) 150 | assert "[x|y]" == installs._make_opt_part(["", "y", "x"]) 151 | 152 | 153 | def test_install_alias_names(): 154 | input = [{"name": i} for i in ["py3.exe", "PY3-64.exe", "PYW3.exe", "pyw3-64.exe"]] 155 | input.extend([{"name": i, "windowed": 1} for i in ["xy3.exe", "XY3-64.exe", "XYW3.exe", "xyw3-64.exe"]]) 156 | expect = ["py[w]3[-64].exe"] 157 | expectw = ["py[w]3[-64].exe", "xy[w]3[-64].exe"] 158 | assert expect == installs.get_install_alias_names(input, friendly=True, windowed=False) 159 | assert expectw == installs.get_install_alias_names(input, friendly=True, windowed=True) 160 | -------------------------------------------------------------------------------- /tests/test_scriptutils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import pytest 3 | import subprocess 4 | import sys 5 | import textwrap 6 | 7 | from pathlib import PurePath 8 | 9 | from manage.scriptutils import ( 10 | find_install_from_script, 11 | _find_shebang_command, 12 | _read_script, 13 | NewEncoding, 14 | _maybe_quote, 15 | quote_args, 16 | split_args 17 | ) 18 | 19 | def _fake_install(v, **kwargs): 20 | return { 21 | "company": kwargs.get("company", "Test"), 22 | "id": f"test-{v}", 23 | "tag": str(v), 24 | "version": str(v), 25 | "prefix": PurePath(f"./pkgs/test-{v}"), 26 | "executable": PurePath(f"./pkgs/test-{v}/test-binary-{v}.exe"), 27 | **kwargs 28 | } 29 | 30 | INSTALLS = [ 31 | _fake_install("1.0", alias=[{"name": "test1.0.exe", "target": "./test-binary-1.0.exe"}]), 32 | _fake_install("1.1", alias=[{"name": "test1.1.exe", "target": "./test-binary-1.1.exe"}]), 33 | _fake_install("1.3.1", company="PythonCore"), 34 | _fake_install("1.3.2", company="PythonOther"), 35 | _fake_install("2.0", alias=[{"name": "test2.0.exe", "target": "./test-binary-2.0.exe"}]), 36 | ] 37 | 38 | @pytest.mark.parametrize("script, expect", [ 39 | ("", None), 40 | ("#! /usr/bin/test1.0\n#! /usr/bin/test2.0\n", "1.0"), 41 | ("#! /usr/bin/test2.0\n#! /usr/bin/test1.0\n", "2.0"), 42 | ("#! /usr/bin/test1.0.exe\n#! /usr/bin/test2.0\n", "1.0"), 43 | ("#!test1.0.exe\n", "1.0"), 44 | ("#!test1.1.exe\n", "1.1"), 45 | ("#!test1.2.exe\n", None), 46 | ("#!test-binary-1.1.exe\n", "1.1"), 47 | ("#!.\\pkgs\\test-1.1\\test-binary-1.1.exe\n", "1.1"), 48 | ("#!.\\pkgs\\test-1.0\\test-binary-1.1.exe\n", None), 49 | ("#! /usr/bin/env test1.0\n", "1.0"), 50 | ("#! /usr/bin/env test2.0\n", "2.0"), 51 | ("#! /usr/bin/env -S test2.0\n", "2.0"), 52 | # Legacy handling specifically for "python" 53 | ("#! /usr/bin/python1.3.1", "1.3.1"), 54 | ("#! /usr/bin/env python1.3.1", "1.3.1"), 55 | ("#! /usr/bin/python1.3.2", None), 56 | ]) 57 | def test_read_shebang(fake_config, tmp_path, script, expect): 58 | fake_config.installs.extend(INSTALLS) 59 | if expect: 60 | expect = [i for i in INSTALLS if i["tag"] == expect][0] 61 | 62 | script_py = tmp_path / "test-script.py" 63 | if isinstance(script, str): 64 | script = script.encode() 65 | script_py.write_bytes(script) 66 | try: 67 | actual = find_install_from_script(fake_config, script_py) 68 | assert expect == actual 69 | except LookupError: 70 | assert not expect 71 | 72 | 73 | def test_default_py_shebang(fake_config, tmp_path): 74 | inst = _fake_install("1.0", company="PythonCore", prefix=PurePath("C:\\TestRoot"), default=True) 75 | inst["run-for"] = [ 76 | dict(name="python.exe", target=".\\python.exe"), 77 | dict(name="pythonw.exe", target=".\\pythonw.exe", windowed=1), 78 | ] 79 | fake_config.installs[:] = [inst] 80 | 81 | # Finds the install's default executable 82 | assert _find_shebang_command(fake_config, "python")["executable"].match("test-binary-1.0.exe") 83 | assert _find_shebang_command(fake_config, "py")["executable"].match("test-binary-1.0.exe") 84 | assert _find_shebang_command(fake_config, "python1.0")["executable"].match("test-binary-1.0.exe") 85 | # Finds the install's run-for executable with windowed=1 86 | assert _find_shebang_command(fake_config, "pythonw")["executable"].match("pythonw.exe") 87 | assert _find_shebang_command(fake_config, "pyw")["executable"].match("pythonw.exe") 88 | assert _find_shebang_command(fake_config, "pythonw1.0")["executable"].match("pythonw.exe") 89 | 90 | 91 | 92 | @pytest.mark.parametrize("script, expect", [ 93 | ("# not a coding comment", None), 94 | ("# coding: utf-8-sig", None), 95 | ("# coding: utf-8", "utf-8"), 96 | ("# coding: ascii", "ascii"), 97 | ("# actually a coding: comment", "comment"), 98 | ("#~=~=~=coding:ascii=~=~=~=~", "ascii"), 99 | ("#! /usr/bin/env python\n# coding: ascii", None), 100 | ]) 101 | def test_read_coding_comment(fake_config, tmp_path, script, expect): 102 | script_py = tmp_path / "test-script.py" 103 | if isinstance(script, str): 104 | script = script.encode() 105 | script_py.write_bytes(script) 106 | try: 107 | _read_script(fake_config, script_py, "utf-8-sig") 108 | except NewEncoding as enc: 109 | assert enc.args[0] == expect 110 | except LookupError: 111 | assert not expect 112 | else: 113 | assert not expect 114 | 115 | 116 | @pytest.mark.parametrize("arg, expect", [pytest.param(*a, id=a[0]) for a in [ 117 | ('abc', 'abc'), 118 | ('a b c', '"a b c"'), 119 | ('abc ', '"abc "'), 120 | (' abc', '" abc"'), 121 | ('a1\\b\\c', 'a1\\b\\c'), 122 | ('a2\\ b', '"a2\\ b"'), 123 | ('a3\\b\\', 'a3\\b\\'), 124 | ('a4 b\\', '"a4 b\\\\"'), 125 | ('a5 b\\\\', '"a5 b\\\\\\\\"'), 126 | ('a1"b', 'a1\\"b'), 127 | ('a2\\"b', 'a2\\\\\\"b'), 128 | ('a3\\\\"b', 'a3\\\\\\\\\\"b'), 129 | ('a4\\\\\\"b', 'a4\\\\\\\\\\\\\\"b'), 130 | ('a5 "b', '"a5 \\"b"'), 131 | ('a6\\ "b', '"a6\\ \\"b"'), 132 | ('a7 \\"b', '"a7 \\\\\\"b"'), 133 | ]]) 134 | def test_quote_one_arg(arg, expect): 135 | # Test our expected result by passing it to Python and checking what it sees 136 | test_cmd = ( 137 | 'python -c "import base64, sys; ' 138 | 'expect = base64.b64decode(\'{}\').decode(); ' 139 | 'print(\'Expect:\', repr(expect), \' Actual:\', repr(sys.argv[1])); ' 140 | 'sys.exit(0 if expect == sys.argv[1] else 1)" {} END_OF_ARGS' 141 | ).format(base64.b64encode(arg.encode()).decode("ascii"), expect) 142 | subprocess.check_call(test_cmd, executable=sys.executable) 143 | # Test that our quote function produces the expected result 144 | assert expect == _maybe_quote(arg) 145 | 146 | 147 | @pytest.mark.parametrize("arg, expect, expect_call", [pytest.param(*a, id=a[0]) for a in [ 148 | ('"a1 b"', '"a1 b"', 'a1 b'), 149 | ('"a2" b"', '"a2\\" b"', 'a2" b'), 150 | ]]) 151 | def test_quote_one_quoted_arg(arg, expect, expect_call): 152 | # Test our expected result by passing it to Python and checking what it sees 153 | test_cmd = ( 154 | 'python -c "import base64, sys; ' 155 | 'expect = base64.b64decode(\'{}\').decode(); ' 156 | 'print(\'Expect:\', repr(expect), \' Actual:\', repr(sys.argv[1])); ' 157 | 'sys.exit(0 if expect == sys.argv[1] else 1)" {} END_OF_ARGS' 158 | ).format(base64.b64encode(expect_call.encode()).decode("ascii"), expect) 159 | subprocess.check_call(test_cmd, executable=sys.executable) 160 | # Test that our quote function produces the expected result 161 | assert expect == _maybe_quote(arg) 162 | 163 | 164 | # We're not going to try too hard here - most of the tricky logic is covered 165 | # by the previous couple of tests. 166 | @pytest.mark.parametrize("args, expect", [pytest.param(*a, id=a[1]) for a in [ 167 | (["a1", "b", "c"], 'a1 b c'), 168 | (["a2 b", "c d"], '"a2 b" "c d"'), 169 | (['a3"b', 'c"d', 'e f'], 'a3\\"b c\\"d "e f"'), 170 | (['a4"b c"d', 'e f'], '"a4\\"b c\\"d" "e f"'), 171 | (['a5\\b\\', 'c\\d'], 'a5\\b\\ c\\d'), 172 | (['a6\\b\\ c\\', 'd\\e'], '"a6\\b\\ c\\\\" d\\e'), 173 | ]]) 174 | def test_quote_args(args, expect): 175 | # Test our expected result by passing it to Python and checking what it sees 176 | test_cmd = ( 177 | 'python -c "import base64, sys; ' 178 | 'expect = base64.b64decode(\'{}\').decode().split(\'\\0\'); ' 179 | 'print(\'Expect:\', repr(expect), \' Actual:\', repr(sys.argv)); ' 180 | 'sys.exit(0 if expect == sys.argv[1:-1] else 1)" {} END_OF_ARGS' 181 | ).format(base64.b64encode('\0'.join(args).encode()).decode("ascii"), expect) 182 | subprocess.check_call(test_cmd, executable=sys.executable) 183 | # Test that our quote function produces the expected result 184 | assert expect == quote_args(args) 185 | # Test that our split function produces the same result 186 | assert args == split_args(expect), expect 187 | -------------------------------------------------------------------------------- /src/_native/misc.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include "helpers.h" 6 | 7 | 8 | extern "C" { 9 | 10 | PyObject *coinitialize(PyObject *, PyObject *args, PyObject *kwargs) { 11 | HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); 12 | if (FAILED(hr)) { 13 | PyErr_SetFromWindowsErr(hr); 14 | return NULL; 15 | } 16 | Py_RETURN_NONE; 17 | } 18 | 19 | static void _invalid_parameter( 20 | const wchar_t * expression, 21 | const wchar_t * function, 22 | const wchar_t * file, 23 | unsigned int line, 24 | uintptr_t pReserved 25 | ) { } 26 | 27 | PyObject *fd_supports_vt100(PyObject *, PyObject *args, PyObject *kwargs) { 28 | static const char * keywords[] = {"fd", NULL}; 29 | int fd; 30 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "i:fd_supports_vt100", keywords, &fd)) { 31 | return NULL; 32 | } 33 | PyObject *r = NULL; 34 | HANDLE h; 35 | DWORD mode; 36 | const DWORD expect_flags = ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING; 37 | 38 | auto handler = _set_thread_local_invalid_parameter_handler(_invalid_parameter); 39 | h = (HANDLE)_get_osfhandle(fd); 40 | _set_thread_local_invalid_parameter_handler(handler); 41 | 42 | if (GetConsoleMode(h, &mode)) { 43 | if ((mode & expect_flags) == expect_flags) { 44 | r = Py_GetConstant(Py_CONSTANT_TRUE); 45 | } else { 46 | r = Py_GetConstant(Py_CONSTANT_FALSE); 47 | } 48 | } else { 49 | PyErr_SetFromWindowsErr(0); 50 | } 51 | return r; 52 | } 53 | 54 | PyObject *date_as_str(PyObject *, PyObject *, PyObject *) { 55 | wchar_t buffer[256]; 56 | DWORD cch = GetDateFormatEx( 57 | LOCALE_NAME_INVARIANT, 58 | 0, 59 | NULL, 60 | L"yyyyMMdd", 61 | buffer, 62 | sizeof(buffer) / sizeof(buffer[0]), 63 | NULL 64 | ); 65 | if (!cch) { 66 | PyErr_SetFromWindowsErr(0); 67 | return NULL; 68 | } 69 | return PyUnicode_FromWideChar(buffer, cch - 1); 70 | } 71 | 72 | PyObject *datetime_as_str(PyObject *, PyObject *, PyObject *) { 73 | wchar_t buffer[256]; 74 | DWORD cch = GetDateFormatEx( 75 | LOCALE_NAME_INVARIANT, 76 | 0, 77 | NULL, 78 | L"yyyyMMdd", 79 | buffer, 80 | sizeof(buffer) / sizeof(buffer[0]), 81 | NULL 82 | ); 83 | if (!cch) { 84 | PyErr_SetFromWindowsErr(0); 85 | return NULL; 86 | } 87 | cch -= 1; 88 | cch += GetTimeFormatEx( 89 | LOCALE_NAME_INVARIANT, 90 | 0, 91 | NULL, 92 | L"HHmmss", 93 | &buffer[cch], 94 | sizeof(buffer) / sizeof(buffer[0]) - cch 95 | ); 96 | return PyUnicode_FromWideChar(buffer, cch - 1); 97 | } 98 | 99 | PyObject *reg_rename_key(PyObject *, PyObject *args, PyObject *kwargs) { 100 | static const char * keywords[] = {"handle", "name1", "name2", NULL}; 101 | PyObject *handle_obj; 102 | wchar_t *name1 = NULL; 103 | wchar_t *name2 = NULL; 104 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO&O&:reg_rename_key", keywords, 105 | &handle_obj, as_utf16, &name1, as_utf16, &name2)) { 106 | return NULL; 107 | } 108 | PyObject *r = NULL; 109 | HKEY h; 110 | if (PyLong_AsNativeBytes(handle_obj, &h, sizeof(h), -1) >= 0) { 111 | int err = (int)RegRenameKey(h, name1, name2); 112 | if (!err) { 113 | r = Py_GetConstant(Py_CONSTANT_NONE); 114 | } else { 115 | PyErr_SetFromWindowsErr(err); 116 | } 117 | } 118 | PyMem_Free(name1); 119 | PyMem_Free(name2); 120 | return r; 121 | } 122 | 123 | 124 | PyObject *get_current_package(PyObject *, PyObject *, PyObject *) { 125 | wchar_t package_name[256]; 126 | UINT32 cch = sizeof(package_name) / sizeof(package_name[0]); 127 | int err = GetCurrentPackageFamilyName(&cch, package_name); 128 | switch (err) { 129 | case ERROR_SUCCESS: 130 | return PyUnicode_FromWideChar(package_name, cch ? cch - 1 : 0); 131 | case APPMODEL_ERROR_NO_PACKAGE: 132 | return Py_GetConstant(Py_CONSTANT_NONE); 133 | default: 134 | PyErr_SetFromWindowsErr(err); 135 | return NULL; 136 | } 137 | } 138 | 139 | 140 | PyObject *read_alias_package(PyObject *, PyObject *args, PyObject *kwargs) { 141 | static const char * keywords[] = {"path", NULL}; 142 | wchar_t *path = NULL; 143 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&:read_alias_package", keywords, 144 | as_utf16, &path)) { 145 | return NULL; 146 | } 147 | 148 | HANDLE h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 149 | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); 150 | PyMem_Free(path); 151 | if (h == INVALID_HANDLE_VALUE) { 152 | PyErr_SetFromWindowsErr(0); 153 | return NULL; 154 | } 155 | 156 | struct { 157 | DWORD tag; 158 | DWORD _reserved1; 159 | DWORD _reserved2; 160 | wchar_t package_name[256]; 161 | wchar_t nul; 162 | } buffer; 163 | DWORD nread; 164 | 165 | if (!DeviceIoControl(h, FSCTL_GET_REPARSE_POINT, NULL, 0, 166 | &buffer, sizeof(buffer), &nread, NULL) 167 | // we expect our buffer to be too small, but we only want the package 168 | && GetLastError() != ERROR_MORE_DATA) { 169 | PyErr_SetFromWindowsErr(0); 170 | CloseHandle(h); 171 | return NULL; 172 | } 173 | 174 | CloseHandle(h); 175 | 176 | if (buffer.tag != IO_REPARSE_TAG_APPEXECLINK) { 177 | return Py_GetConstant(Py_CONSTANT_NONE); 178 | } 179 | 180 | buffer.nul = 0; 181 | return PyUnicode_FromWideChar(buffer.package_name, -1); 182 | } 183 | 184 | 185 | typedef LRESULT (*PSendMessageTimeoutW)( 186 | HWND hWnd, 187 | UINT Msg, 188 | WPARAM wParam, 189 | LPARAM lParam, 190 | UINT fuFlags, 191 | UINT uTimeout, 192 | PDWORD_PTR lpdwResult 193 | ); 194 | 195 | PyObject *broadcast_settings_change(PyObject *, PyObject *, PyObject *) { 196 | // Avoid depending on user32 because it's so slow 197 | HMODULE user32 = LoadLibraryExW(L"user32.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32); 198 | if (!user32) { 199 | PyErr_SetFromWindowsErr(0); 200 | return NULL; 201 | } 202 | PSendMessageTimeoutW sm = (PSendMessageTimeoutW)GetProcAddress(user32, "SendMessageTimeoutW"); 203 | if (!sm) { 204 | PyErr_SetFromWindowsErr(0); 205 | FreeLibrary(user32); 206 | return NULL; 207 | } 208 | 209 | // SendMessageTimeout needs special error handling 210 | SetLastError(0); 211 | LPARAM lParam = (LPARAM)L"Environment"; 212 | 213 | if (!(*sm)( 214 | HWND_BROADCAST, 215 | WM_SETTINGCHANGE, 216 | NULL, 217 | lParam, 218 | SMTO_ABORTIFHUNG, 219 | 50, 220 | NULL 221 | )) { 222 | int err = GetLastError(); 223 | if (!err) { 224 | PyErr_SetString(PyExc_OSError, "Unspecified error"); 225 | } else { 226 | PyErr_SetFromWindowsErr(err); 227 | } 228 | FreeLibrary(user32); 229 | return NULL; 230 | } 231 | 232 | FreeLibrary(user32); 233 | return Py_GetConstant(Py_CONSTANT_NONE); 234 | } 235 | 236 | typedef enum { 237 | CPU_X86 = 0, 238 | CPU_X86_64 = 9, 239 | CPU_ARM = 5, 240 | CPU_ARM64 = 12, 241 | CPU_UNKNOWN = 0xffff 242 | } CpuArchitecture; 243 | 244 | PyObject *get_processor_architecture(PyObject *, PyObject *, PyObject *) { 245 | SYSTEM_INFO system_info; 246 | GetNativeSystemInfo(&system_info); 247 | 248 | switch (system_info.wProcessorArchitecture) { 249 | case CPU_X86: return PyUnicode_FromString("-32"); 250 | case CPU_X86_64: return PyUnicode_FromString("-64"); 251 | case CPU_ARM: return PyUnicode_FromString("-arm"); 252 | case CPU_ARM64: return PyUnicode_FromString("-arm64"); 253 | default: return PyUnicode_FromString("-64"); // x86-64 254 | } 255 | } 256 | 257 | } 258 | -------------------------------------------------------------------------------- /src/manage/uninstall_command.py: -------------------------------------------------------------------------------- 1 | from .exceptions import ArgumentError, FilesInUseError 2 | from .fsutils import rmtree, unlink 3 | from .installs import get_matching_install_tags 4 | from .install_command import SHORTCUT_HANDLERS, update_all_shortcuts 5 | from .logging import LOGGER 6 | from .pathutils import Path, PurePath 7 | from .tagutils import tag_or_range 8 | 9 | 10 | def _iterdir(p, only_files=False): 11 | try: 12 | if only_files: 13 | return [f for f in Path(p).iterdir() if f.is_file()] 14 | return list(Path(p).iterdir()) 15 | except FileNotFoundError: 16 | LOGGER.debug("Skipping %s because it does not exist", p) 17 | return [] 18 | 19 | 20 | def _do_purge_global_dir(global_dir, warn_msg, *, hive=None, subkey="Environment"): 21 | import winreg 22 | 23 | if hive is None: 24 | hive = winreg.HKEY_CURRENT_USER 25 | try: 26 | with winreg.OpenKeyEx(hive, subkey) as key: 27 | path, kind = winreg.QueryValueEx(key, "Path") 28 | if kind not in (winreg.REG_SZ, winreg.REG_EXPAND_SZ): 29 | raise ValueError("Value kind is not a string") 30 | except (OSError, ValueError): 31 | LOGGER.debug("Not removing global commands directory from PATH", exc_info=True) 32 | else: 33 | LOGGER.debug("Current PATH contains %s", path) 34 | paths = path.split(";") 35 | newpaths = [] 36 | for p in paths: 37 | # We should expand entries here, but we only want to remove those 38 | # that we added ourselves (during firstrun), and we never use 39 | # environment variables. So even if the kind is REG_EXPAND_SZ, we 40 | # don't need to expand to find our own entry. 41 | #ep = os.path.expandvars(p) if kind == winreg.REG_EXPAND_SZ else p 42 | ep = p 43 | if PurePath(ep).match(global_dir): 44 | LOGGER.debug("Removing from PATH: %s", p) 45 | else: 46 | newpaths.append(p) 47 | if len(newpaths) < len(paths): 48 | newpath = ";".join(newpaths) 49 | with winreg.CreateKeyEx(hive, subkey, access=winreg.KEY_READ|winreg.KEY_WRITE) as key: 50 | path2, kind2 = winreg.QueryValueEx(key, "Path") 51 | if path2 == path and kind2 == kind: 52 | LOGGER.info("Removing global commands directory from PATH") 53 | LOGGER.debug("New PATH contains %s", newpath) 54 | winreg.SetValueEx(key, "Path", 0, kind, newpath) 55 | else: 56 | LOGGER.debug("Not removing global commands directory from PATH " 57 | "because the registry changed while processing.") 58 | 59 | try: 60 | from _native import broadcast_settings_change 61 | broadcast_settings_change() 62 | except (ImportError, OSError): 63 | LOGGER.debug("Did not broadcast settings change notification", 64 | exc_info=True) 65 | 66 | if not global_dir.is_dir(): 67 | return 68 | LOGGER.info("Purging global commands from %s", global_dir) 69 | for f in _iterdir(global_dir): 70 | LOGGER.debug("Purging %s", f) 71 | rmtree(f, after_5s_warning=warn_msg) 72 | 73 | 74 | def execute(cmd): 75 | LOGGER.debug("BEGIN uninstall_command.execute: %r", cmd.args) 76 | 77 | warn_msg = ("Attempting to remove {} is taking longer than expected. " + 78 | "Ensure no Python interpreters are running, and continue to wait " + 79 | "or press Ctrl+C to abort.") 80 | 81 | # Clear any active venv so we don't try to delete it 82 | cmd.virtual_env = None 83 | installed = list(cmd.get_installs()) 84 | 85 | cmd.tags = [] 86 | 87 | if cmd.purge: 88 | if not cmd.ask_yn("Uninstall all runtimes?"): 89 | LOGGER.debug("END uninstall_command.execute") 90 | return 91 | for i in installed: 92 | LOGGER.info("Purging %s from %s", i["display-name"], i["prefix"]) 93 | try: 94 | rmtree( 95 | i["prefix"], 96 | after_5s_warning=warn_msg.format(i["display-name"]), 97 | remove_ext_first=("exe", "dll", "json") 98 | ) 99 | except FilesInUseError: 100 | LOGGER.warn("Unable to purge %s because it is still in use.", 101 | i["display-name"]) 102 | continue 103 | LOGGER.info("Purging saved downloads from %s", cmd.download_dir) 104 | rmtree(cmd.download_dir, after_5s_warning=warn_msg.format("cached downloads")) 105 | # Purge global commands directory 106 | _do_purge_global_dir(cmd.global_dir, warn_msg.format("global commands")) 107 | LOGGER.info("Purging all shortcuts") 108 | for _, cleanup in SHORTCUT_HANDLERS.values(): 109 | cleanup(cmd, []) 110 | LOGGER.debug("END uninstall_command.execute") 111 | return 112 | 113 | if not cmd.args: 114 | raise ArgumentError("Please specify one or more runtimes to uninstall.") 115 | 116 | to_uninstall = [] 117 | if not cmd.by_id: 118 | for tag in cmd.args: 119 | try: 120 | if tag.casefold() == "default".casefold(): 121 | cmd.tags.append(tag_or_range(cmd.default_tag)) 122 | else: 123 | cmd.tags.append(tag_or_range(tag)) 124 | except ValueError as ex: 125 | LOGGER.warn("%s", ex) 126 | 127 | for tag in cmd.tags: 128 | candidates = get_matching_install_tags( 129 | installed, 130 | tag, 131 | default_platform=cmd.default_platform, 132 | ) 133 | if not candidates: 134 | LOGGER.warn("No install found matching '%s'", tag) 135 | continue 136 | i, _ = candidates[0] 137 | LOGGER.debug("Selected %s (%s) to uninstall", i["display-name"], i["id"]) 138 | to_uninstall.append(i) 139 | installed.remove(i) 140 | else: 141 | ids = {tag.casefold() for tag in cmd.args} 142 | for i in installed: 143 | if i["id"].casefold() in ids: 144 | LOGGER.debug("Selected %s (%s) to uninstall", i["display-name"], i["id"]) 145 | to_uninstall.append(i) 146 | for i in to_uninstall: 147 | installed.remove(i) 148 | 149 | if not to_uninstall: 150 | LOGGER.info("No runtimes selected to uninstall.") 151 | return 152 | elif cmd.confirm: 153 | if len(to_uninstall) == 1: 154 | if not cmd.ask_yn("Uninstall %s?", to_uninstall[0]["display-name"]): 155 | return 156 | else: 157 | msg = ", ".join(i["display-name"] for i in to_uninstall) 158 | if not cmd.ask_yn("Uninstall these runtimes: %s?", msg): 159 | return 160 | 161 | for i in to_uninstall: 162 | LOGGER.debug("Uninstalling %s from %s", i["display-name"], i["prefix"]) 163 | try: 164 | rmtree( 165 | i["prefix"], 166 | after_5s_warning=warn_msg.format(i["display-name"]), 167 | remove_ext_first=("exe", "dll", "json"), 168 | ) 169 | except FilesInUseError as ex: 170 | LOGGER.error("Could not uninstall %s because it is still in use.", 171 | i["display-name"]) 172 | raise SystemExit(1) from ex 173 | LOGGER.info("Removed %s", i["display-name"]) 174 | try: 175 | for target in cmd.global_dir.glob("*.__target__"): 176 | alias = target.with_suffix("") 177 | entry = target.read_text(encoding="utf-8-sig", errors="strict") 178 | if PurePath(entry).match(i["executable"]): 179 | LOGGER.debug("Unlink %s", alias) 180 | unlink(alias, after_5s_warning=warn_msg.format(alias)) 181 | unlink(target, after_5s_warning=warn_msg.format(target)) 182 | except OSError as ex: 183 | LOGGER.warn("Failed to remove alias: %s", ex) 184 | LOGGER.debug("TRACEBACK:", exc_info=True) 185 | 186 | if to_uninstall: 187 | update_all_shortcuts(cmd) 188 | 189 | LOGGER.debug("END uninstall_command.execute") 190 | -------------------------------------------------------------------------------- /tests/test_list.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import re 3 | 4 | from itertools import zip_longest 5 | 6 | from manage import list_command 7 | from manage.indexutils import Index 8 | 9 | FAKE_INSTALLS = [ 10 | {"company": "Company2", "tag": "1.0", "sort-version": "1.0"}, 11 | {"company": "Company1", "tag": "2.0", "sort-version": "2.0"}, 12 | {"company": "Company1", "tag": "1.0", "sort-version": "1.0", "default": True}, 13 | ] 14 | 15 | FAKE_INDEX = { 16 | "versions": [ 17 | { 18 | **{k: v for k, v in i.items() if k not in ("default",)}, 19 | "schema": 1, 20 | "id": f"{i['company']}-{i['tag']}", 21 | "install-for": [i["tag"]], 22 | } for i in FAKE_INSTALLS 23 | ] 24 | } 25 | 26 | class ListCapture: 27 | def __init__(self): 28 | self.args = [] 29 | # Out of order on purpose - list_command should not modify order 30 | self.installs = FAKE_INSTALLS 31 | self.captured = [] 32 | self.source = None 33 | self.install_dir = "" 34 | self.default_platform = "-64" 35 | self.format = "test" 36 | self.formatter_callable = None 37 | self.one = False 38 | self.unmanaged = True 39 | list_command.FORMATTERS["test"] = lambda c, i: self.captured.extend(i) 40 | 41 | def __call__(self, *filters, **kwargs): 42 | self.args = filters 43 | for k, v in kwargs.items(): 44 | if not hasattr(self, k): 45 | raise TypeError(f"command has no option {k!r}") 46 | setattr(self, k, v) 47 | self.captured.clear() 48 | list_command.execute(self) 49 | return [f"{i['company']}/{i['tag']}" for i in self.captured] 50 | 51 | def get_installs(self, include_unmanaged=False): 52 | assert include_unmanaged == self.unmanaged 53 | return self.installs 54 | 55 | 56 | @pytest.fixture 57 | def list_cmd(): 58 | try: 59 | yield ListCapture() 60 | finally: 61 | list_command.FORMATTERS.pop("test", None) 62 | 63 | 64 | def test_list(list_cmd): 65 | # list_command does not sort its entries - get_installs() does that 66 | assert list_cmd() == [ 67 | "Company2/1.0", 68 | "Company1/2.0", 69 | "Company1/1.0", 70 | ] 71 | # unmanaged doesn't affect our result (because we shim the function that 72 | # does the check), but it'll at least ensure it gets passed through. 73 | assert list_cmd(unmanaged=True) 74 | 75 | 76 | def test_list_filter(list_cmd): 77 | assert list_cmd("2.0") == ["Company1/2.0"] 78 | assert list_cmd("1.0") == ["Company2/1.0", "Company1/1.0"] 79 | assert list_cmd("Company1/") == ["Company1/2.0", "Company1/1.0"] 80 | assert list_cmd("Company1\\") == ["Company1/2.0", "Company1/1.0"] 81 | assert list_cmd("Company\\") == ["Company2/1.0", "Company1/2.0", "Company1/1.0"] 82 | 83 | assert list_cmd(">1") == ["Company1/2.0"] 84 | assert list_cmd(">=1") == ["Company2/1.0", "Company1/2.0", "Company1/1.0"] 85 | assert list_cmd("<=2") == ["Company2/1.0", "Company1/2.0", "Company1/1.0"] 86 | assert list_cmd("<2") == ["Company2/1.0", "Company1/1.0"] 87 | 88 | assert list_cmd("1", "2") == ["Company2/1.0", "Company1/2.0", "Company1/1.0"] 89 | assert list_cmd("Company1\\1", "Company2\\1") == ["Company2/1.0", "Company1/1.0"] 90 | 91 | 92 | def test_list_one(list_cmd): 93 | assert list_cmd("2", one=True) == ["Company1/2.0"] 94 | # Gets Company1/1.0 because it's marked as default 95 | assert list_cmd("1", one=True) == ["Company1/1.0"] 96 | 97 | 98 | def from_index(*filters): 99 | return [ 100 | f"{i['company']}/{i['tag']}" 101 | for i in list_command._get_installs_from_index([Index("./index.json", FAKE_INDEX)], filters) 102 | ] 103 | 104 | 105 | def test_list_online(): 106 | assert from_index("Company1\\2.0") == ["Company1/2.0"] 107 | assert from_index("Company2\\", "Company1\\1") == ["Company2/1.0", "Company1/1.0"] 108 | assert from_index() == ["Company1/2.0", "Company2/1.0", "Company1/1.0"] 109 | 110 | 111 | def test_format_table(assert_log): 112 | list_command.format_table(None, FAKE_INSTALLS) 113 | assert_log( 114 | (r"!B!Tag\s+Name\s+Managed By\s+Version\s+Alias\s*!W!", ()), 115 | (r"Company2\\1\.0\s+Company2\s+1\.0", ()), 116 | (r"Company1\\2\.0\s+Company1\s+2\.0", ()), 117 | (r"!G!Company1\\1\.0\s+\*\s+Company1\s+1\.0\s*!W!", ()), 118 | ) 119 | 120 | 121 | def test_format_table_aliases(assert_log): 122 | list_command.format_table(None, [ 123 | { 124 | "company": "COMPANY", 125 | "tag": "TAG", 126 | "display-name": "DISPLAY", 127 | "sort-version": "VER", 128 | "alias": [ 129 | {"name": "python.exe"}, 130 | {"name": "pythonw.exe"}, 131 | {"name": "python3.10.exe"}, 132 | {"name": "pythonw3.10.exe"}, 133 | ], 134 | } 135 | ]) 136 | assert_log( 137 | (r"!B!Tag\s+Name\s+Managed By\s+Version\s+Alias\s*!W!", ()), 138 | (r"COMPANY\\TAG\s+DISPLAY\s+COMPANY\s+VER\s+" + re.escape("python[w].exe, python[w]3.10.exe"), ()), 139 | ) 140 | 141 | 142 | def test_format_table_truncated(assert_log): 143 | list_command.format_table(None, [ 144 | { 145 | "company": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4, 146 | "tag": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4, 147 | "display-name": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4, 148 | "sort-version": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4, 149 | "alias": [ 150 | {"name": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4}, 151 | ], 152 | } 153 | ]) 154 | assert_log( 155 | (r"!B!Tag\s+Name\s+Managed By\s+Version\s+Alias\s*!W!", ()), 156 | (r"\w{27}\.\.\.\s+\w{57}\.\.\.\s+\w{27}\.\.\.\s+\w{12}\.\.\.\s+\w{47}\.\.\.", ()), 157 | (r"", ()), 158 | (r".+columns were truncated.+", ()), 159 | ) 160 | 161 | 162 | def test_format_table_empty(assert_log): 163 | list_command.format_table(None, []) 164 | assert_log( 165 | (r"!B!Tag\s+Name\s+Managed By\s+Version\s+Alias\s*!W!", ()), 166 | (r".+No runtimes.+", ()), 167 | ) 168 | 169 | 170 | def test_format_csv(assert_log): 171 | list_command.format_csv(None, FAKE_INSTALLS) 172 | # CSV format only contains columns that are present, so this doesn't look 173 | # as complete as for normal installs, but it's fine for the test. 174 | assert_log( 175 | "company,tag,sort-version,default", 176 | "Company2,1.0,1.0,", 177 | "Company1,2.0,2.0,", 178 | "Company1,1.0,1.0,True", 179 | ) 180 | 181 | 182 | def test_format_csv_complex(assert_log): 183 | data = [ 184 | { 185 | **d, 186 | "alias": [dict(name=f"n{i}.{j}", target=f"t{i}.{j}") for j in range(i + 1)] 187 | } 188 | for i, d in enumerate(FAKE_INSTALLS) 189 | ] 190 | list_command.format_csv(None, data) 191 | assert_log( 192 | "company,tag,sort-version,alias.name,alias.target.default", 193 | "Company2,1.0,1.0,n0.0,t0.0,", 194 | "Company1,2.0,2.0,n1.0,t1.0,", 195 | "Company1,2.0,2.0,n1.1,t1.1,", 196 | "Company1,1.0,1.0,n2.0,t2.0,True", 197 | "Company1,1.0,1.0,n2.1,t2.1,True", 198 | "Company1,1.0,1.0,n2.2,t2.2,True", 199 | ) 200 | 201 | 202 | def test_format_csv_empty(assert_log): 203 | list_command.format_csv(None, []) 204 | assert_log(assert_log.end_of_log()) 205 | 206 | 207 | def test_csv_exclude(): 208 | result = list(list_command._csv_filter_and_expand([ 209 | dict(a=1, b=2), 210 | dict(a=3, c=4), 211 | dict(a=5, b=6, c=7), 212 | ], exclude={"b"})) 213 | assert result == [dict(a=1), dict(a=3, c=4), dict(a=5, c=7)] 214 | 215 | 216 | def test_csv_expand(): 217 | result = list(list_command._csv_filter_and_expand([ 218 | dict(a=[1, 2], b=[3, 4]), 219 | dict(a=[5], b=[6]), 220 | dict(a=7, b=8), 221 | ], expand={"a"})) 222 | assert result == [ 223 | dict(a=1, b=[3, 4]), 224 | dict(a=2, b=[3, 4]), 225 | dict(a=5, b=[6]), 226 | dict(a=7, b=8), 227 | ] 228 | 229 | 230 | def test_formats(assert_log): 231 | list_command.list_formats(None, ["fake", "installs", "that", "should", "crash", 123]) 232 | assert_log( 233 | r".*Format\s+Description", 234 | r"table\s+Lists.+", 235 | # Assume the rest are okay 236 | ) 237 | -------------------------------------------------------------------------------- /src/pyshellext/shellext_test.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "shellext.h" 4 | #include "..\_native\helpers.h" 5 | 6 | extern "C" { 7 | 8 | PyObject *shellext_RegReadStr(PyObject *, PyObject *args, PyObject *) 9 | { 10 | HKEY hkey; 11 | PyObject *hkeyObj; 12 | wchar_t *valueName; 13 | if (!PyArg_ParseTuple(args, "OO&", &hkeyObj, as_utf16, &valueName)) { 14 | return NULL; 15 | } 16 | if (!PyLong_AsNativeBytes(hkeyObj, &hkey, sizeof(hkey), -1)) { 17 | PyMem_Free(valueName); 18 | return NULL; 19 | } 20 | 21 | PyObject *r = NULL; 22 | std::wstring result; 23 | int err = (int)RegReadStr(hkey, valueName, result); 24 | if (err) { 25 | PyErr_SetFromWindowsErr(err); 26 | } else { 27 | r = PyUnicode_FromWideChar(result.data(), result.size()); 28 | } 29 | 30 | PyMem_Free(valueName); 31 | return r; 32 | } 33 | 34 | 35 | PyObject *shellext_ReadIdleInstalls(PyObject *, PyObject *args, PyObject *) 36 | { 37 | HKEY hkey; 38 | wchar_t *company; 39 | REGSAM flags; 40 | PyObject *hkeyObj, *flagsObj; 41 | if (!PyArg_ParseTuple(args, "OO&O", &hkeyObj, as_utf16, &company, &flagsObj)) { 42 | return NULL; 43 | } 44 | if (!PyLong_AsNativeBytes(hkeyObj, &hkey, sizeof(hkey), -1) || 45 | !PyLong_AsNativeBytes(flagsObj, &flags, sizeof(flags), -1)) { 46 | PyMem_Free(company); 47 | return NULL; 48 | } 49 | 50 | PyObject *r = NULL; 51 | std::vector result; 52 | HRESULT hr = ReadIdleInstalls(result, hkey, company, flags); 53 | 54 | if (FAILED(hr)) { 55 | PyErr_SetFromWindowsErr((int)hr); 56 | } else { 57 | r = PyList_New(0); 58 | for (auto &i : result) { 59 | PyObject *o = Py_BuildValue("uuu", i.title.c_str(), i.exe.c_str(), i.idle.c_str()); 60 | if (!o) { 61 | Py_CLEAR(r); 62 | break; 63 | } 64 | if (PyList_Append(r, o) < 0) { 65 | Py_DECREF(o); 66 | Py_CLEAR(r); 67 | break; 68 | } 69 | Py_DECREF(o); 70 | } 71 | } 72 | 73 | PyMem_Free(company); 74 | return r; 75 | } 76 | 77 | 78 | PyObject *shellext_ReadAllIdleInstalls(PyObject *, PyObject *args, PyObject *) 79 | { 80 | HKEY hkey; 81 | REGSAM flags; 82 | PyObject *hkeyObj, *flagsObj; 83 | if (!PyArg_ParseTuple(args, "OO", &hkeyObj, &flagsObj)) { 84 | return NULL; 85 | } 86 | if (!PyLong_AsNativeBytes(hkeyObj, &hkey, sizeof(hkey), -1) || 87 | !PyLong_AsNativeBytes(flagsObj, &flags, sizeof(flags), -1)) { 88 | return NULL; 89 | } 90 | 91 | PyObject *r = NULL; 92 | std::vector result; 93 | HRESULT hr = ReadAllIdleInstalls(result, hkey, NULL, flags); 94 | 95 | if (FAILED(hr)) { 96 | PyErr_SetFromWindowsErr((int)hr); 97 | } else { 98 | r = PyList_New(0); 99 | for (auto &i : result) { 100 | PyObject *o = Py_BuildValue("uuu", i.title.c_str(), i.exe.c_str(), i.idle.c_str()); 101 | if (!o) { 102 | Py_CLEAR(r); 103 | break; 104 | } 105 | if (PyList_Append(r, o) < 0) { 106 | Py_DECREF(o); 107 | Py_CLEAR(r); 108 | break; 109 | } 110 | Py_DECREF(o); 111 | } 112 | } 113 | 114 | return r; 115 | } 116 | 117 | 118 | PyObject *shellext_PassthroughTitle(PyObject *, PyObject *args, PyObject *) 119 | { 120 | wchar_t *value; 121 | if (!PyArg_ParseTuple(args, "O&", as_utf16, &value)) { 122 | return NULL; 123 | } 124 | 125 | PyObject *r = NULL; 126 | IExplorerCommand *cmd = MakeLaunchCommand(value, L"", L""); 127 | wchar_t *title; 128 | HRESULT hr = cmd->GetTitle(NULL, &title); 129 | if (SUCCEEDED(hr)) { 130 | r = PyUnicode_FromWideChar(title, -1); 131 | CoTaskMemFree((void*)title); 132 | } else { 133 | PyErr_SetFromWindowsErr((int)hr); 134 | } 135 | cmd->Release(); 136 | return r; 137 | } 138 | 139 | 140 | PyObject *shellext_IdleCommand(PyObject *, PyObject *args, PyObject *) 141 | { 142 | HKEY hkey; 143 | REGSAM flags; 144 | PyObject *hkeyObj, *flagsObj; 145 | if (!PyArg_ParseTuple(args, "O", &hkeyObj)) { 146 | return NULL; 147 | } 148 | if (!PyLong_AsNativeBytes(hkeyObj, &hkey, sizeof(hkey), -1)) { 149 | return NULL; 150 | } 151 | 152 | IExplorerCommand *cmd = MakeIdleCommand(hkey, NULL); 153 | IEnumExplorerCommand *enm = NULL; 154 | PyObject *r = PyList_New(0); 155 | PyObject *o; 156 | wchar_t *s; 157 | HRESULT hr; 158 | ULONG fetched; 159 | 160 | hr = cmd->GetTitle(NULL, &s); 161 | if (SUCCEEDED(hr)) { 162 | o = PyUnicode_FromWideChar(s, -1); 163 | if (!o || PyList_Append(r, o) < 0) { 164 | goto abort; 165 | } 166 | Py_CLEAR(o); 167 | CoTaskMemFree((void *)s); 168 | s = NULL; 169 | } else { 170 | goto abort; 171 | } 172 | 173 | hr = cmd->GetIcon(NULL, &s); 174 | if (SUCCEEDED(hr)) { 175 | o = PyUnicode_FromWideChar(s, -1); 176 | if (!o || PyList_Append(r, o) < 0) { 177 | goto abort; 178 | } 179 | Py_CLEAR(o); 180 | CoTaskMemFree((void *)s); 181 | s = NULL; 182 | } else { 183 | goto abort; 184 | } 185 | 186 | hr = cmd->EnumSubCommands(&enm); 187 | cmd->Release(); 188 | cmd = NULL; 189 | if (FAILED(hr)) { 190 | goto abort; 191 | } 192 | 193 | while ((hr = enm->Next(1, &cmd, &fetched)) == S_OK) { 194 | if (fetched != 1) { 195 | PyErr_SetString(PyExc_RuntimeError, "'fetched' was not 1"); 196 | goto abort; 197 | } 198 | 199 | hr = cmd->GetTitle(NULL, &s); 200 | if (SUCCEEDED(hr)) { 201 | o = PyUnicode_FromWideChar(s, -1); 202 | if (!o || PyList_Append(r, o) < 0) { 203 | goto abort; 204 | } 205 | Py_CLEAR(o); 206 | CoTaskMemFree((void *)s); 207 | s = NULL; 208 | } else { 209 | goto abort; 210 | } 211 | 212 | cmd->Release(); 213 | cmd = NULL; 214 | } 215 | if (FAILED(hr)) { 216 | goto abort; 217 | } 218 | 219 | enm->Release(); 220 | enm = NULL; 221 | 222 | return r; 223 | 224 | abort: 225 | Py_XDECREF(o); 226 | Py_XDECREF(r); 227 | CoTaskMemFree((void *)s); 228 | if (enm) { 229 | enm->Release(); 230 | } 231 | if (cmd) { 232 | cmd->Release(); 233 | } 234 | if (FAILED(hr)) { 235 | PyErr_SetFromWindowsErr((int)hr); 236 | } 237 | return NULL; 238 | } 239 | 240 | 241 | PyObject *shellext_GetDropArgumentsW(PyObject *, PyObject *args, PyObject *) 242 | { 243 | Py_buffer value; 244 | if (!PyArg_ParseTuple(args, "y*", &value)) { 245 | return NULL; 246 | } 247 | PyObject *r = NULL; 248 | std::wstring parsed; 249 | HRESULT hr = GetDropArgumentsW((wchar_t *)value.buf, parsed); 250 | PyBuffer_Release(&value); 251 | if (SUCCEEDED(hr)) { 252 | r = PyUnicode_FromWideChar(parsed.data(), parsed.size()); 253 | } else { 254 | PyErr_SetFromWindowsErr((int)hr); 255 | } 256 | return r; 257 | } 258 | 259 | PyObject *shellext_GetDropArgumentsA(PyObject *, PyObject *args, PyObject *) 260 | { 261 | Py_buffer value; 262 | if (!PyArg_ParseTuple(args, "y*", &value)) { 263 | return NULL; 264 | } 265 | PyObject *r = NULL; 266 | std::wstring parsed; 267 | HRESULT hr = GetDropArgumentsA((char *)value.buf, parsed); 268 | PyBuffer_Release(&value); 269 | if (SUCCEEDED(hr)) { 270 | r = PyUnicode_FromWideChar(parsed.data(), parsed.size()); 271 | } else { 272 | PyErr_SetFromWindowsErr((int)hr); 273 | } 274 | return r; 275 | } 276 | 277 | PyObject *shellext_GetDropDescription(PyObject *, PyObject *args, PyObject *) 278 | { 279 | wchar_t *value1; 280 | Py_ssize_t value2; 281 | if (!PyArg_ParseTuple(args, "O&n", as_utf16, &value1, &value2)) { 282 | return NULL; 283 | } 284 | PyObject *r = NULL; 285 | std::wstring parsed1, parsed2; 286 | HRESULT hr = GetDropDescription(value1, value2, parsed1, parsed2); 287 | if (SUCCEEDED(hr)) { 288 | r = Py_BuildValue( 289 | "u#u#", 290 | parsed1.data(), (Py_ssize_t)parsed1.size(), 291 | parsed2.data(), (Py_ssize_t)parsed2.size() 292 | ); 293 | } else { 294 | PyErr_SetFromWindowsErr((int)hr); 295 | } 296 | return r; 297 | } 298 | 299 | 300 | } 301 | -------------------------------------------------------------------------------- /src/manage/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import winreg 5 | 6 | from .exceptions import InvalidConfigurationError 7 | from .logging import LOGGER 8 | from .pathutils import Path 9 | 10 | 11 | DEFAULT_CONFIG_NAME = "pymanager.json" 12 | 13 | 14 | def config_append(x, y): 15 | if x is None: 16 | return [y] 17 | if isinstance(x, list): 18 | return [*x, y] 19 | return [x, y] 20 | 21 | 22 | def config_split(x): 23 | import re 24 | return re.split("[;:|,+]", x) 25 | 26 | 27 | def config_split_append(x, y): 28 | return config_append(x, config_split(y)) 29 | 30 | 31 | def config_bool(v): 32 | if not v: 33 | return False 34 | if isinstance(v, str): 35 | return v.lower().startswith(("t", "y", "1")) 36 | return bool(v) 37 | 38 | 39 | def _is_valid_url(u): 40 | from .urlutils import is_valid_url 41 | return is_valid_url(u) 42 | 43 | 44 | def load_global_config(cfg, schema): 45 | try: 46 | from _native import package_get_root 47 | except ImportError: 48 | file = Path(sys.executable).parent / DEFAULT_CONFIG_NAME 49 | else: 50 | file = Path(package_get_root()) / DEFAULT_CONFIG_NAME 51 | try: 52 | load_one_config(cfg, file, schema=schema) 53 | except FileNotFoundError: 54 | pass 55 | 56 | 57 | def load_config(root, override_file, schema): 58 | cfg = {} 59 | 60 | load_global_config(cfg, schema=schema) 61 | 62 | try: 63 | reg_cfg = load_registry_config(cfg["registry_override_key"], schema=schema) 64 | merge_config(cfg, reg_cfg, schema=schema, source="registry", overwrite=True) 65 | except LookupError: 66 | reg_cfg = {} 67 | 68 | for source, overwrite in [ 69 | ("base_config", True), 70 | ("user_config", False), 71 | ("additional_config", False), 72 | ]: 73 | try: 74 | try: 75 | file = reg_cfg[source] 76 | except LookupError: 77 | file = cfg[source] 78 | except LookupError: 79 | pass 80 | else: 81 | if file: 82 | load_one_config(cfg, file, schema=schema, overwrite=overwrite) 83 | 84 | if reg_cfg: 85 | # Apply the registry overrides one more time 86 | merge_config(cfg, reg_cfg, schema=schema, source="registry", overwrite=True) 87 | 88 | if override_file: 89 | load_one_config(cfg, override_file, schema=schema, overwrite=True) 90 | 91 | return cfg 92 | 93 | 94 | def load_one_config(cfg, file, schema, *, overwrite=False): 95 | try: 96 | with open(file, "r", encoding="utf-8-sig") as f: 97 | LOGGER.verbose("Loading configuration from %s", file) 98 | cfg2 = json.load(f) 99 | except FileNotFoundError: 100 | LOGGER.verbose("Skipping configuration at %s because it does not exist", file) 101 | return 102 | except OSError as ex: 103 | LOGGER.warn("Failed to read configuration from %s: %s", file, ex) 104 | LOGGER.debug("TRACEBACK:", exc_info=True) 105 | return 106 | except ValueError as ex: 107 | LOGGER.warn("Error reading configuration from %s: %s", file, ex) 108 | LOGGER.debug("TRACEBACK:", exc_info=True) 109 | return 110 | cfg2["_config_files"] = file 111 | resolve_config(cfg2, file, Path(file).absolute().parent, schema=schema) 112 | merge_config(cfg, cfg2, schema=schema, source=file, overwrite=overwrite) 113 | 114 | 115 | def load_registry_config(key_path, schema): 116 | hive_name, _, key_name = key_path.replace("/", "\\").partition("\\") 117 | hive = getattr(winreg, hive_name) 118 | cfg = {} 119 | try: 120 | key = winreg.OpenKey(hive, key_name) 121 | except FileNotFoundError: 122 | return cfg 123 | with key: 124 | for i in range(10000): 125 | try: 126 | name, value, vt = winreg.EnumValue(key, i) 127 | except OSError: 128 | break 129 | bits = name.split(".") 130 | subcfg = cfg 131 | for b in bits[:-1]: 132 | subcfg = subcfg.setdefault(b, {}) 133 | subcfg[bits[-1]] = value 134 | else: 135 | LOGGER.warn("Too many registry values were read from %s. " + 136 | "This is very unexpected. Please check your configuration " + 137 | "or report an issue at https://github.com/python/pymanager.", 138 | key_path) 139 | 140 | try: 141 | from _native import package_get_root 142 | root = Path(package_get_root()) 143 | except ImportError: 144 | root = Path(sys.executable).parent 145 | resolve_config(cfg, key_path, root, schema=schema, error_unknown=True) 146 | return cfg 147 | 148 | 149 | def _expand_vars(v, env): 150 | import re 151 | def _sub(m): 152 | v2 = env.get(m.group(1)) 153 | if v2: 154 | return v2 + (m.group(2) or "") 155 | return "" 156 | v2 = re.sub(r"%(.*?)%([\\/])?", _sub, v) 157 | return v2 158 | 159 | 160 | def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_unknown=False): 161 | for k, v in list(cfg.items()): 162 | if k.startswith("#"): 163 | continue 164 | 165 | try: 166 | subschema = schema[k] 167 | except LookupError: 168 | if error_unknown: 169 | raise InvalidConfigurationError(source, key_so_far + k) 170 | LOGGER.verbose("Ignoring unknown configuration %s%s in %s", key_so_far, k, source) 171 | continue 172 | 173 | if isinstance(subschema, dict): 174 | if not isinstance(v, dict): 175 | raise InvalidConfigurationError(source, key_so_far + k, v) 176 | resolve_config(v, source, relative_to, f"{key_so_far}{k}.", subschema) 177 | continue 178 | 179 | kind, merge, *opts = subschema 180 | from_env = False 181 | if "env" in opts and isinstance(v, str): 182 | try: 183 | orig_v = v 184 | v = _expand_vars(v, os.environ) 185 | from_env = orig_v != v 186 | except TypeError: 187 | pass 188 | if not v: 189 | del cfg[k] 190 | continue 191 | try: 192 | v = kind(v) 193 | except (TypeError, ValueError): 194 | raise InvalidConfigurationError(source, key_so_far + k, v) 195 | if v and "path" in opts and not _is_valid_url(v): 196 | # Paths from the config file are relative to the config file. 197 | # Paths from the environment are relative to the current working dir 198 | if not from_env: 199 | v = relative_to / v 200 | else: 201 | v = type(relative_to)(v).absolute() 202 | if v and "uri" in opts: 203 | if hasattr(v, "as_uri"): 204 | v = v.as_uri() 205 | else: 206 | v = str(v) 207 | if not _is_valid_url(v): 208 | raise InvalidConfigurationError(source, key_so_far + k, v) 209 | cfg[k] = v 210 | 211 | 212 | def merge_config(into_cfg, from_cfg, schema, *, source="", overwrite=False): 213 | for k, v in from_cfg.items(): 214 | if k.startswith("#"): 215 | continue 216 | try: 217 | into = into_cfg[k] 218 | except LookupError: 219 | LOGGER.debug("Setting config %s to %r", k, v) 220 | into_cfg[k] = v 221 | continue 222 | 223 | try: 224 | subschema = schema[k] 225 | except LookupError: 226 | # No schema information, so let's just replace 227 | LOGGER.warn("Unknown configuration key %s in %s", k, source) 228 | into_cfg[k] = v 229 | continue 230 | 231 | if isinstance(subschema, dict): 232 | if isinstance(into, dict) and isinstance(v, dict): 233 | LOGGER.debug("Recursively updating config %s", k) 234 | merge_config(into, v, subschema, source=source, overwrite=overwrite) 235 | else: 236 | # Source isn't recursing, so let's ignore 237 | # Should have been validated earlier 238 | LOGGER.warn("Invalid configuration key %s in %s", k, source) 239 | continue 240 | 241 | _, merge, *_ = subschema 242 | if not merge or overwrite: 243 | LOGGER.debug("Updating config %s from %r to %r", k, into, v) 244 | into_cfg[k] = v 245 | else: 246 | v2 = merge(into, v) 247 | LOGGER.debug("Updating config %s from %r to %r", k, into, v2) 248 | into_cfg[k] = v2 249 | -------------------------------------------------------------------------------- /ci/upload.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | from pathlib import Path 5 | from urllib.request import Request, urlopen 6 | from xml.etree import ElementTree as ET 7 | 8 | UPLOAD_URL_PREFIX = os.getenv("UPLOAD_URL_PREFIX", "https://www.python.org/ftp/") 9 | UPLOAD_PATH_PREFIX = os.getenv("UPLOAD_PATH_PREFIX", "/srv/www.python.org/ftp/") 10 | UPLOAD_URL = os.getenv("UPLOAD_URL") 11 | UPLOAD_DIR = os.getenv("UPLOAD_DIR", "dist") 12 | UPLOAD_HOST = os.getenv("UPLOAD_HOST", "") 13 | UPLOAD_HOST_KEY = os.getenv("UPLOAD_HOST_KEY", "") 14 | UPLOAD_KEYFILE = os.getenv("UPLOAD_KEYFILE", "") 15 | UPLOAD_USER = os.getenv("UPLOAD_USER", "") 16 | NO_UPLOAD = os.getenv("NO_UPLOAD", "no")[:1].lower() in "yt1" 17 | 18 | # Set to 'true' when updating index.json, rather than the app 19 | UPLOADING_INDEX = os.getenv("UPLOADING_INDEX", "no")[:1].lower() in "yt1" 20 | 21 | 22 | if not UPLOAD_URL: 23 | print("##[error]Cannot upload without UPLOAD_URL") 24 | sys.exit(1) 25 | 26 | 27 | def find_cmd(env, exe): 28 | cmd = os.getenv(env) 29 | if cmd: 30 | return Path(cmd) 31 | for p in os.getenv("PATH", "").split(";"): 32 | if p: 33 | cmd = Path(p) / exe 34 | if cmd.is_file(): 35 | return cmd 36 | if UPLOAD_HOST: 37 | raise RuntimeError( 38 | f"Could not find {exe} to perform upload. Try setting %{env}% or %PATH%" 39 | ) 40 | print(f"Did not find {exe}, but not uploading anyway.") 41 | 42 | 43 | PLINK = find_cmd("PLINK", "plink.exe") 44 | PSCP = find_cmd("PSCP", "pscp.exe") 45 | 46 | 47 | def _std_args(cmd): 48 | if not cmd: 49 | raise RuntimeError("Cannot upload because command is missing") 50 | all_args = [cmd, "-batch"] 51 | if UPLOAD_HOST_KEY: 52 | all_args.append("-hostkey") 53 | all_args.append(UPLOAD_HOST_KEY) 54 | if UPLOAD_KEYFILE: 55 | all_args.append("-noagent") 56 | all_args.append("-i") 57 | all_args.append(UPLOAD_KEYFILE) 58 | return all_args 59 | 60 | 61 | class RunError(Exception): 62 | pass 63 | 64 | 65 | def _run(*args): 66 | with subprocess.Popen( 67 | args, 68 | stdout=subprocess.PIPE, 69 | stderr=subprocess.STDOUT, 70 | encoding="ascii", 71 | errors="replace", 72 | ) as p: 73 | out, _ = p.communicate(None) 74 | if out: 75 | print(out.encode("ascii", "replace").decode("ascii")) 76 | if p.returncode: 77 | raise RunError(p.returncode, out) 78 | 79 | 80 | def call_ssh(*args, allow_fail=True): 81 | if not UPLOAD_HOST or NO_UPLOAD: 82 | print("Skipping", args, "because UPLOAD_HOST is missing") 83 | return 84 | try: 85 | _run(*_std_args(PLINK), f"{UPLOAD_USER}@{UPLOAD_HOST}", *args) 86 | except RunError: 87 | if not allow_fail: 88 | raise 89 | 90 | 91 | def upload_ssh(source, dest): 92 | if not UPLOAD_HOST or NO_UPLOAD: 93 | print("Skipping upload of", source, "because UPLOAD_HOST is missing") 94 | return 95 | _run(*_std_args(PSCP), source, f"{UPLOAD_USER}@{UPLOAD_HOST}:{dest}") 96 | call_ssh(f"chgrp downloads {dest} && chmod g-x,o+r {dest}") 97 | 98 | 99 | def download_ssh(source, dest): 100 | if not UPLOAD_HOST: 101 | print("Skipping download of", source, "because UPLOAD_HOST is missing") 102 | return 103 | Path(dest).parent.mkdir(exist_ok=True, parents=True) 104 | _run(*_std_args(PSCP), f"{UPLOAD_USER}@{UPLOAD_HOST}:{source}", dest) 105 | 106 | 107 | def ls_ssh(dest): 108 | if not UPLOAD_HOST: 109 | print("Skipping ls of", dest, "because UPLOAD_HOST is missing") 110 | return 111 | try: 112 | _run(*_std_args(PSCP), "-ls", f"{UPLOAD_USER}@{UPLOAD_HOST}:{dest}") 113 | except RunError as ex: 114 | if not ex.args[1].rstrip().endswith("No such file or directory"): 115 | raise 116 | print(dest, "was not found") 117 | 118 | 119 | def url2path(url): 120 | if not UPLOAD_URL_PREFIX: 121 | raise ValueError("%UPLOAD_URL_PREFIX% was not set") 122 | if not url: 123 | raise ValueError("Unexpected empty URL") 124 | if not url.startswith(UPLOAD_URL_PREFIX): 125 | raise ValueError(f"Unexpected URL: {url}") 126 | return UPLOAD_PATH_PREFIX + url[len(UPLOAD_URL_PREFIX) :] 127 | 128 | 129 | def appinstaller_uri_matches(file, name): 130 | NS = {} 131 | with open(file, "r", encoding="utf-8") as f: 132 | NS = dict(e for _, e in ET.iterparse(f, events=("start-ns",))) 133 | for k, v in NS.items(): 134 | ET.register_namespace(k, v) 135 | NS["x"] = NS[""] 136 | 137 | with open(file, "r", encoding="utf-8") as f: 138 | xml = ET.parse(f) 139 | 140 | self_uri = xml.find(".[@Uri]", NS).get("Uri") 141 | if not self_uri: 142 | print("##[error]Empty Uri attribute in appinstaller file") 143 | sys.exit(2) 144 | 145 | return self_uri.rpartition("/")[2].casefold() == name.casefold() 146 | 147 | 148 | def validate_appinstaller(file, uploads): 149 | NS = {} 150 | with open(file, "r", encoding="utf-8") as f: 151 | NS = dict(e for _, e in ET.iterparse(f, events=("start-ns",))) 152 | for k, v in NS.items(): 153 | ET.register_namespace(k, v) 154 | NS["x"] = NS[""] 155 | 156 | with open(file, "r", encoding="utf-8") as f: 157 | xml = ET.parse(f) 158 | 159 | self_uri = xml.find(".[@Uri]", NS).get("Uri") 160 | if not self_uri: 161 | print("##[error]Empty Uri attribute in appinstaller file") 162 | sys.exit(2) 163 | upload_targets = [u for f, u, _ in uploads if f == file] 164 | if not any(u.casefold() == self_uri.casefold() for u in upload_targets): 165 | print("##[error]Uri", self_uri, "in appinstaller file is not where " 166 | "the appinstaller file is being uploaded.") 167 | sys.exit(2) 168 | 169 | main = xml.find("x:MainPackage[@Uri]", NS) 170 | if main is None: 171 | print("##[error]No MainPackage element with Uri in appinstaller file") 172 | sys.exit(2) 173 | package_uri = main.get("Uri") 174 | if not package_uri: 175 | print("##[error]Empty Mainpackage.Uri attribute in appinstaller file") 176 | sys.exit(2) 177 | if package_uri.casefold() not in [u.casefold() for _, u, _ in uploads]: 178 | print("##[error]Uri", package_uri, "in appinstaller file is not being uploaded") 179 | sys.exit(2) 180 | 181 | print(file, "checked:") 182 | print("-", package_uri, "is part of this upload") 183 | print("-", self_uri, "is the destination of this file") 184 | if len(upload_targets) > 1: 185 | print(" - other destinations:", *(set(upload_targets) - set([self_uri]))) 186 | print() 187 | 188 | 189 | def purge(url): 190 | if not UPLOAD_HOST or NO_UPLOAD: 191 | print("Skipping purge of", url, "because UPLOAD_HOST is missing") 192 | return 193 | with urlopen(Request(url, method="PURGE", headers={"Fastly-Soft-Purge": 1})) as r: 194 | r.read() 195 | 196 | 197 | UPLOAD_DIR = Path(UPLOAD_DIR).absolute() 198 | UPLOAD_URL = UPLOAD_URL.rstrip("/") + "/" 199 | 200 | UPLOADS = [] 201 | 202 | if UPLOADING_INDEX: 203 | for f in UPLOAD_DIR.glob("*.json"): 204 | u = UPLOAD_URL + f.name 205 | UPLOADS.append((f, u, url2path(u))) 206 | else: 207 | for pat in ("python-manager-*.msix", "python-manager-*.msi"): 208 | for f in UPLOAD_DIR.glob(pat): 209 | u = UPLOAD_URL + f.name 210 | UPLOADS.append((f, u, url2path(u))) 211 | 212 | # pymanager.appinstaller is always uploaded to the pymanager-preview URL, 213 | # and where the file specifies a different location, is also updated as its 214 | # own filename. Later validation checks that the URL listed in the file is 215 | # one of the planned uploads. If we ever need to release an update for the 216 | # "main" line but not prereleases, this code would have to be modified 217 | # (but more likely we'd just immediately modify or replace 218 | # 'pymanager.appinstaller' on the download server). 219 | f = UPLOAD_DIR / "pymanager.appinstaller" 220 | if f.is_file(): 221 | u = UPLOAD_URL + "pymanager-preview.appinstaller" 222 | UPLOADS.append((f, u, url2path(u))) 223 | 224 | if not appinstaller_uri_matches(f, "pymanager-preview.appinstaller"): 225 | u = UPLOAD_URL + f.name 226 | UPLOADS.append((f, u, url2path(u))) 227 | 228 | print("Planned uploads:") 229 | for f, u, p in UPLOADS: 230 | print(f"{f} -> {p}") 231 | print(f" Final URL: {u}") 232 | print() 233 | 234 | for f in {f for f, *_ in UPLOADS if f.match("*.appinstaller")}: 235 | validate_appinstaller(f, UPLOADS) 236 | 237 | for f, u, p in UPLOADS: 238 | print("Upload", f, "to", p) 239 | upload_ssh(f, p) 240 | print("Purge", u) 241 | purge(u) 242 | 243 | # Purge the upload directory so that the FTP browser is up to date 244 | purge(UPLOAD_URL) 245 | --------------------------------------------------------------------------------