├── 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 | [](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 |
--------------------------------------------------------------------------------