├── .github ├── ISSUE_TEMPLATE │ ├── ask-a-question.md │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── _make_helper.py ├── _msbuild.py ├── _msbuild_test.py ├── ci ├── release.yml ├── repartition-index.yml └── upload.py ├── make-all.py ├── make-msi.py ├── make-msix.py ├── make.py ├── pyproject.toml ├── pytest.ini ├── scripts ├── generate-nuget-index.py ├── repartition-index.py └── test-firstrun.py ├── src ├── _native │ ├── __init__.py │ ├── bits.cpp │ ├── helpers.cpp │ ├── helpers.h │ ├── misc.cpp │ ├── paths.cpp │ ├── shortcut.cpp │ └── winhttp.cpp ├── manage │ ├── __init__.py │ ├── __main__.py │ ├── arputils.py │ ├── commands.py │ ├── config.py │ ├── exceptions.py │ ├── firstrun.py │ ├── fsutils.py │ ├── indexutils.py │ ├── install_command.py │ ├── installs.py │ ├── list_command.py │ ├── logging.py │ ├── pathutils.py │ ├── pep514utils.py │ ├── scriptutils.py │ ├── startutils.py │ ├── tagutils.py │ ├── uninstall_command.py │ ├── urlutils.py │ └── verutils.py ├── pymanager.json ├── pymanager │ ├── MsixAppInstallerData.xml │ ├── _launch.cpp │ ├── _launch.h │ ├── _resources │ │ ├── py.ico │ │ ├── pyc.ico │ │ ├── pyd.ico │ │ ├── python.ico │ │ ├── pythonw.ico │ │ ├── pythonwx150.png │ │ ├── pythonwx44.png │ │ ├── pythonx150.png │ │ ├── pythonx44.png │ │ ├── pythonx50.png │ │ ├── pyx256.png │ │ ├── setupx150.png │ │ └── setupx44.png │ ├── appxmanifest.xml │ ├── default.manifest │ ├── launcher.cpp │ ├── main.cpp │ ├── msi.wixproj │ ├── msi.wxs │ ├── pyicon.rc │ ├── pymanager.appinstaller │ ├── pywicon.rc │ ├── resources.xml │ ├── setup.ico │ └── templates │ │ └── template.py └── pyshellext │ ├── default.manifest │ ├── idle.ico │ ├── py.ico │ ├── pyshellext.def │ ├── pyshellext.rc │ ├── python.ico │ ├── pythonw.ico │ ├── shellext.cpp │ ├── shellext.h │ └── shellext_test.cpp └── tests ├── conftest.py ├── localserver.py ├── test_commands.py ├── test_firstrun.py ├── test_fsutils.py ├── test_indexutils.py ├── test_install_command.py ├── test_installs.py ├── test_list.py ├── test_logging.py ├── test_pathutils.py ├── test_pep514utils.py ├── test_scriptutils.py ├── test_shellext.py ├── test_shortcut.py ├── test_tagutils.py ├── test_uninstall_command.py ├── test_urlutils.py └── test_verutils.py /.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 | -------------------------------------------------------------------------------- /.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 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | If you have log files (check your `%TEMP%` directory!), drag-and-drop them here to include them. 25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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.0b2 44 | run: | 45 | nuget install python -Version 3.14.0-b2 -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.0b2 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, 0, 'beta', 2) 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 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: 'Offline bundle download and install' 176 | run: | 177 | pymanager list --online 3 3-32 3-64 3-arm64 178 | pymanager install --download .\bundle 3 3-32 3-64 3-arm64 179 | pymanager list --source .\bundle 180 | pymanager install --source .\bundle 3 3-32 3-64 3-arm64 181 | env: 182 | PYMANAGER_DEBUG: true 183 | 184 | - name: 'Remove MSIX' 185 | run: | 186 | Get-AppxPackage PythonSoftwareFoundation.PythonManager | Remove-AppxPackage 187 | shell: powershell 188 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Install Manager 2 | 3 | [![Codecov](https://codecov.io/gh/python/pymanager/graph/badge.svg)](https://codecov.io/gh/python/pymanager) 4 | 5 | This is the source code for the Python Install Manager app. 6 | 7 | For information about how to use the Python install manager, 8 | including troubleshooting steps, 9 | please refer to the documentation at 10 | [docs.python.org/using/windows](https://docs.python.org/3.14/using/windows.html). 11 | 12 | The original PEP leading to this tool was 13 | [PEP 773](https://peps.python.org/pep-0773/). 14 | 15 | 16 | # Build 17 | 18 | To build and run locally requires [`pymsbuild`](https://pypi.org/project/pymsbuild) 19 | and a Visual Studio installation that includes the C/C++ compilers. 20 | 21 | ``` 22 | > python -m pip install pymsbuild 23 | > python -m pymsbuild 24 | > python-manager\py.exe ... 25 | ``` 26 | 27 | Any modification to a source file requires rebuilding. 28 | The `.py` files are packaged into an extension module. 29 | However, see the following section on tests, as test runs do not require a full 30 | build. 31 | 32 | For additional output, set `%PYMANAGER_DEBUG%` to force debug-level output. 33 | This is the equivalent of passing `-vv`, though it also works for contexts that 34 | do not accept options (such as launching a runtime). 35 | 36 | # Tests 37 | 38 | To run the test suite locally: 39 | 40 | ``` 41 | > python -m pip install pymsbuild pytest 42 | > python -m pymsbuild -c _msbuild_test.py 43 | > python -m pytest 44 | ``` 45 | 46 | This builds the native components separately so that you can quickly iterate on 47 | the Python code. Any updates to the C++ files will require running the 48 | ``pymsbuild`` step again. 49 | 50 | # Package 51 | 52 | To produce an (almost) installer app package: 53 | 54 | ``` 55 | > python -m pip install pymsbuild 56 | > python make-all.py 57 | ``` 58 | 59 | This will rebuild the project and produce MSIX, APPXSYM and MSI packages. 60 | 61 | You will need to sign the MSIX package before you can install it. This can be a 62 | self-signed certificate, but it must be added to your Trusted Publishers. 63 | Alternatively, rename the file to ``.zip`` and extract it to a directory, and 64 | run ``Add-AppxPackage -Register \appxmanifest.xml`` to do a 65 | development install. This should add the global aliases and allow you to test 66 | as if it was properly installed. 67 | 68 | # Contributions 69 | 70 | Contributions are welcome under all the same conditions as for CPython. 71 | 72 | # Release Schedule 73 | 74 | As this project is currently considered to be in prerelease stage, 75 | the release schedule is "as needed". 76 | 77 | The release manager for the Python Install Manager on Windows is whoever is the 78 | build manager for Windows for CPython. 79 | 80 | ## Versioning 81 | 82 | PyManager uses the two digit year as the first part of the version, 83 | with the second part incrementing for each release. 84 | This is to avoid any sense of features being tied to the version number, 85 | and to avoid any direct association with Python releases. 86 | 87 | The two digit year is used because MSI does not support major version fields 88 | over 256. If/when we completely drop the MSI, we could switch to four digit 89 | years, but as long as it exists we have to handle its compatibility constraints. 90 | 91 | 92 | # Copyright and License Information 93 | 94 | Copyright © 2025 Python Software Foundation. All rights reserved. 95 | 96 | See the [LICENSE](https://github.com/python/pymanager/blob/main/LICENSE) for 97 | information on the terms & conditions for usage, and a DISCLAIMER OF ALL 98 | WARRANTIES. 99 | 100 | All trademarks referenced herein are property of their respective holders. -------------------------------------------------------------------------------- /_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 | -------------------------------------------------------------------------------- /_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_get_start_programs'), 48 | CFunction('hide_file'), 49 | CFunction('fd_supports_vt100'), 50 | CFunction('date_as_str'), 51 | CFunction('datetime_as_str'), 52 | CFunction('reg_rename_key'), 53 | CFunction('get_current_package'), 54 | CFunction('read_alias_package'), 55 | CFunction('broadcast_settings_change'), 56 | source='src/_native', 57 | ), 58 | DllPackage('_shellext_test', 59 | PyFile('_native/__init__.py'), 60 | ItemDefinition('ClCompile', 61 | PreprocessorDefinitions=Prepend("PYSHELLEXT_TEST=1;"), 62 | LanguageStandard='stdcpp20', 63 | ), 64 | ItemDefinition('Link', AdditionalDependencies=Prepend("RuntimeObject.lib;")), 65 | CSourceFile('pyshellext/shellext.cpp'), 66 | CSourceFile('pyshellext/shellext_test.cpp'), 67 | IncludeFile('pyshellext/shellext.h'), 68 | CSourceFile('_native/helpers.cpp'), 69 | IncludeFile('_native/helpers.h'), 70 | CFunction('shellext_RegReadStr'), 71 | CFunction('shellext_ReadIdleInstalls'), 72 | CFunction('shellext_ReadAllIdleInstalls'), 73 | CFunction('shellext_PassthroughTitle'), 74 | CFunction('shellext_IdleCommand'), 75 | source='src', 76 | ) 77 | ) 78 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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") 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 validate_appinstaller(file, uploads): 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 | if not any( 145 | u.casefold() == self_uri.casefold() and f == file 146 | for f, u, _ in uploads 147 | ): 148 | print("##[error]Uri", self_uri, "in appinstaller file is not where " 149 | "the appinstaller file is being uploaded.") 150 | sys.exit(2) 151 | 152 | main = xml.find("x:MainPackage[@Uri]", NS) 153 | if main is None: 154 | print("##[error]No MainPackage element with Uri in appinstaller file") 155 | sys.exit(2) 156 | package_uri = main.get("Uri") 157 | if not package_uri: 158 | print("##[error]Empty Mainpackage.Uri attribute in appinstaller file") 159 | sys.exit(2) 160 | if package_uri.casefold() not in [u.casefold() for _, u, _ in uploads]: 161 | print("##[error]Uri", package_uri, "in appinstaller file is not being uploaded") 162 | sys.exit(2) 163 | 164 | print(file, "checked:") 165 | print("-", package_uri, "is part of this upload") 166 | print("-", self_uri, "is the destination of this file") 167 | print() 168 | 169 | 170 | def purge(url): 171 | if not UPLOAD_HOST or NO_UPLOAD: 172 | print("Skipping purge of", url, "because UPLOAD_HOST is missing") 173 | return 174 | with urlopen(Request(url, method="PURGE", headers={"Fastly-Soft-Purge": 1})) as r: 175 | r.read() 176 | 177 | 178 | UPLOAD_DIR = Path(UPLOAD_DIR).absolute() 179 | UPLOAD_URL = UPLOAD_URL.rstrip("/") + "/" 180 | 181 | UPLOADS = [] 182 | 183 | if UPLOADING_INDEX: 184 | for f in UPLOAD_DIR.glob("*.json"): 185 | u = UPLOAD_URL + f.name 186 | UPLOADS.append((f, u, url2path(u))) 187 | else: 188 | for pat in ("python-manager-*.msix", "python-manager-*.msi", "pymanager.appinstaller"): 189 | for f in UPLOAD_DIR.glob(pat): 190 | u = UPLOAD_URL + f.name 191 | UPLOADS.append((f, u, url2path(u))) 192 | 193 | print("Planned uploads:") 194 | for f, u, p in UPLOADS: 195 | print(f"{f} -> {p}") 196 | print(f" Final URL: {u}") 197 | print() 198 | 199 | for f, *_ in UPLOADS: 200 | if f.match("*.appinstaller"): 201 | validate_appinstaller(f, UPLOADS) 202 | 203 | for f, u, p in UPLOADS: 204 | print("Upload", f, "to", p) 205 | upload_ssh(f, p) 206 | print("Purge", u) 207 | purge(u) 208 | 209 | # Purge the upload directory so that the FTP browser is up to date 210 | purge(UPLOAD_URL) 211 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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, unlink 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 | if os.getenv("BUILD_SOURCEBRANCH"): 25 | ref = os.getenv("BUILD_SOURCEBRANCH") 26 | else: 27 | with subprocess.Popen( 28 | ["git", "describe", "HEAD", "--tags"], 29 | stdout=subprocess.PIPE, 30 | stderr=subprocess.PIPE 31 | ) as p: 32 | out, err = p.communicate() 33 | if out: 34 | ref = "refs/tags/" + out.decode().strip() 35 | ref = os.getenv("OVERRIDE_REF", ref) 36 | print("Building for tag", ref) 37 | except subprocess.CalledProcessError: 38 | pass 39 | 40 | # Run main build - this fills in BUILD and LAYOUT 41 | run([sys.executable, "-m", "pymsbuild", "wheel"], 42 | cwd=DIRS["root"], 43 | env={**os.environ, "BUILD_SOURCEBRANCH": ref}) 44 | 45 | # Bundle current latest release 46 | run([LAYOUT / "py-manager.exe", "install", "-v", "-f", "--download", TEMP / "bundle", "default"]) 47 | (LAYOUT / "bundled").mkdir(parents=True, exist_ok=True) 48 | (TEMP / "bundle" / "index.json").rename(LAYOUT / "bundled" / "fallback-index.json") 49 | for f in (TEMP / "bundle").iterdir(): 50 | f.rename(LAYOUT / "bundled" / f.name) 51 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath=src 3 | testpaths=tests 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/_native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/_native/__init__.py -------------------------------------------------------------------------------- /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 (!os_message) { 55 | DWORD len = FormatMessageW( 56 | /* Error API error */ 57 | FORMAT_MESSAGE_ALLOCATE_BUFFER 58 | | FORMAT_MESSAGE_FROM_SYSTEM 59 | | (hModule ? FORMAT_MESSAGE_FROM_HMODULE : 0) 60 | | FORMAT_MESSAGE_IGNORE_INSERTS, 61 | hModule, 62 | error, 63 | MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), 64 | (LPWSTR)&os_message_buffer, 65 | 0, 66 | NULL 67 | ); 68 | if (len) { 69 | while (len > 0 && isspace(os_message_buffer[--len])) { 70 | os_message_buffer[len] = L'\0'; 71 | } 72 | os_message = os_message_buffer; 73 | } 74 | } 75 | 76 | PyObject *msg; 77 | if (message && os_message) { 78 | msg = PyUnicode_FromFormat("%s: %ls", message, os_message); 79 | } else if (os_message) { 80 | msg = PyUnicode_FromWideChar(os_message, -1); 81 | } else if (message) { 82 | msg = PyUnicode_FromString(message); 83 | } else { 84 | msg = PyUnicode_FromString("Unknown error"); 85 | } 86 | 87 | if (msg) { 88 | // Hacky way to get OSError without a direct data reference 89 | // This allows us to delay load the Python DLL 90 | PyObject *builtins = PyEval_GetFrameBuiltins(); 91 | PyObject *oserr = builtins ? PyDict_GetItemString(builtins, "OSError") : NULL; 92 | if (oserr) { 93 | PyObject *exc_args = Py_BuildValue( 94 | "(iOOiO)", 95 | (int)0, 96 | msg, 97 | Py_GetConstantBorrowed(Py_CONSTANT_NONE), 98 | error, 99 | Py_GetConstantBorrowed(Py_CONSTANT_NONE) 100 | ); 101 | if (exc_args) { 102 | PyErr_SetObject(oserr, exc_args); 103 | Py_DECREF(exc_args); 104 | } 105 | } 106 | Py_XDECREF(builtins); 107 | Py_DECREF(msg); 108 | } 109 | 110 | if (os_message_buffer) { 111 | LocalFree((void *)os_message_buffer); 112 | } 113 | 114 | if (cause) { 115 | // References are all stolen here, so no DECREF required 116 | PyObject *chained = PyErr_GetRaisedException(); 117 | PyException_SetContext(chained, cause); 118 | PyErr_SetRaisedException(chained); 119 | } 120 | } 121 | 122 | -------------------------------------------------------------------------------- /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/_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 | } 237 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/_native/shortcut.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include "helpers.h" 6 | 7 | extern "C" { 8 | 9 | PyObject * 10 | shortcut_create(PyObject *, PyObject *args, PyObject *kwargs) 11 | { 12 | static const char *keywords[] = { 13 | "path", "target", "arguments", "working_directory", 14 | "icon", "icon_index", 15 | NULL 16 | }; 17 | wchar_t *path = NULL; 18 | wchar_t *target = NULL; 19 | wchar_t *arguments = NULL; 20 | wchar_t *workingDirectory = NULL; 21 | wchar_t *iconPath = NULL; 22 | int iconIndex = 0; 23 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, 24 | "O&O&|O&O&O&i:shortcut_create", keywords, 25 | as_utf16, &path, as_utf16, &target, as_utf16, &arguments, as_utf16, &workingDirectory, 26 | as_utf16, &iconPath, &iconIndex)) { 27 | return NULL; 28 | } 29 | 30 | PyObject *r = NULL; 31 | IShellLinkW *lnk = NULL; 32 | IPersistFile *persist = NULL; 33 | HRESULT hr; 34 | 35 | hr = CoCreateInstance(CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER, 36 | IID_IShellLinkW, (void **)&lnk); 37 | 38 | if (FAILED(hr)) { 39 | err_SetFromWindowsErrWithMessage(hr, "Creating system shortcut"); 40 | goto done; 41 | } 42 | if (FAILED(hr = lnk->SetPath(target))) { 43 | err_SetFromWindowsErrWithMessage(hr, "Setting shortcut target"); 44 | goto done; 45 | } 46 | if (arguments && *arguments && FAILED(hr = lnk->SetArguments(arguments))) { 47 | err_SetFromWindowsErrWithMessage(hr, "Setting shortcut arguments"); 48 | goto done; 49 | } 50 | if (workingDirectory && *workingDirectory && FAILED(hr = lnk->SetWorkingDirectory(workingDirectory))) { 51 | err_SetFromWindowsErrWithMessage(hr, "Setting shortcut working directory"); 52 | goto done; 53 | } 54 | if (iconPath && *iconPath && FAILED(hr = lnk->SetIconLocation(iconPath, iconIndex))) { 55 | err_SetFromWindowsErrWithMessage(hr, "Setting shortcut icon"); 56 | goto done; 57 | } 58 | if (FAILED(hr = lnk->QueryInterface(&persist))) { 59 | err_SetFromWindowsErrWithMessage(hr, "Getting persist interface"); 60 | goto done; 61 | } 62 | // gh-15: Apparently ERROR_USER_MAPPED_FILE can occur here, which suggests 63 | // contention on the file. We should be able to sleep and retry to handle it 64 | // in most cases. 65 | for (int retries = 5; retries; --retries) { 66 | if (SUCCEEDED(hr = persist->Save(path, 0))) { 67 | break; 68 | } 69 | if (retries == 1 || hr != HRESULT_FROM_WIN32(ERROR_USER_MAPPED_FILE)) { 70 | err_SetFromWindowsErrWithMessage(hr, "Writing shortcut"); 71 | goto done; 72 | } 73 | Sleep(10); 74 | } 75 | 76 | r = Py_NewRef(Py_None); 77 | 78 | done: 79 | if (persist) { 80 | persist->Release(); 81 | } 82 | if (lnk) { 83 | lnk->Release(); 84 | } 85 | if (path) PyMem_Free(path); 86 | if (target) PyMem_Free(target); 87 | if (arguments) PyMem_Free(arguments); 88 | if (workingDirectory) PyMem_Free(workingDirectory); 89 | if (iconPath) PyMem_Free(iconPath); 90 | return r; 91 | } 92 | 93 | 94 | PyObject * 95 | shortcut_get_start_programs(PyObject *, PyObject *, PyObject *) 96 | { 97 | wchar_t *path; 98 | HRESULT hr = SHGetKnownFolderPath( 99 | FOLDERID_Programs, 100 | KF_FLAG_NO_PACKAGE_REDIRECTION | KF_FLAG_CREATE, 101 | NULL, 102 | &path 103 | ); 104 | if (FAILED(hr)) { 105 | err_SetFromWindowsErrWithMessage(hr, "Obtaining Start Menu location"); 106 | return NULL; 107 | } 108 | PyObject *r = PyUnicode_FromWideChar(path, -1); 109 | CoTaskMemFree(path); 110 | return r; 111 | } 112 | 113 | 114 | PyObject * 115 | hide_file(PyObject *, PyObject *args, PyObject *kwargs) 116 | { 117 | static const char *keywords[] = {"path", "hidden", NULL}; 118 | wchar_t *path; 119 | int hidden = 1; 120 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&|b:hide_file", keywords, as_utf16, &path, &hidden)) { 121 | return NULL; 122 | } 123 | PyObject *r = NULL; 124 | DWORD attr = GetFileAttributesW(path); 125 | if (attr == INVALID_FILE_ATTRIBUTES) { 126 | err_SetFromWindowsErrWithMessage(GetLastError(), "Reading file attributes"); 127 | goto done; 128 | } 129 | if (hidden) { 130 | attr |= FILE_ATTRIBUTE_HIDDEN; 131 | } else { 132 | attr &= ~FILE_ATTRIBUTE_HIDDEN; 133 | } 134 | if (!SetFileAttributesW(path, attr)) { 135 | err_SetFromWindowsErrWithMessage(GetLastError(), "Setting file attributes"); 136 | goto done; 137 | } 138 | 139 | r = Py_NewRef(Py_None); 140 | 141 | done: 142 | PyMem_Free(path); 143 | return r; 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /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/manage/__main__.py: -------------------------------------------------------------------------------- 1 | import manage 2 | import sys 3 | 4 | sys.exit(manage.main(sys.argv)) 5 | -------------------------------------------------------------------------------- /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", 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 | -------------------------------------------------------------------------------- /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 | LOGGER.verbose("Loading configuration from %s", file) 96 | try: 97 | with open(file, "r", encoding="utf-8-sig") as f: 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 %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 | try: 163 | subschema = schema[k] 164 | except LookupError: 165 | if error_unknown: 166 | raise InvalidConfigurationError(source, key_so_far + k) 167 | LOGGER.verbose("Ignoring unknown configuration %s%s in %s", key_so_far, k, source) 168 | continue 169 | 170 | if isinstance(subschema, dict): 171 | if not isinstance(v, dict): 172 | raise InvalidConfigurationError(source, key_so_far + k, v) 173 | resolve_config(v, source, relative_to, f"{key_so_far}{k}.", subschema) 174 | continue 175 | 176 | kind, merge, *opts = subschema 177 | from_env = False 178 | if "env" in opts and isinstance(v, str): 179 | try: 180 | orig_v = v 181 | v = _expand_vars(v, os.environ) 182 | from_env = orig_v != v 183 | except TypeError: 184 | pass 185 | if not v: 186 | del cfg[k] 187 | continue 188 | try: 189 | v = kind(v) 190 | except (TypeError, ValueError): 191 | raise InvalidConfigurationError(source, key_so_far + k, v) 192 | if v and "path" in opts and not _is_valid_url(v): 193 | # Paths from the config file are relative to the config file. 194 | # Paths from the environment are relative to the current working dir 195 | if not from_env: 196 | v = relative_to / v 197 | else: 198 | v = type(relative_to)(v).absolute() 199 | if v and "uri" in opts: 200 | if hasattr(v, "as_uri"): 201 | v = v.as_uri() 202 | else: 203 | v = str(v) 204 | if not _is_valid_url(v): 205 | raise InvalidConfigurationError(source, key_so_far + k, v) 206 | cfg[k] = v 207 | 208 | 209 | def merge_config(into_cfg, from_cfg, schema, *, source="", overwrite=False): 210 | for k, v in from_cfg.items(): 211 | try: 212 | into = into_cfg[k] 213 | except LookupError: 214 | LOGGER.debug("Setting config %s to %r", k, v) 215 | into_cfg[k] = v 216 | continue 217 | 218 | try: 219 | subschema = schema[k] 220 | except LookupError: 221 | # No schema information, so let's just replace 222 | LOGGER.warn("Unknown configuration key %s in %s", k, source) 223 | into_cfg[k] = v 224 | continue 225 | 226 | if isinstance(subschema, dict): 227 | if isinstance(into, dict) and isinstance(v, dict): 228 | LOGGER.debug("Recursively updating config %s", k) 229 | merge_config(into, v, subschema, source=source, overwrite=overwrite) 230 | else: 231 | # Source isn't recursing, so let's ignore 232 | # Should have been validated earlier 233 | LOGGER.warn("Invalid configuration key %s in %s", k, source) 234 | continue 235 | 236 | _, merge, *_ = subschema 237 | if not merge or overwrite: 238 | LOGGER.debug("Updating config %s from %r to %r", k, into, v) 239 | into_cfg[k] = v 240 | else: 241 | v2 = merge(into, v) 242 | LOGGER.debug("Updating config %s from %r to %r", k, into, v2) 243 | into_cfg[k] = v2 244 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/manage/indexutils.py: -------------------------------------------------------------------------------- 1 | from .exceptions import InvalidFeedError 2 | from .logging import LOGGER 3 | from .tagutils import tag_or_range, install_matches_any 4 | from .verutils import Version 5 | 6 | SCHEMA = { 7 | "next": str, 8 | "versions": [ 9 | { 10 | "schema": 1, 11 | # Unique ID used for install detection/side-by-sides 12 | "id": str, 13 | # Version used to sort installs. Also determines prerelease 14 | "sort-version": Version, 15 | # Company field 16 | "company": str, 17 | # Default tag, mainly for UI purposes. Must also be specified in 18 | # 'install-for' and 'run-for'. 19 | "tag": str, 20 | # List of tags to install this package for. Does not have to be 21 | # unique across all installs; the first match will be selected. 22 | "install-for": [str], 23 | # List of tags to run this package for. Does not have to be unique 24 | # across all installs; the first match will be selected. 25 | "run-for": [{"tag": str, "target": str, "args": [str], "windowed": int}], 26 | # List of global CLI aliases to create for this package. Does not 27 | # have to be unique across all installs; the first match will be 28 | # created. 29 | "alias": [{"name": str, "target": str, "windowed": int}], 30 | # List of other kinds of shortcuts to create. 31 | "shortcuts": [{"kind": str, ...: ...}], 32 | # Name to display in the UI 33 | "display-name": str, 34 | # [RESERVED] Install prefix. This will always be overwritten, so 35 | # don't specify it in the index. 36 | "prefix": None, 37 | # Default executable path (relative to prefix) 38 | "executable": str, 39 | # Optional arguments to launch the executable with 40 | # (inserted before any user-provided arguments) 41 | "executable_args": [str], 42 | # URL to download the package 43 | "url": str, 44 | # Optional set of hashes to validate the download 45 | "hash": { 46 | ...: str, 47 | }, 48 | }, 49 | ], 50 | } 51 | 52 | 53 | def _typename(t): 54 | if isinstance(t, type): 55 | return t.__name__ 56 | if isinstance(t, tuple): 57 | return ", ".join(map(_typename, t)) 58 | return type(t).__name__ 59 | 60 | 61 | def _schema_error(actual, expect, ctxt): 62 | return InvalidFeedError("Expected '{}' at {}; found '{}'".format( 63 | _typename(expect), ".".join(ctxt), _typename(actual), 64 | )) 65 | 66 | 67 | # More specific for better internal handling. Users don't need to distinguish. 68 | class InvalidFeedVersionError(InvalidFeedError): 69 | pass 70 | 71 | 72 | def _version_error(actual, expect, ctxt): 73 | return InvalidFeedVersionError("Expected {} {}; found {}".format( 74 | ".".join(ctxt), expect, actual, 75 | )) 76 | 77 | 78 | def _validate_one_dict_match(d, expect): 79 | if not isinstance(d, dict): 80 | return True 81 | if not isinstance(expect, dict): 82 | return True 83 | try: 84 | if expect["schema"] == d["schema"]: 85 | return True 86 | except KeyError: 87 | pass 88 | try: 89 | if expect["version"] == d["version"]: 90 | return True 91 | except KeyError: 92 | pass 93 | 94 | for k, v in expect.items(): 95 | if isinstance(v, int) and d.get(k) != v: 96 | return False 97 | if ... not in expect: 98 | for k in d: 99 | if k not in expect: 100 | return False 101 | return True 102 | 103 | 104 | def _validate_one_or_list(d, expects, ctxt): 105 | if not isinstance(d, list): 106 | d = [d] 107 | ctxt.append("[]") 108 | for i, e in enumerate(d): 109 | ctxt[-1] = f"[{i}]" 110 | for expect in expects: 111 | if _validate_one_dict_match(e, expect): 112 | yield _validate_one(e, expect, ctxt) 113 | break 114 | else: 115 | raise InvalidFeedError("No matching 'version' or 'schema' at {}".format( 116 | ".".join(ctxt) 117 | )) 118 | del ctxt[-1] 119 | 120 | 121 | def _validate_one(d, expect, ctxt=None): 122 | if ctxt is None: 123 | ctxt = [] 124 | if expect is None: 125 | raise InvalidFeedError("Unexpected key {}".format(".".join(ctxt))) 126 | if isinstance(expect, int): 127 | if d != expect: 128 | raise _version_error(d, expect, ctxt) 129 | return d 130 | if isinstance(expect, list): 131 | return list(_validate_one_or_list(d, expect, ctxt)) 132 | if expect is ...: 133 | # Allow ... value for arbitrary value types 134 | return d 135 | if isinstance(d, dict): 136 | if isinstance(expect, dict): 137 | d2 = {} 138 | for k, v in d.items(): 139 | ctxt.append(k) 140 | try: 141 | expect2 = expect[k] 142 | except LookupError: 143 | # Allow ... key for arbitrary key names 144 | try: 145 | expect2 = expect[...] 146 | except LookupError: 147 | raise InvalidFeedError("Unexpected key {}".format(".".join(ctxt))) from None 148 | d2[k] = _validate_one(v, expect2, ctxt) 149 | del ctxt[-1] 150 | return d2 151 | raise _schema_error(dict, expect, ctxt) 152 | if isinstance(expect, type) and isinstance(d, expect): 153 | return d 154 | try: 155 | return expect(d) 156 | except Exception as ex: 157 | raise _schema_error(d, expect, ctxt) from ex 158 | 159 | 160 | def _patch_schema_1(source_url, v): 161 | try: 162 | url = v["url"] 163 | except LookupError: 164 | pass 165 | else: 166 | from .urlutils import urljoin 167 | if "://" not in url: 168 | v["url"] = urljoin(source_url, url, to_parent=True) 169 | 170 | # HACK: to help transition alpha users from their existing installs 171 | try: 172 | v["display-name"] = v["displayName"] 173 | except LookupError: 174 | pass 175 | 176 | return v 177 | 178 | 179 | _SCHEMA_PATCHES = { 180 | 1: _patch_schema_1, 181 | } 182 | 183 | 184 | def _patch(source_url, v): 185 | return _SCHEMA_PATCHES.get(v.get("schema"), lambda _, v: v)(source_url, v) 186 | 187 | 188 | class Index: 189 | def __init__(self, source_url, d): 190 | try: 191 | validated = _validate_one(d, SCHEMA) 192 | except InvalidFeedError as ex: 193 | LOGGER.debug("ERROR:", exc_info=True) 194 | raise InvalidFeedError(feed_url=source_url) from ex 195 | self.source_url = source_url 196 | self.next_url = validated.get("next") 197 | versions = [_patch(source_url, v) for v in validated["versions"]] 198 | self.versions = sorted(versions, key=lambda v: v["sort-version"], reverse=True) 199 | 200 | def __repr__(self): 201 | return "".format( 202 | self.source_url, 203 | self.next_url, 204 | len(self.versions), 205 | ) 206 | 207 | def find_all(self, tags, *, seen_ids=None, loose_company=False, with_prerelease=False): 208 | filters = [] 209 | for tag in tags: 210 | try: 211 | filters.append(tag_or_range(tag)) 212 | except ValueError as ex: 213 | LOGGER.warn("%s", ex) 214 | for i in self.versions: 215 | if seen_ids is not None: 216 | if i["id"].casefold() in seen_ids: 217 | continue 218 | if with_prerelease or not i["sort-version"].is_prerelease: 219 | if not filters or install_matches_any(i, filters, loose_company=loose_company): 220 | if seen_ids is not None: 221 | seen_ids.add(i["id"].casefold()) 222 | yield i 223 | 224 | def find_to_install(self, tag, *, loose_company=False, prefer_prerelease=False): 225 | tag_list = [tag] if tag else [] 226 | LOGGER.debug("Finding %s to install", tag_list) 227 | for i in self.find_all(tag_list, loose_company=loose_company, with_prerelease=prefer_prerelease): 228 | return i 229 | if not loose_company: 230 | return self.find_to_install(tag, loose_company=True, prefer_prerelease=prefer_prerelease) 231 | if not prefer_prerelease: 232 | for i in self.find_all(tag_list, loose_company=loose_company, with_prerelease=True): 233 | return i 234 | LOGGER.debug("No install found for %s", tag_list) 235 | raise LookupError(tag) 236 | -------------------------------------------------------------------------------- /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 | return self.name.rpartition(".")[0] 48 | 49 | @property 50 | def suffix(self): 51 | return self.name.rpartition(".")[2] 52 | 53 | @property 54 | def parent(self): 55 | return type(self)(self._parent) 56 | 57 | @property 58 | def parts(self): 59 | drive, root, tail = os.path.splitroot(self._p) 60 | bits = [] 61 | if drive or root: 62 | bits.append(drive + root) 63 | if tail: 64 | bits.extend(tail.split("\\")) 65 | while "." in bits: 66 | bits.remove(".") 67 | while ".." in bits: 68 | i = bits.index("..") 69 | bits.pop(i) 70 | bits.pop(i - 1) 71 | return bits 72 | 73 | def __truediv__(self, other): 74 | other = str(other) 75 | # Quick hack to hide leading ".\" on paths. We don't fully normalise 76 | # here because it can change the meaning of paths. 77 | while other.startswith(("./", ".\\")): 78 | other = other[2:] 79 | return type(self)(os.path.join(self._p, other)) 80 | 81 | def __eq__(self, other): 82 | if isinstance(other, PurePath): 83 | return self._p.casefold() == other._p.casefold() 84 | return self._p.casefold() == str(other).casefold() 85 | 86 | def __ne__(self, other): 87 | if isinstance(other, PurePath): 88 | return self._p.casefold() != other._p.casefold() 89 | return self._p.casefold() != str(other).casefold() 90 | 91 | def with_name(self, name): 92 | return type(self)(os.path.join(self._parent, name)) 93 | 94 | def with_suffix(self, suffix): 95 | if suffix and suffix[:1] != ".": 96 | suffix = f".{suffix}" 97 | return type(self)(os.path.join(self._parent, self.stem + suffix)) 98 | 99 | def relative_to(self, base): 100 | base = PurePath(base).parts 101 | parts = self.parts 102 | if not all(x.casefold() == y.casefold() for x, y in zip(base, parts)): 103 | raise ValueError("path not relative to base") 104 | return type(self)("\\".join(parts[len(base):])) 105 | 106 | def as_uri(self): 107 | drive, root, tail = os.path.splitroot(self._p) 108 | if drive[1:2] == ":" and root: 109 | return "file:///" + self._p.replace("\\", "/") 110 | if drive[:2] == "\\\\": 111 | return "file:" + self._p.replace("\\", "/") 112 | return "file://" + self._p.replace("\\", "/") 113 | 114 | def full_match(self, pattern): 115 | return self.match(pattern, full_match=True) 116 | 117 | def match(self, pattern, full_match=False): 118 | p = str(pattern).casefold().replace("/", "\\") 119 | assert "?" not in p 120 | 121 | m = self._p if full_match or "\\" in p else self.name 122 | m = m.casefold() 123 | 124 | if "*" not in p: 125 | return m.casefold() == p 126 | 127 | must_start_with = True 128 | for bit in p.split("*"): 129 | if bit: 130 | try: 131 | i = m.index(bit) 132 | except ValueError: 133 | return False 134 | if must_start_with and i != 0: 135 | return False 136 | m = m[i + len(bit):] 137 | must_start_with = False 138 | return not m or p.endswith("*") 139 | 140 | 141 | class Path(PurePath): 142 | @classmethod 143 | def cwd(cls): 144 | return cls(os.getcwd()) 145 | 146 | def absolute(self): 147 | return Path.cwd() / self 148 | 149 | def exists(self): 150 | return os.path.exists(self._p) 151 | 152 | def is_dir(self): 153 | return os.path.isdir(self._p) 154 | 155 | def is_file(self): 156 | return os.path.isfile(self._p) 157 | 158 | def iterdir(self): 159 | try: 160 | return (self / n for n in os.listdir(self._p)) 161 | except FileNotFoundError: 162 | return () 163 | 164 | def glob(self, pattern): 165 | return (f for f in self.iterdir() if f.match(pattern)) 166 | 167 | def lstat(self): 168 | return os.lstat(self._p) 169 | 170 | def mkdir(self, mode=0o777, parents=False, exist_ok=False): 171 | try: 172 | os.mkdir(self._p, mode) 173 | except FileNotFoundError: 174 | if not parents or self.parent == self: 175 | raise 176 | self.parent.mkdir(parents=True, exist_ok=True) 177 | self.mkdir(mode, parents=False, exist_ok=exist_ok) 178 | except OSError: 179 | # Cannot rely on checking for EEXIST, since the operating system 180 | # could give priority to other errors like EACCES or EROFS 181 | if not exist_ok or not self.is_dir(): 182 | raise 183 | 184 | def rename(self, new_name): 185 | os.rename(self._p, new_name) 186 | return self.parent / PurePath(new_name) 187 | 188 | def rmdir(self): 189 | os.rmdir(self._p) 190 | 191 | def unlink(self): 192 | os.unlink(self._p) 193 | 194 | def open(self, mode="r", encoding=None, errors=None): 195 | if "b" in mode: 196 | return open(self._p, mode) 197 | if not encoding: 198 | encoding = "utf-8-sig" if "r" in mode else "utf-8" 199 | return open(self._p, mode, encoding=encoding, errors=errors or "strict") 200 | 201 | def read_bytes(self): 202 | with open(self._p, "rb") as f: 203 | return f.read() 204 | 205 | def read_text(self, encoding="utf-8-sig", errors="strict"): 206 | with open(self._p, "r", encoding=encoding, errors=errors) as f: 207 | return f.read() 208 | 209 | def write_bytes(self, data): 210 | with open(self._p, "wb") as f: 211 | f.write(data) 212 | 213 | def write_text(self, text, encoding="utf-8", errors="strict"): 214 | with open(self._p, "w", encoding=encoding, errors=errors) as f: 215 | f.write(text) 216 | -------------------------------------------------------------------------------- /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 | icon=_unprefix(item.get("Icon"), prefix), 56 | icon_index=item.get("IconIndex", 0), 57 | ) 58 | return lnk 59 | 60 | 61 | def _make_directory(root, name, prefix, items): 62 | cleanup_dir = True 63 | subdir = root / name 64 | try: 65 | subdir.mkdir(parents=True, exist_ok=False) 66 | except FileExistsError: 67 | cleanup_dir = False 68 | 69 | cleanup_items = [] 70 | try: 71 | for i in items: 72 | cleanup_items.append(_make(subdir, prefix, i)) 73 | except Exception: 74 | if cleanup_dir: 75 | rmtree(subdir) 76 | else: 77 | for i in cleanup_items: 78 | if i: 79 | unlink(i) 80 | raise 81 | 82 | ini = subdir / "pymanager.ini" 83 | with open(ini, "a", encoding="utf-8") as f: 84 | for i in cleanup_items: 85 | if i: 86 | try: 87 | print(i.relative_to(subdir), file=f) 88 | except ValueError: 89 | LOGGER.warn("Package attempted to create shortcut outside of its directory") 90 | LOGGER.debug("Path: %s", i) 91 | LOGGER.debug("Directory: %s", subdir) 92 | _native.hide_file(ini, True) 93 | 94 | return subdir 95 | 96 | 97 | def _cleanup(root, keep): 98 | if root in keep: 99 | return 100 | 101 | ini = root / "pymanager.ini" 102 | try: 103 | with open(ini, "r", encoding="utf-8-sig") as f: 104 | files = {root / s.strip() for s in f if s.strip()} 105 | except FileNotFoundError: 106 | return 107 | _native.hide_file(ini, False) 108 | unlink(ini) 109 | 110 | retained = [] 111 | for f in files: 112 | if f in keep: 113 | retained.append(f) 114 | continue 115 | LOGGER.debug("Removing %s", f) 116 | try: 117 | unlink(f) 118 | except IsADirectoryError: 119 | _cleanup(f, keep) 120 | 121 | if retained: 122 | with open(ini, "w", encoding="utf-8") as f: 123 | for i in retained: 124 | try: 125 | print(i.relative_to(root), file=f) 126 | except ValueError: 127 | LOGGER.debug("Ignoring file outside of current directory %s", i) 128 | _native.hide_file(ini, True) 129 | elif not any(root.iterdir()): 130 | LOGGER.debug("Removing %s", root) 131 | rmtree(root) 132 | 133 | 134 | def _get_to_keep(keep, root, item): 135 | keep.add(root / item["Name"]) 136 | for i in item.get("Items", ()): 137 | try: 138 | _get_to_keep(keep, root / item["Name"], i) 139 | except LookupError: 140 | pass 141 | 142 | 143 | def create_one(root, install, shortcut, warn_for=[]): 144 | root = Path(_native.shortcut_get_start_programs()) / root 145 | _make(root, install["prefix"], shortcut, allow_warn=install_matches_any(install, warn_for)) 146 | 147 | 148 | def cleanup(root, preserve, warn_for=[]): 149 | root = Path(_native.shortcut_get_start_programs()) / root 150 | 151 | if not root.is_dir(): 152 | if root.is_file(): 153 | unlink(root) 154 | return 155 | 156 | keep = set() 157 | for item in preserve: 158 | _get_to_keep(keep, root, item) 159 | 160 | LOGGER.debug("Cleaning up Start menu shortcuts") 161 | for item in keep: 162 | LOGGER.debug("Except: %s", item) 163 | 164 | for entry in root.iterdir(): 165 | _cleanup(entry, keep) 166 | 167 | if not any(root.iterdir()): 168 | LOGGER.debug("Removing %s", root) 169 | rmtree(root) 170 | -------------------------------------------------------------------------------- /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 os 22 | import winreg 23 | 24 | if hive is None: 25 | hive = winreg.HKEY_CURRENT_USER 26 | try: 27 | with winreg.OpenKeyEx(hive, subkey) as key: 28 | path, kind = winreg.QueryValueEx(key, "Path") 29 | if kind not in (winreg.REG_SZ, winreg.REG_EXPAND_SZ): 30 | raise ValueError("Value kind is not a string") 31 | except (OSError, ValueError): 32 | LOGGER.debug("Not removing global commands directory from PATH", exc_info=True) 33 | else: 34 | LOGGER.debug("Current PATH contains %s", path) 35 | paths = path.split(";") 36 | newpaths = [] 37 | for p in paths: 38 | # We should expand entries here, but we only want to remove those 39 | # that we added ourselves (during firstrun), and we never use 40 | # environment variables. So even if the kind is REG_EXPAND_SZ, we 41 | # don't need to expand to find our own entry. 42 | #ep = os.path.expandvars(p) if kind == winreg.REG_EXPAND_SZ else p 43 | ep = p 44 | if PurePath(ep).match(global_dir): 45 | LOGGER.debug("Removing from PATH: %s", p) 46 | else: 47 | newpaths.append(p) 48 | if len(newpaths) < len(paths): 49 | newpath = ";".join(newpaths) 50 | with winreg.CreateKeyEx(hive, subkey, access=winreg.KEY_READ|winreg.KEY_WRITE) as key: 51 | path2, kind2 = winreg.QueryValueEx(key, "Path") 52 | if path2 == path and kind2 == kind: 53 | LOGGER.info("Removing global commands directory from PATH") 54 | LOGGER.debug("New PATH contains %s", newpath) 55 | winreg.SetValueEx(key, "Path", 0, kind, newpath) 56 | else: 57 | LOGGER.debug("Not removing global commands directory from PATH " 58 | "because the registry changed while processing.") 59 | 60 | try: 61 | from _native import broadcast_settings_change 62 | broadcast_settings_change() 63 | except (ImportError, OSError): 64 | LOGGER.debug("Did not broadcast settings change notification", 65 | exc_info=True) 66 | 67 | if not global_dir.is_dir(): 68 | return 69 | LOGGER.info("Purging global commands from %s", global_dir) 70 | for f in _iterdir(global_dir): 71 | LOGGER.debug("Purging %s", f) 72 | rmtree(f, after_5s_warning=warn_msg) 73 | 74 | 75 | def execute(cmd): 76 | LOGGER.debug("BEGIN uninstall_command.execute: %r", cmd.args) 77 | 78 | warn_msg = ("Attempting to remove {} is taking longer than expected. " + 79 | "Ensure no Python interpreters are running, and continue to wait " + 80 | "or press Ctrl+C to abort.") 81 | 82 | # Clear any active venv so we don't try to delete it 83 | cmd.virtual_env = None 84 | installed = list(cmd.get_installs()) 85 | 86 | cmd.tags = [] 87 | 88 | if cmd.purge: 89 | if not cmd.ask_yn("Uninstall all runtimes?"): 90 | LOGGER.debug("END uninstall_command.execute") 91 | return 92 | for i in installed: 93 | LOGGER.info("Purging %s from %s", i["display-name"], i["prefix"]) 94 | try: 95 | rmtree( 96 | i["prefix"], 97 | after_5s_warning=warn_msg.format(i["display-name"]), 98 | remove_ext_first=("exe", "dll", "json") 99 | ) 100 | except FilesInUseError: 101 | LOGGER.warn("Unable to purge %s because it is still in use.", 102 | i["display-name"]) 103 | continue 104 | LOGGER.info("Purging saved downloads from %s", cmd.download_dir) 105 | rmtree(cmd.download_dir, after_5s_warning=warn_msg.format("cached downloads")) 106 | # Purge global commands directory 107 | _do_purge_global_dir(cmd.global_dir, warn_msg.format("global commands")) 108 | LOGGER.info("Purging all shortcuts") 109 | for _, cleanup in SHORTCUT_HANDLERS.values(): 110 | cleanup(cmd, []) 111 | LOGGER.debug("END uninstall_command.execute") 112 | return 113 | 114 | if not cmd.args: 115 | raise ArgumentError("Please specify one or more runtimes to uninstall.") 116 | 117 | to_uninstall = [] 118 | if not cmd.by_id: 119 | for tag in cmd.args: 120 | try: 121 | if tag.casefold() == "default".casefold(): 122 | cmd.tags.append(tag_or_range(cmd.default_tag)) 123 | else: 124 | cmd.tags.append(tag_or_range(tag)) 125 | except ValueError as ex: 126 | LOGGER.warn("%s", ex) 127 | 128 | for tag in cmd.tags: 129 | candidates = get_matching_install_tags( 130 | installed, 131 | tag, 132 | default_platform=cmd.default_platform, 133 | ) 134 | if not candidates: 135 | LOGGER.warn("No install found matching '%s'", tag) 136 | continue 137 | i, _ = candidates[0] 138 | LOGGER.debug("Selected %s (%s) to uninstall", i["display-name"], i["id"]) 139 | to_uninstall.append(i) 140 | installed.remove(i) 141 | else: 142 | ids = {tag.casefold() for tag in cmd.args} 143 | for i in installed: 144 | if i["id"].casefold() in ids: 145 | LOGGER.debug("Selected %s (%s) to uninstall", i["display-name"], i["id"]) 146 | to_uninstall.append(i) 147 | for i in to_uninstall: 148 | installed.remove(i) 149 | 150 | if not to_uninstall: 151 | LOGGER.info("No runtimes selected to uninstall.") 152 | return 153 | elif cmd.confirm: 154 | if len(to_uninstall) == 1: 155 | if not cmd.ask_yn("Uninstall %s?", to_uninstall[0]["display-name"]): 156 | return 157 | else: 158 | msg = ", ".join(i["display-name"] for i in to_uninstall) 159 | if not cmd.ask_yn("Uninstall these runtimes: %s?", msg): 160 | return 161 | 162 | for i in to_uninstall: 163 | LOGGER.debug("Uninstalling %s from %s", i["display-name"], i["prefix"]) 164 | try: 165 | rmtree( 166 | i["prefix"], 167 | after_5s_warning=warn_msg.format(i["display-name"]), 168 | remove_ext_first=("exe", "dll", "json"), 169 | ) 170 | except FilesInUseError as ex: 171 | LOGGER.error("Could not uninstall %s because it is still in use.", 172 | i["display-name"]) 173 | raise SystemExit(1) from ex 174 | LOGGER.info("Removed %s", i["display-name"]) 175 | try: 176 | for target in cmd.global_dir.glob("*.__target__"): 177 | alias = target.with_suffix("") 178 | entry = target.read_text(encoding="utf-8-sig", errors="strict") 179 | if PurePath(entry).match(i["executable"]): 180 | LOGGER.debug("Unlink %s", alias) 181 | unlink(alias, after_5s_warning=warn_msg.format(alias)) 182 | unlink(target, after_5s_warning=warn_msg.format(target)) 183 | except OSError as ex: 184 | LOGGER.warn("Failed to remove alias: %s", ex) 185 | LOGGER.debug("TRACEBACK:", exc_info=True) 186 | 187 | if to_uninstall: 188 | update_all_shortcuts(cmd) 189 | 190 | LOGGER.debug("END uninstall_command.execute") 191 | -------------------------------------------------------------------------------- /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 | 17 | # Versions with more fields than this will be truncated. 18 | MAX_FIELDS = 8 19 | 20 | def __init__(self, s): 21 | import re 22 | levels = "|".join(re.escape(k) for k in self.TEXT_MAP if k) 23 | m = re.match( 24 | r"^(?P\d+(\.\d+)*)([\.\-]?(?P" + levels + r")[\.]?(?P\d*))?$", 25 | s, 26 | re.I, 27 | ) 28 | if not m: 29 | raise ValueError("Failed to parse version %s", s) 30 | bits = [int(v) for v in m.group("numbers").split(".")] 31 | try: 32 | dev = self.TEXT_MAP[(m.group("level") or "").lower()] 33 | except LookupError: 34 | dev = 0 35 | LOGGER.warn("Version %s has invalid development level specified which will be ignored", s) 36 | self.s = s 37 | if len(bits) > self.MAX_FIELDS: 38 | LOGGER.warn("Version %s is too long and will be truncated to %s for ordering purposes", 39 | s, ".".join(map(str, bits[:self.MAX_FIELDS]))) 40 | self.sortkey = ( 41 | *bits[:self.MAX_FIELDS], 42 | *([0] * (self.MAX_FIELDS - len(bits))), 43 | len(bits), # for sort stability 44 | dev, 45 | int(m.group("serial") or 0) 46 | ) 47 | self.prefix_match = dev == self.TEXT_MAP["*"] 48 | self.prerelease_match = dev == self.TEXT_MAP["dev"] 49 | 50 | def __str__(self): 51 | return self.s 52 | 53 | def __repr__(self): 54 | return self.s 55 | 56 | def __hash__(self): 57 | return hash(self.sortkey) 58 | 59 | def _are_equal(self, other, prefix_match=None, other_prefix_match=None, prerelease_match=None): 60 | if other is None: 61 | return False 62 | if isinstance(other, str): 63 | return self.s.casefold() == other.casefold() 64 | if not isinstance(other, type(self)): 65 | return False 66 | if self.sortkey == other.sortkey: 67 | return True 68 | if prefix_match is not None and prefix_match or self.prefix_match: 69 | if (self.sortkey[-3] <= other.sortkey[-3] 70 | and self.sortkey[:self.sortkey[-3]] == other.sortkey[:self.sortkey[-3]]): 71 | return True 72 | elif other_prefix_match is not None and other_prefix_match or other.prefix_match: 73 | if (self.sortkey[-3] >= other.sortkey[-3] 74 | and self.sortkey[:other.sortkey[-3]] == other.sortkey[:other.sortkey[-3]]): 75 | return True 76 | if prerelease_match is not None and prerelease_match or self.prerelease_match: 77 | if self.sortkey[:-3] == other.sortkey[:-3]: 78 | return True 79 | return False 80 | 81 | def startswith(self, other): 82 | return self._are_equal(other, other_prefix_match=True) 83 | 84 | def above_lower_bound(self, other): 85 | if other is None: 86 | return True 87 | if self.sortkey[:other.sortkey[-3]] > other.sortkey[:other.sortkey[-3]]: 88 | return True 89 | return False 90 | 91 | def below_upper_bound(self, other): 92 | if other is None: 93 | return True 94 | if self.sortkey[:other.sortkey[-3]] < other.sortkey[:other.sortkey[-3]]: 95 | return True 96 | return False 97 | 98 | def __eq__(self, other): 99 | return self._are_equal(other) 100 | 101 | def __gt__(self, other): 102 | if other is None: 103 | return True 104 | if isinstance(other, str): 105 | other = type(self)(other) 106 | return self.sortkey > other.sortkey 107 | 108 | def __lt__(self, other): 109 | if other is None: 110 | return False 111 | if isinstance(other, str): 112 | other = type(self)(other) 113 | return self.sortkey < other.sortkey 114 | 115 | def __le__(self, other): 116 | return self < other or self == other 117 | 118 | def __ge__(self, other): 119 | return self > other or self == other 120 | 121 | @property 122 | def is_prerelease(self): 123 | return self.sortkey[-2] < self.TEXT_MAP[""] 124 | 125 | def to_python_style(self, n=3, with_dev=True): 126 | v = ".".join(str(i) for i in self.sortkey[:min(n, self.MAX_FIELDS)]) 127 | if with_dev: 128 | try: 129 | dev = self._TEXT_UNMAP[self.sortkey[-2]] 130 | if dev: 131 | v += f"{dev}{self.sortkey[-1]}" 132 | except LookupError: 133 | pass 134 | return v 135 | -------------------------------------------------------------------------------- /src/pymanager.json: -------------------------------------------------------------------------------- 1 | { 2 | "install": { 3 | "source": "%PYTHON_MANAGER_SOURCE_URL%", 4 | "fallback_source": "./bundled/fallback-index.json" 5 | }, 6 | "list": { 7 | "format": "%PYTHON_MANAGER_LIST_FORMAT%" 8 | }, 9 | "registry_override_key": "HKEY_LOCAL_MACHINE\\Software\\Policies\\Python\\PyManager", 10 | 11 | "confirm": "%PYTHON_MANAGER_CONFIRM%", 12 | "automatic_install": "%PYTHON_MANAGER_AUTOMATIC_INSTALL%", 13 | "include_unmanaged": "%PYTHON_MANAGER_INCLUDE_UNMANAGED%", 14 | "virtual_env": "%VIRTUAL_ENV%", 15 | "shebang_can_run_anything": "%PYTHON_MANAGER_SHEBANG_CAN_RUN_ANYTHING%", 16 | "shebang_can_run_anything_silently": false, 17 | 18 | "install_dir": "%LocalAppData%\\Python", 19 | "download_dir": "%LocalAppData%\\Python\\_cache", 20 | "global_dir": "%LocalAppData%\\Python\\bin", 21 | "bundled_dir": "./bundled", 22 | "logs_dir": "%PYTHON_MANAGER_LOGS%", 23 | 24 | "default_tag": "%PYTHON_MANAGER_DEFAULT%", 25 | "default_platform": "%PYTHON_MANAGER_DEFAULT_PLATFORM%", 26 | "user_config": "%AppData%\\Python\\PyManager.json", 27 | "additional_config": "%PYTHON_MANAGER_CONFIG%", 28 | 29 | "pep514_root": "HKEY_CURRENT_USER\\Software\\Python", 30 | "start_folder": "Python", 31 | 32 | "launcher_exe": "./templates/launcher.exe", 33 | "launcherw_exe": "./templates/launcherw.exe", 34 | "welcome_on_update": true 35 | } -------------------------------------------------------------------------------- /src/pymanager/MsixAppInstallerData.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /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(const wchar_t *executable, const wchar_t *insert_args, int skip_argc, DWORD *exitCode) 38 | { 39 | HANDLE job; 40 | JOBOBJECT_EXTENDED_LIMIT_INFORMATION info; 41 | DWORD info_len; 42 | STARTUPINFOW si; 43 | PROCESS_INFORMATION pi; 44 | int lastError = 0; 45 | const wchar_t *arg_space = L" "; 46 | LPCWSTR origCmdLine = GetCommandLineW(); 47 | const wchar_t *cmdLine = NULL; 48 | 49 | if (insert_args == NULL) { 50 | insert_args = L""; 51 | } 52 | 53 | size_t n = wcslen(executable) + wcslen(origCmdLine) + wcslen(insert_args) + 5; 54 | wchar_t *newCmdLine = (wchar_t *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, n * sizeof(wchar_t)); 55 | if (!newCmdLine) { 56 | lastError = GetLastError(); 57 | goto exit; 58 | } 59 | 60 | if (origCmdLine[0] == L'"') { 61 | cmdLine = wcschr(origCmdLine + 1, L'"'); 62 | } else { 63 | cmdLine = wcschr(origCmdLine, L' '); 64 | } 65 | 66 | while (skip_argc-- > 0) { 67 | wchar_t c; 68 | while (*++cmdLine && *cmdLine == L' ') { } 69 | while (*++cmdLine && *cmdLine != L' ') { } 70 | } 71 | 72 | if (!insert_args || !*insert_args) { 73 | arg_space = L""; 74 | } 75 | if (cmdLine && *cmdLine) { 76 | swprintf_s(newCmdLine, n + 1, L"\"%s\"%s%s %s", executable, arg_space, insert_args, cmdLine + 1); 77 | } else { 78 | swprintf_s(newCmdLine, n + 1, L"\"%s\"%s%s", executable, arg_space, insert_args); 79 | } 80 | 81 | #if defined(_WINDOWS) 82 | /* 83 | When explorer launches a Windows (GUI) application, it displays 84 | the "app starting" (the "pointer + hourglass") cursor for a number 85 | of seconds, or until the app does something UI-ish (eg, creating a 86 | window, or fetching a message). As this launcher doesn't do this 87 | directly, that cursor remains even after the child process does these 88 | things. We avoid that by doing a simple post+get message. 89 | See http://bugs.python.org/issue17290 90 | */ 91 | MSG msg; 92 | 93 | PostMessage(0, 0, 0, 0); 94 | GetMessage(&msg, 0, 0, 0); 95 | #endif 96 | 97 | job = CreateJobObject(NULL, NULL); 98 | if (!job 99 | || !QueryInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info), &info_len) 100 | || info_len != sizeof(info) 101 | ) { 102 | lastError = GetLastError(); 103 | goto exit; 104 | } 105 | info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | 106 | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK; 107 | if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info))) { 108 | lastError = GetLastError(); 109 | goto exit; 110 | } 111 | 112 | memset(&si, 0, sizeof(si)); 113 | GetStartupInfoW(&si); 114 | if ((lastError = dup_handle(GetStdHandle(STD_INPUT_HANDLE), &si.hStdInput)) 115 | || (lastError = dup_handle(GetStdHandle(STD_OUTPUT_HANDLE), &si.hStdOutput)) 116 | || (lastError = dup_handle(GetStdHandle(STD_ERROR_HANDLE), &si.hStdError)) 117 | ) { 118 | goto exit; 119 | } 120 | if (!SetConsoleCtrlHandler(ctrl_c_handler, TRUE)) { 121 | lastError = GetLastError(); 122 | goto exit; 123 | } 124 | 125 | si.dwFlags = STARTF_USESTDHANDLES; 126 | if (!CreateProcessW(executable, newCmdLine, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) { 127 | lastError = GetLastError(); 128 | goto exit; 129 | } 130 | 131 | AssignProcessToJobObject(job, pi.hProcess); 132 | CloseHandle(pi.hThread); 133 | WaitForSingleObjectEx(pi.hProcess, INFINITE, FALSE); 134 | if (!GetExitCodeProcess(pi.hProcess, exitCode)) { 135 | lastError = GetLastError(); 136 | } 137 | exit: 138 | if (newCmdLine) { 139 | HeapFree(GetProcessHeap(), 0, newCmdLine); 140 | } 141 | return lastError ? HRESULT_FROM_WIN32(lastError) : 0; 142 | } 143 | -------------------------------------------------------------------------------- /src/pymanager/_launch.h: -------------------------------------------------------------------------------- 1 | int launch(const wchar_t *executable, const wchar_t *insert_args, int skip_argc, DWORD *exitCode); 2 | -------------------------------------------------------------------------------- /src/pymanager/_resources/py.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/py.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/pyc.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/pyc.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/pyd.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/pyd.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/python.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/python.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonw.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/pythonw.ico -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonwx150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/pythonwx150.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonwx44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/pythonwx44.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonx150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/pythonx150.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonx44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/pythonx44.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pythonx50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/pythonx50.png -------------------------------------------------------------------------------- /src/pymanager/_resources/pyx256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/pyx256.png -------------------------------------------------------------------------------- /src/pymanager/_resources/setupx150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/setupx150.png -------------------------------------------------------------------------------- /src/pymanager/_resources/setupx44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/_resources/setupx44.png -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 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 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /src/pymanager/pyicon.rc: -------------------------------------------------------------------------------- 1 | 1 ICON DISCARDABLE "_resources\python.ico" 2 | -------------------------------------------------------------------------------- /src/pymanager/pymanager.appinstaller: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 13 | 14 | 15 | 16 | true 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/pymanager/pywicon.rc: -------------------------------------------------------------------------------- 1 | 1 ICON DISCARDABLE "_resources\pythonw.ico" 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/pymanager/setup.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/setup.ico -------------------------------------------------------------------------------- /src/pymanager/templates/template.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pymanager/templates/template.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/pyshellext/idle.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pyshellext/idle.ico -------------------------------------------------------------------------------- /src/pyshellext/py.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pyshellext/py.ico -------------------------------------------------------------------------------- /src/pyshellext/pyshellext.def: -------------------------------------------------------------------------------- 1 | EXPORTS 2 | DllGetClassObject PRIVATE 3 | DllCanUnloadNow PRIVATE 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/pyshellext/python.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pyshellext/python.ico -------------------------------------------------------------------------------- /src/pyshellext/pythonw.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/pymanager/52aa23ce9bcaf0f673fcf365e51004c9bbff75b2/src/pyshellext/pythonw.ico -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } 242 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /tests/test_install_command.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pytest 4 | import secrets 5 | from pathlib import Path, PurePath 6 | 7 | from manage import install_command as IC 8 | from manage import installs 9 | 10 | 11 | @pytest.fixture 12 | def alias_checker(tmp_path): 13 | with AliasChecker(tmp_path) as checker: 14 | yield checker 15 | 16 | 17 | class AliasChecker: 18 | class Cmd: 19 | global_dir = "out" 20 | launcher_exe = "launcher.txt" 21 | launcherw_exe = "launcherw.txt" 22 | default_platform = "-64" 23 | 24 | def __init__(self, platform=None): 25 | if platform: 26 | self.default_platform = platform 27 | 28 | 29 | def __init__(self, tmp_path): 30 | self.Cmd.global_dir = tmp_path / "out" 31 | self.Cmd.launcher_exe = tmp_path / "launcher.txt" 32 | self.Cmd.launcherw_exe = tmp_path / "launcherw.txt" 33 | self._expect_target = "target-" + secrets.token_hex(32) 34 | self._expect = { 35 | "-32": "-32-" + secrets.token_hex(32), 36 | "-64": "-64-" + secrets.token_hex(32), 37 | "-arm64": "-arm64-" + secrets.token_hex(32), 38 | "w-32": "w-32-" + secrets.token_hex(32), 39 | "w-64": "w-64-" + secrets.token_hex(32), 40 | "w-arm64": "w-arm64-" + secrets.token_hex(32), 41 | } 42 | for k, v in self._expect.items(): 43 | (tmp_path / f"launcher{k}.txt").write_text(v) 44 | 45 | def __enter__(self): 46 | return self 47 | 48 | def __exit__(self, *exc_info): 49 | pass 50 | 51 | def check(self, cmd, tag, name, expect, windowed=0): 52 | IC._write_alias( 53 | cmd, 54 | {"tag": tag}, 55 | {"name": f"{name}.txt", "windowed": windowed}, 56 | self._expect_target, 57 | ) 58 | print(*cmd.global_dir.glob("*"), sep="\n") 59 | assert (cmd.global_dir / f"{name}.txt").is_file() 60 | assert (cmd.global_dir / f"{name}.txt.__target__").is_file() 61 | assert (cmd.global_dir / f"{name}.txt").read_text() == expect 62 | assert (cmd.global_dir / f"{name}.txt.__target__").read_text() == self._expect_target 63 | 64 | def check_32(self, cmd, tag, name): 65 | self.check(cmd, tag, name, self._expect["-32"]) 66 | 67 | def check_w32(self, cmd, tag, name): 68 | self.check(cmd, tag, name, self._expect["w-32"], windowed=1) 69 | 70 | def check_64(self, cmd, tag, name): 71 | self.check(cmd, tag, name, self._expect["-64"]) 72 | 73 | def check_w64(self, cmd, tag, name): 74 | self.check(cmd, tag, name, self._expect["w-64"], windowed=1) 75 | 76 | def check_arm64(self, cmd, tag, name): 77 | self.check(cmd, tag, name, self._expect["-arm64"]) 78 | 79 | def check_warm64(self, cmd, tag, name): 80 | self.check(cmd, tag, name, self._expect["w-arm64"], windowed=1) 81 | 82 | 83 | def test_write_alias_tag_with_platform(alias_checker): 84 | alias_checker.check_32(alias_checker.Cmd, "1.0-32", "testA") 85 | alias_checker.check_w32(alias_checker.Cmd, "1.0-32", "testB") 86 | alias_checker.check_64(alias_checker.Cmd, "1.0-64", "testC") 87 | alias_checker.check_w64(alias_checker.Cmd, "1.0-64", "testD") 88 | alias_checker.check_arm64(alias_checker.Cmd, "1.0-arm64", "testE") 89 | alias_checker.check_warm64(alias_checker.Cmd, "1.0-arm64", "testF") 90 | 91 | 92 | def test_write_alias_default_platform(alias_checker): 93 | alias_checker.check_32(alias_checker.Cmd("-32"), "1.0", "testA") 94 | alias_checker.check_w32(alias_checker.Cmd("-32"), "1.0", "testB") 95 | alias_checker.check_64(alias_checker.Cmd, "1.0", "testC") 96 | alias_checker.check_w64(alias_checker.Cmd, "1.0", "testD") 97 | alias_checker.check_arm64(alias_checker.Cmd("-arm64"), "1.0", "testE") 98 | alias_checker.check_warm64(alias_checker.Cmd("-arm64"), "1.0", "testF") 99 | 100 | 101 | def test_write_alias_fallback_platform(alias_checker): 102 | alias_checker.check_64(alias_checker.Cmd("-spam"), "1.0", "testA") 103 | alias_checker.check_w64(alias_checker.Cmd("-spam"), "1.0", "testB") 104 | 105 | 106 | def test_print_cli_shortcuts(patched_installs, assert_log, monkeypatch, tmp_path): 107 | class Cmd: 108 | global_dir = Path(tmp_path) 109 | def get_installs(self): 110 | return installs.get_installs(None) 111 | 112 | (tmp_path / "fake.exe").write_bytes(b"") 113 | 114 | monkeypatch.setitem(os.environ, "PATH", f"{os.environ['PATH']};{Cmd.global_dir}") 115 | IC.print_cli_shortcuts(Cmd()) 116 | assert_log( 117 | assert_log.skip_until("Installed %s", ["Python 2.0-64", PurePath("C:\\2.0-64")]), 118 | assert_log.skip_until("%s will be launched by %s", ["Python 1.0-64", "py1.0[-64].exe"]), 119 | ("%s will be launched by %s", ["Python 1.0-32", "py1.0-32.exe"]), 120 | ) 121 | 122 | 123 | def test_print_path_warning(patched_installs, assert_log, tmp_path): 124 | class Cmd: 125 | global_dir = Path(tmp_path) 126 | def get_installs(self): 127 | return installs.get_installs(None) 128 | 129 | (tmp_path / "fake.exe").write_bytes(b"") 130 | 131 | IC.print_cli_shortcuts(Cmd()) 132 | assert_log( 133 | assert_log.skip_until(".*Global shortcuts directory is not on PATH") 134 | ) 135 | 136 | 137 | def test_merge_existing_index(tmp_path): 138 | # This function is for multiple downloaded index.jsons, so it merges based 139 | # on the url property, which should usually be a local file. 140 | existing = tmp_path / "index.json" 141 | with open(existing, "w", encoding="utf-8") as f: 142 | json.dump({"versions": [ 143 | {"id": "test-1", "url": "test-file-1.zip"}, 144 | {"id": "test-2", "url": "test-file-2.zip"}, 145 | {"id": "test-3", "url": "test-file-3.zip"}, 146 | ]}, f) 147 | 148 | new = [ 149 | # Ensure new versions appear first 150 | {"id": "test-4", "url": "test-file-4.zip"}, 151 | # Ensure matching ID doesn't result in overwrite 152 | {"id": "test-1", "url": "test-file-1b.zip"}, 153 | # Ensure matching URL excludes original entry 154 | {"id": "test-2b", "url": "test-file-2.zip"}, 155 | ] 156 | 157 | IC._merge_existing_index(new, existing) 158 | 159 | assert new == [ 160 | {"id": "test-4", "url": "test-file-4.zip"}, 161 | {"id": "test-1", "url": "test-file-1b.zip"}, 162 | {"id": "test-2b", "url": "test-file-2.zip"}, 163 | {"id": "test-1", "url": "test-file-1.zip"}, 164 | {"id": "test-3", "url": "test-file-3.zip"}, 165 | ] 166 | 167 | 168 | def test_merge_existing_index_not_found(tmp_path): 169 | existing = tmp_path / "index.json" 170 | try: 171 | existing.unlink() 172 | except FileNotFoundError: 173 | pass 174 | 175 | # Expect no failure and no change 176 | new = [1, 2, 3] 177 | IC._merge_existing_index(new, existing) 178 | assert new == [1, 2, 3] 179 | 180 | 181 | def test_merge_existing_index_not_valid(tmp_path): 182 | existing = tmp_path / "index.json" 183 | with open(existing, "w", encoding="utf-8") as f: 184 | print("It's not a list of installs", file=f) 185 | print("But more importantly,", file=f) 186 | print("it's not valid JSON!", file=f) 187 | 188 | # Expect no failure and no change 189 | new = [1, 2, 3] 190 | IC._merge_existing_index(new, existing) 191 | assert new == [1, 2, 3] 192 | -------------------------------------------------------------------------------- /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_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.format = "test" 35 | self.one = False 36 | self.unmanaged = True 37 | list_command.FORMATTERS["test"] = lambda c, i: self.captured.extend(i) 38 | 39 | def __call__(self, *filters, **kwargs): 40 | self.args = filters 41 | for k, v in kwargs.items(): 42 | if not hasattr(self, k): 43 | raise TypeError(f"command has no option {k!r}") 44 | setattr(self, k, v) 45 | self.captured.clear() 46 | list_command.execute(self) 47 | return [f"{i['company']}/{i['tag']}" for i in self.captured] 48 | 49 | def get_installs(self, include_unmanaged=False): 50 | assert include_unmanaged == self.unmanaged 51 | return self.installs 52 | 53 | 54 | @pytest.fixture 55 | def list_cmd(): 56 | try: 57 | yield ListCapture() 58 | finally: 59 | list_command.FORMATTERS.pop("test", None) 60 | 61 | 62 | def test_list(list_cmd): 63 | # list_command does not sort its entries - get_installs() does that 64 | assert list_cmd() == [ 65 | "Company2/1.0", 66 | "Company1/2.0", 67 | "Company1/1.0", 68 | ] 69 | # unmanaged doesn't affect our result (because we shim the function that 70 | # does the check), but it'll at least ensure it gets passed through. 71 | assert list_cmd(unmanaged=True) 72 | 73 | 74 | def test_list_filter(list_cmd): 75 | assert list_cmd("2.0") == ["Company1/2.0"] 76 | assert list_cmd("1.0") == ["Company2/1.0", "Company1/1.0"] 77 | assert list_cmd("Company1/") == ["Company1/2.0", "Company1/1.0"] 78 | assert list_cmd("Company1\\") == ["Company1/2.0", "Company1/1.0"] 79 | assert list_cmd("Company\\") == ["Company2/1.0", "Company1/2.0", "Company1/1.0"] 80 | 81 | assert list_cmd(">1") == ["Company1/2.0"] 82 | assert list_cmd(">=1") == ["Company2/1.0", "Company1/2.0", "Company1/1.0"] 83 | assert list_cmd("<=2") == ["Company2/1.0", "Company1/2.0", "Company1/1.0"] 84 | assert list_cmd("<2") == ["Company2/1.0", "Company1/1.0"] 85 | 86 | assert list_cmd("1", "2") == ["Company2/1.0", "Company1/2.0", "Company1/1.0"] 87 | assert list_cmd("Company1\\1", "Company2\\1") == ["Company2/1.0", "Company1/1.0"] 88 | 89 | 90 | def test_list_one(list_cmd): 91 | assert list_cmd("2", one=True) == ["Company1/2.0"] 92 | # Gets Company1/1.0 because it's marked as default 93 | assert list_cmd("1", one=True) == ["Company1/1.0"] 94 | 95 | 96 | def from_index(*filters): 97 | return [ 98 | f"{i['company']}/{i['tag']}" 99 | for i in list_command._get_installs_from_index([Index("./index.json", FAKE_INDEX)], filters) 100 | ] 101 | 102 | 103 | def test_list_online(): 104 | assert from_index("Company1\\2.0") == ["Company1/2.0"] 105 | assert from_index("Company2\\", "Company1\\1") == ["Company2/1.0", "Company1/1.0"] 106 | assert from_index() == ["Company1/2.0", "Company2/1.0", "Company1/1.0"] 107 | 108 | 109 | def test_format_table(assert_log): 110 | list_command.format_table(None, FAKE_INSTALLS) 111 | assert_log( 112 | (r"!B!Tag\s+Name\s+Managed By\s+Version\s+Alias\s*!W!", ()), 113 | (r"Company2\\1\.0\s+Company2\s+1\.0", ()), 114 | (r"Company1\\2\.0\s+Company1\s+2\.0", ()), 115 | (r"!G!Company1\\1\.0\s+\*\s+Company1\s+1\.0\s*!W!", ()), 116 | ) 117 | 118 | 119 | def test_format_table_aliases(assert_log): 120 | list_command.format_table(None, [ 121 | { 122 | "company": "COMPANY", 123 | "tag": "TAG", 124 | "display-name": "DISPLAY", 125 | "sort-version": "VER", 126 | "alias": [ 127 | {"name": "python.exe"}, 128 | {"name": "pythonw.exe"}, 129 | {"name": "python3.10.exe"}, 130 | {"name": "pythonw3.10.exe"}, 131 | ], 132 | } 133 | ]) 134 | assert_log( 135 | (r"!B!Tag\s+Name\s+Managed By\s+Version\s+Alias\s*!W!", ()), 136 | (r"COMPANY\\TAG\s+DISPLAY\s+COMPANY\s+VER\s+" + re.escape("python[w].exe, python[w]3.10.exe"), ()), 137 | ) 138 | 139 | 140 | def test_format_table_truncated(assert_log): 141 | list_command.format_table(None, [ 142 | { 143 | "company": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4, 144 | "tag": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4, 145 | "display-name": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4, 146 | "sort-version": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4, 147 | "alias": [ 148 | {"name": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 4}, 149 | ], 150 | } 151 | ]) 152 | assert_log( 153 | (r"!B!Tag\s+Name\s+Managed By\s+Version\s+Alias\s*!W!", ()), 154 | (r"\w{27}\.\.\.\s+\w{57}\.\.\.\s+\w{27}\.\.\.\s+\w{12}\.\.\.\s+\w{47}\.\.\.", ()), 155 | (r"", ()), 156 | (r".+columns were truncated.+", ()), 157 | ) 158 | 159 | 160 | def test_format_table_empty(assert_log): 161 | list_command.format_table(None, []) 162 | assert_log( 163 | (r"!B!Tag\s+Name\s+Managed By\s+Version\s+Alias\s*!W!", ()), 164 | (r".+No runtimes.+", ()), 165 | ) 166 | 167 | 168 | def test_format_csv(assert_log): 169 | list_command.format_csv(None, FAKE_INSTALLS) 170 | # CSV format only contains columns that are present, so this doesn't look 171 | # as complete as for normal installs, but it's fine for the test. 172 | assert_log( 173 | "company,tag,sort-version,default", 174 | "Company2,1.0,1.0,", 175 | "Company1,2.0,2.0,", 176 | "Company1,1.0,1.0,True", 177 | ) 178 | 179 | 180 | def test_format_csv_complex(assert_log): 181 | data = [ 182 | { 183 | **d, 184 | "alias": [dict(name=f"n{i}.{j}", target=f"t{i}.{j}") for j in range(i + 1)] 185 | } 186 | for i, d in enumerate(FAKE_INSTALLS) 187 | ] 188 | list_command.format_csv(None, data) 189 | assert_log( 190 | "company,tag,sort-version,alias.name,alias.target.default", 191 | "Company2,1.0,1.0,n0.0,t0.0,", 192 | "Company1,2.0,2.0,n1.0,t1.0,", 193 | "Company1,2.0,2.0,n1.1,t1.1,", 194 | "Company1,1.0,1.0,n2.0,t2.0,True", 195 | "Company1,1.0,1.0,n2.1,t2.1,True", 196 | "Company1,1.0,1.0,n2.2,t2.2,True", 197 | ) 198 | 199 | 200 | def test_format_csv_empty(assert_log): 201 | list_command.format_csv(None, []) 202 | assert_log(assert_log.end_of_log()) 203 | 204 | 205 | def test_csv_exclude(): 206 | result = list(list_command._csv_filter_and_expand([ 207 | dict(a=1, b=2), 208 | dict(a=3, c=4), 209 | dict(a=5, b=6, c=7), 210 | ], exclude={"b"})) 211 | assert result == [dict(a=1), dict(a=3, c=4), dict(a=5, c=7)] 212 | 213 | 214 | def test_csv_expand(): 215 | result = list(list_command._csv_filter_and_expand([ 216 | dict(a=[1, 2], b=[3, 4]), 217 | dict(a=[5], b=[6]), 218 | dict(a=7, b=8), 219 | ], expand={"a"})) 220 | assert result == [ 221 | dict(a=1, b=[3, 4]), 222 | dict(a=2, b=[3, 4]), 223 | dict(a=5, b=[6]), 224 | dict(a=7, b=8), 225 | ] 226 | 227 | 228 | def test_formats(assert_log): 229 | list_command.list_formats(None, ["fake", "installs", "that", "should", "crash", 123]) 230 | assert_log( 231 | r".*Format\s+Description", 232 | r"table\s+Lists.+", 233 | # Assume the rest are okay 234 | ) 235 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | _read_script, 12 | NewEncoding, 13 | _maybe_quote, 14 | quote_args, 15 | split_args 16 | ) 17 | 18 | def _fake_install(v, **kwargs): 19 | return { 20 | "company": kwargs.get("company", "Test"), 21 | "id": f"test-{v}", 22 | "tag": str(v), 23 | "version": str(v), 24 | "prefix": PurePath(f"./pkgs/test-{v}"), 25 | "executable": PurePath(f"./pkgs/test-{v}/test-binary-{v}.exe"), 26 | **kwargs 27 | } 28 | 29 | INSTALLS = [ 30 | _fake_install("1.0", alias=[{"name": "test1.0.exe", "target": "./test-binary-1.0.exe"}]), 31 | _fake_install("1.1", alias=[{"name": "test1.1.exe", "target": "./test-binary-1.1.exe"}]), 32 | _fake_install("1.3.1", company="PythonCore"), 33 | _fake_install("1.3.2", company="PythonOther"), 34 | _fake_install("2.0", alias=[{"name": "test2.0.exe", "target": "./test-binary-2.0.exe"}]), 35 | ] 36 | 37 | @pytest.mark.parametrize("script, expect", [ 38 | ("", None), 39 | ("#! /usr/bin/test1.0\n#! /usr/bin/test2.0\n", "1.0"), 40 | ("#! /usr/bin/test2.0\n#! /usr/bin/test1.0\n", "2.0"), 41 | ("#! /usr/bin/test1.0.exe\n#! /usr/bin/test2.0\n", "1.0"), 42 | ("#!test1.0.exe\n", "1.0"), 43 | ("#!test1.1.exe\n", "1.1"), 44 | ("#!test1.2.exe\n", None), 45 | ("#!test-binary-1.1.exe\n", "1.1"), 46 | ("#!.\\pkgs\\test-1.1\\test-binary-1.1.exe\n", "1.1"), 47 | ("#!.\\pkgs\\test-1.0\\test-binary-1.1.exe\n", None), 48 | ("#! /usr/bin/env test1.0\n", "1.0"), 49 | ("#! /usr/bin/env test2.0\n", "2.0"), 50 | ("#! /usr/bin/env -S test2.0\n", "2.0"), 51 | # Legacy handling specifically for "python" 52 | ("#! /usr/bin/python1.3.1", "1.3.1"), 53 | ("#! /usr/bin/env python1.3.1", "1.3.1"), 54 | ("#! /usr/bin/python1.3.2", None), 55 | ]) 56 | def test_read_shebang(fake_config, tmp_path, script, expect): 57 | fake_config.installs.extend(INSTALLS) 58 | if expect: 59 | expect = [i for i in INSTALLS if i["tag"] == expect][0] 60 | 61 | script_py = tmp_path / "test-script.py" 62 | if isinstance(script, str): 63 | script = script.encode() 64 | script_py.write_bytes(script) 65 | try: 66 | actual = find_install_from_script(fake_config, script_py) 67 | assert expect == actual 68 | except LookupError: 69 | assert not expect 70 | 71 | 72 | @pytest.mark.parametrize("script, expect", [ 73 | ("# not a coding comment", None), 74 | ("# coding: utf-8-sig", None), 75 | ("# coding: utf-8", "utf-8"), 76 | ("# coding: ascii", "ascii"), 77 | ("# actually a coding: comment", "comment"), 78 | ("#~=~=~=coding:ascii=~=~=~=~", "ascii"), 79 | ("#! /usr/bin/env python\n# coding: ascii", None), 80 | ]) 81 | def test_read_coding_comment(fake_config, tmp_path, script, expect): 82 | script_py = tmp_path / "test-script.py" 83 | if isinstance(script, str): 84 | script = script.encode() 85 | script_py.write_bytes(script) 86 | try: 87 | _read_script(fake_config, script_py, "utf-8-sig") 88 | except NewEncoding as enc: 89 | assert enc.args[0] == expect 90 | except LookupError: 91 | assert not expect 92 | else: 93 | assert not expect 94 | 95 | 96 | @pytest.mark.parametrize("arg, expect", [pytest.param(*a, id=a[0]) for a in [ 97 | ('abc', 'abc'), 98 | ('a b c', '"a b c"'), 99 | ('abc ', '"abc "'), 100 | (' abc', '" abc"'), 101 | ('a1\\b\\c', 'a1\\b\\c'), 102 | ('a2\\ b', '"a2\\ b"'), 103 | ('a3\\b\\', 'a3\\b\\'), 104 | ('a4 b\\', '"a4 b\\\\"'), 105 | ('a5 b\\\\', '"a5 b\\\\\\\\"'), 106 | ('a1"b', 'a1\\"b'), 107 | ('a2\\"b', 'a2\\\\\\"b'), 108 | ('a3\\\\"b', 'a3\\\\\\\\\\"b'), 109 | ('a4\\\\\\"b', 'a4\\\\\\\\\\\\\\"b'), 110 | ('a5 "b', '"a5 \\"b"'), 111 | ('a6\\ "b', '"a6\\ \\"b"'), 112 | ('a7 \\"b', '"a7 \\\\\\"b"'), 113 | ]]) 114 | def test_quote_one_arg(arg, expect): 115 | # Test our expected result by passing it to Python and checking what it sees 116 | test_cmd = ( 117 | 'python -c "import base64, sys; ' 118 | 'expect = base64.b64decode(\'{}\').decode(); ' 119 | 'print(\'Expect:\', repr(expect), \' Actual:\', repr(sys.argv[1])); ' 120 | 'sys.exit(0 if expect == sys.argv[1] else 1)" {} END_OF_ARGS' 121 | ).format(base64.b64encode(arg.encode()).decode("ascii"), expect) 122 | subprocess.check_call(test_cmd, executable=sys.executable) 123 | # Test that our quote function produces the expected result 124 | assert expect == _maybe_quote(arg) 125 | 126 | 127 | @pytest.mark.parametrize("arg, expect, expect_call", [pytest.param(*a, id=a[0]) for a in [ 128 | ('"a1 b"', '"a1 b"', 'a1 b'), 129 | ('"a2" b"', '"a2\\" b"', 'a2" b'), 130 | ]]) 131 | def test_quote_one_quoted_arg(arg, expect, expect_call): 132 | # Test our expected result by passing it to Python and checking what it sees 133 | test_cmd = ( 134 | 'python -c "import base64, sys; ' 135 | 'expect = base64.b64decode(\'{}\').decode(); ' 136 | 'print(\'Expect:\', repr(expect), \' Actual:\', repr(sys.argv[1])); ' 137 | 'sys.exit(0 if expect == sys.argv[1] else 1)" {} END_OF_ARGS' 138 | ).format(base64.b64encode(expect_call.encode()).decode("ascii"), expect) 139 | subprocess.check_call(test_cmd, executable=sys.executable) 140 | # Test that our quote function produces the expected result 141 | assert expect == _maybe_quote(arg) 142 | 143 | 144 | # We're not going to try too hard here - most of the tricky logic is covered 145 | # by the previous couple of tests. 146 | @pytest.mark.parametrize("args, expect", [pytest.param(*a, id=a[1]) for a in [ 147 | (["a1", "b", "c"], 'a1 b c'), 148 | (["a2 b", "c d"], '"a2 b" "c d"'), 149 | (['a3"b', 'c"d', 'e f'], 'a3\\"b c\\"d "e f"'), 150 | (['a4"b c"d', 'e f'], '"a4\\"b c\\"d" "e f"'), 151 | (['a5\\b\\', 'c\\d'], 'a5\\b\\ c\\d'), 152 | (['a6\\b\\ c\\', 'd\\e'], '"a6\\b\\ c\\\\" d\\e'), 153 | ]]) 154 | def test_quote_args(args, expect): 155 | # Test our expected result by passing it to Python and checking what it sees 156 | test_cmd = ( 157 | 'python -c "import base64, sys; ' 158 | 'expect = base64.b64decode(\'{}\').decode().split(\'\\0\'); ' 159 | 'print(\'Expect:\', repr(expect), \' Actual:\', repr(sys.argv)); ' 160 | 'sys.exit(0 if expect == sys.argv[1:-1] else 1)" {} END_OF_ARGS' 161 | ).format(base64.b64encode('\0'.join(args).encode()).decode("ascii"), expect) 162 | subprocess.check_call(test_cmd, executable=sys.executable) 163 | # Test that our quote function produces the expected result 164 | assert expect == quote_args(args) 165 | # Test that our split function produces the same result 166 | assert args == split_args(expect), expect 167 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.coinitialize() 11 | _native.shortcut_create( 12 | tmp_path / "test.lnk", 13 | tmp_path / "target.txt", 14 | ) 15 | assert (tmp_path / "test.lnk").is_file() 16 | 17 | 18 | 19 | def test_start_path(): 20 | _native.coinitialize() 21 | p = Path(_native.shortcut_get_start_programs()) 22 | assert p.is_dir() 23 | 24 | # Should be writable 25 | f = p / "__test_file.txt" 26 | try: 27 | open(f, "wb").close() 28 | finally: 29 | f.unlink() 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------