├── .editorconfig ├── .gitignore ├── HISTORY.md ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.rst ├── appveyor.yml ├── docs ├── __init__.py ├── requirements.txt └── source │ ├── conf.py │ ├── develop.rst │ ├── explain.rst │ ├── history.rst │ ├── implement.rst │ ├── index.rst │ ├── install.rst │ ├── manage.rst │ └── misc.rst ├── installers ├── Dockerfile ├── __init__.py ├── lib │ └── setup │ │ ├── activation.py │ │ ├── discovery.py │ │ ├── env.py │ │ └── shim.py └── setup.nsi ├── pythonup ├── __init__.py ├── __main__.py ├── configs.py ├── installation.json ├── installations.py ├── metadata.py ├── operations │ ├── __init__.py │ ├── common.py │ ├── download.py │ ├── install.py │ ├── link.py │ ├── releases.py │ └── versions.py ├── releases.py ├── termui.py ├── utils.py ├── versions.py └── versions │ ├── 2.7.json │ ├── 3.10-32.json │ ├── 3.10.json │ ├── 3.4.json │ ├── 3.5-32.json │ ├── 3.5.json │ ├── 3.6-32.json │ ├── 3.6.json │ ├── 3.7-32.json │ ├── 3.7.json │ ├── 3.8-32.json │ ├── 3.8.json │ ├── 3.9-32.json │ └── 3.9.json ├── setup.cfg ├── shims ├── Cargo.lock ├── Cargo.toml ├── __init__.py └── src │ ├── cmds.rs │ ├── main.rs │ └── procs.rs ├── stubs ├── README.txt └── shim.exe ├── tasks.py ├── tests ├── conftest.py ├── test_installations.py └── test_versions.py └── tools ├── check_md5.py ├── get_msi_product_code.py └── update_version.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = tab 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.{py,rs,rst}] 16 | indent_style = space 17 | 18 | [*.yml] 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .coverage 3 | .venv 4 | .vscode 5 | 6 | *.py[co] 7 | __pycache__ 8 | 9 | /docs/build/ 10 | 11 | /shims/**/target/ 12 | **/*.rs.bk 13 | 14 | /installers/*.exe 15 | /installers/assets/ 16 | /installers/pythonup/ 17 | 18 | /runenv 19 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | ## Next (not released) 2 | 3 | Nothing yet. 4 | 5 | 6 | ## Unstable 7 | 8 | Nothing yet. 9 | 10 | 11 | ## Version 3.0.11 12 | 13 | * Upgrade Python 3.10 definition to 3.10.1. 14 | * Upgrade Python 3.9 definition to 3.9.9. 15 | 16 | 17 | ## Version 3.0.10 18 | 19 | * Fix `pythonup list` to put 3.10 after 3.9. 20 | * Upgrade Bleach to 3.3.0. 21 | * Add Python 3.10 definition (3.10.0). 22 | * Upgrade Python 3.9 definition to 3.9.8. 23 | * Upgrade Python 3.8 definition to 3.8.10. 24 | 25 | 26 | ## Version 3.0.9 27 | 28 | * Upgrade Bleach to 3.1.4. 29 | * Add Python 3.9 definition (3.9.0). 30 | * Upgrade Python 3.8 definition to 3.8.6. 31 | * Upgrade Python 3.7 definition to 3.7.9. 32 | * The bundled Python (used to run PythonUp) is upgraded to 3.6.8. 33 | 34 | 35 | ## Version 3.0.8 36 | 37 | * Upgrade Python 3.8 definition to 3.8.5. 38 | * Upgrade Python 3.7 definition to 3.7.8. 39 | 40 | 41 | ## Version 3.0.7 42 | 43 | * Upgrade Bleach to 3.1.1. 44 | * Upgrade Python 3.8 definition to 3.8.2. 45 | * Upgrade Python 3.7 definition to 3.7.7. 46 | 47 | 48 | ## Version 3.0.6 49 | 50 | * Fix attrs compatibility. 51 | 52 | 53 | ## Version 3.0.5 54 | 55 | ### Behavioural Changes 56 | 57 | * Add Python 3.8 definition (3.8.0). 58 | * Upgrade Python 3.7 definition to 3.7.5. 59 | 60 | 61 | ## Version 3.0.4 62 | 63 | ### Behavioural Changes 64 | 65 | * Upgrade Python 3.7 definition to 3.7.3. 66 | 67 | 68 | ## Verson 3.0.3 69 | 70 | ### Behavioural Changes 71 | 72 | * Upgrade Python 3.6 definition to 3.6.8. 73 | 74 | ## UI Changes 75 | 76 | * pip shims no longer displays “Not using any versions”. 77 | * Fix a tense difference in command help messages. 78 | 79 | 80 | ## Version 3.0.2 81 | 82 | ### Behavioural Changes 83 | 84 | * Upgrade Python 3.6 definition to 3.6.7. 85 | * Upgrade Python 3.7 definition to 3.7.1. 86 | 87 | ### Installer Changes 88 | 89 | * The bundled Python (used to run PythonUp) is upgraded to 3.6.7. 90 | 91 | 92 | ## Version 3.0.1 93 | 94 | Fix `pythonup upgrade self`. The repository moved. 95 | 96 | 97 | ## Version 3.0 98 | 99 | Renamed project from *SNAFU* to *PythonUp*. The entry command is also renamed accordingly. Installation target is now `%LOCALAPPDATA%\Programs\PythonUp`. 100 | 101 | ### UI Changes 102 | 103 | * The main command is renamed to `pythonup`. 104 | 105 | ### Behavioural Changes 106 | 107 | * The scripts PATH is moved ahead of the cmd. This provides potential for more flexible customisations, i.e. if multiple sources install the same executable (CPython and Anaconda, for example), one (CPython) can take precedence in cmd, but allow the user to override this by the `use` command. 108 | * PythonUp now works system-wide Python installations, and can publish shims for them as well as `snafu install`-ed ones. It still only supports installing them in per-user mode, but other commands should mostly work. 109 | **EXCEPTION**: Upgrading an MSI-based Python version (3.4 or earlier) is not supported. 110 | * Bugs are fixed to correctly detect various registry values. 111 | * The shims are updated to work independently from registry values. The active version registry values are removed in favour of an installation-local configuration file. This should not affect how the user interacts with PythonUp. 112 | * Upgrade Python 2.7 definition to 2.7.15. 113 | * Upgrade Python 3.6 definition to 3.6.6. 114 | * Add Python 3.7 definition (3.7.0). 115 | 116 | ### Installer Changes 117 | 118 | * The bundled Python (used to run PythonUp) is upgraded to 3.6.6. 119 | 120 | 121 | ## Version 2.0 122 | 123 | ### UI Changes 124 | 125 | * Remove confusing `activate` and `deactivate` commands in favour of the `use` command. The previous “reactivation” behaviour is now mapped to `link --all`. 126 | * A newly installed Python will be used if there are no other versions detected. 127 | * Add `snafu upgrade self` to perform in-place upgrade without manually downloading the installer. 128 | * Add `snafu download ` to download installer of given Python version without installing. 129 | * More complete help messages are provided to command arguments. 130 | * The uninstalling processes are now as interaction-free as installing. Previously some user intervention was needed (especially the EXE-based versions). 131 | 132 | ### Behavioural Changes 133 | 134 | * `snafu install` now automatically uses the version if it is the only Python installation detected. This should simplify things even more for beginners. 135 | * Minor update to Python 3.4 definition. 136 | * Call `snafu link --all` automatically after installing with a shimmed pip command. 137 | * Upgrade Python 3.4 definition to 3.4.4. 138 | * Upgrade Python 3.6 definition to 3.6.4. 139 | * Improve behaviour when uninstalling versions not installed by SNAFU. 140 | * `snafu link --all` without active versions does not fail anymore. The warning message is still printed, but the exit code is now 0. 141 | * Py Launcher usage is reduced in favour of reading the registry ourselves. 142 | * Documentation is no longer installed with each Python version. 143 | 144 | ### Installer Changes 145 | 146 | * Add an option to install and use a Python version after SNAFU is set up. 147 | * 64-bit variant does not carry x86 MSU files anymore, reducing installer size. 148 | * Correctly install Windows update KB2999226 on 32-bit Windows. 149 | 150 | 151 | ## Version 1.0 152 | 153 | ### UI Changes 154 | 155 | * New `upgrade` command to install a newer patch version on an installed version. 156 | * New `link` command to manually publish a script from the active Python versions. 157 | 158 | ### Behavioural Changes 159 | 160 | * `pip-X.Y` are now published on install. 161 | * Automatically deactivate an uninstalling active version. 162 | * A Python exception will be raised early on download error, instead of failing later during installation. 163 | * Uninstallation now skips gracefully if launcher scripts do not exist. 164 | 165 | ### Installer Changes 166 | 167 | * Bundle and trigger Windows update KB2999226 on installation to provide necessary runtime files on Windows Vista to 8.1 so the bundled Python 3.6 can run correctly. 168 | * Environment variables changes can now propagate correctly without OS restart. 169 | 170 | 171 | ## Version 0.2 172 | 173 | ### UI Changes 174 | 175 | * `uninstall` now attempts to use Windows’s uninstall feature to avoid re-download. 176 | * `install` and `uninstall` receives a `--file` option to allow using local installers without re-downloading. 177 | **IMPORTANT:** Correctness of the installer is not checked. The user is responsible for choosing the correct and valid installer file. Results of installing a faulty installer is undefined. 178 | * New command `where` to show where the actual interpreter is. This is useful if you need to pass it to another command (e.g. `pipenv --python`). 179 | * `list` shows activeness. 180 | * `snafu --version` shows program version. 181 | 182 | ### Behavioural Changes 183 | 184 | * `activate` writes a pin file showing the current active versions. 185 | * Symbols in `snafu list` are changed. 186 | 187 | ### Installer Changes 188 | 189 | * Environment variables are now set up automatically during installation. 190 | * Extract the py launcher MSI to make the distribution substentially smaller. 191 | * The installer now comes with both 64- and 32-bit flavours. 192 | * The uninstaller is now added to registry, so you can remove SNAFU in Control Panel. 193 | 194 | 195 | ## Version 0.1 196 | 197 | Initial release. Features I want the most are largely implemented. Only 64-bit Pythons are supported for now, and the installer is 64-bit-only. 198 | 199 | An all-in-one installer that installs SNAFU into 200 | `%LOCALAPPDATA%\Programs\SNAFU`, and sets up the py launcher. 201 | 202 | * `snafu install/uninstall ` 203 | * `snafu list [--all]` 204 | * `snafu activate/deactivate [ ...]` 205 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Tzu-ping Chung 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = 'pypi' 3 | url = 'https://pypi.org/simple' 4 | verify_ssl = true 5 | 6 | [requires] 7 | python_version = '3.6' 8 | 9 | [packages] 10 | attrs = '*' 11 | click = '~=7.0' 12 | packaging = '*' 13 | requests = '*' 14 | 15 | [dev-packages] 16 | flake8 = '*' 17 | invoke = '*' 18 | m2r = '*' 19 | pytest = '*' 20 | pytest-mock = '*' 21 | pytest-pythonpath = '*' 22 | restview = '*' 23 | sphinx = '*' 24 | sphinx-autobuild = '*' 25 | 26 | [scripts] 27 | pythonup = 'python -m pythonup' 28 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "a8d00e3837e0026b29e32073b2c0e12c906056c8e54119b90d46d2c501525da8" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "attrs": { 20 | "hashes": [ 21 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 22 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 23 | ], 24 | "index": "pypi", 25 | "version": "==21.2.0" 26 | }, 27 | "certifi": { 28 | "hashes": [ 29 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 30 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 31 | ], 32 | "version": "==2021.10.8" 33 | }, 34 | "charset-normalizer": { 35 | "hashes": [ 36 | "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0", 37 | "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b" 38 | ], 39 | "markers": "python_version >= '3'", 40 | "version": "==2.0.7" 41 | }, 42 | "click": { 43 | "hashes": [ 44 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 45 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 46 | ], 47 | "index": "pypi", 48 | "version": "==7.1.2" 49 | }, 50 | "idna": { 51 | "hashes": [ 52 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 53 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 54 | ], 55 | "markers": "python_version >= '3'", 56 | "version": "==3.3" 57 | }, 58 | "packaging": { 59 | "hashes": [ 60 | "sha256:096d689d78ca690e4cd8a89568ba06d07ca097e3306a4381635073ca91479966", 61 | "sha256:14317396d1e8cdb122989b916fa2c7e9ca8e2be9e8060a6eff75b6b7b4d8a7e0" 62 | ], 63 | "index": "pypi", 64 | "version": "==21.2" 65 | }, 66 | "pyparsing": { 67 | "hashes": [ 68 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 69 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 70 | ], 71 | "version": "==2.4.7" 72 | }, 73 | "requests": { 74 | "hashes": [ 75 | "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", 76 | "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" 77 | ], 78 | "index": "pypi", 79 | "version": "==2.26.0" 80 | }, 81 | "urllib3": { 82 | "hashes": [ 83 | "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", 84 | "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" 85 | ], 86 | "version": "==1.26.7" 87 | } 88 | }, 89 | "develop": { 90 | "alabaster": { 91 | "hashes": [ 92 | "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", 93 | "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" 94 | ], 95 | "version": "==0.7.12" 96 | }, 97 | "atomicwrites": { 98 | "hashes": [ 99 | "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197", 100 | "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a" 101 | ], 102 | "markers": "sys_platform == 'win32'", 103 | "version": "==1.4.0" 104 | }, 105 | "attrs": { 106 | "hashes": [ 107 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 108 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 109 | ], 110 | "index": "pypi", 111 | "version": "==21.2.0" 112 | }, 113 | "babel": { 114 | "hashes": [ 115 | "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9", 116 | "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0" 117 | ], 118 | "version": "==2.9.1" 119 | }, 120 | "bleach": { 121 | "hashes": [ 122 | "sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da", 123 | "sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994" 124 | ], 125 | "version": "==4.1.0" 126 | }, 127 | "certifi": { 128 | "hashes": [ 129 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 130 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 131 | ], 132 | "version": "==2021.10.8" 133 | }, 134 | "charset-normalizer": { 135 | "hashes": [ 136 | "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0", 137 | "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b" 138 | ], 139 | "markers": "python_version >= '3'", 140 | "version": "==2.0.7" 141 | }, 142 | "colorama": { 143 | "hashes": [ 144 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 145 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 146 | ], 147 | "markers": "sys_platform == 'win32'", 148 | "version": "==0.4.4" 149 | }, 150 | "docutils": { 151 | "hashes": [ 152 | "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", 153 | "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" 154 | ], 155 | "version": "==0.17.1" 156 | }, 157 | "flake8": { 158 | "hashes": [ 159 | "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d", 160 | "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d" 161 | ], 162 | "index": "pypi", 163 | "version": "==4.0.1" 164 | }, 165 | "idna": { 166 | "hashes": [ 167 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 168 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 169 | ], 170 | "markers": "python_version >= '3'", 171 | "version": "==3.3" 172 | }, 173 | "imagesize": { 174 | "hashes": [ 175 | "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", 176 | "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" 177 | ], 178 | "version": "==1.2.0" 179 | }, 180 | "importlib-metadata": { 181 | "hashes": [ 182 | "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b", 183 | "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31" 184 | ], 185 | "markers": "python_version < '3.8'", 186 | "version": "==4.2.0" 187 | }, 188 | "iniconfig": { 189 | "hashes": [ 190 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 191 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 192 | ], 193 | "version": "==1.1.1" 194 | }, 195 | "invoke": { 196 | "hashes": [ 197 | "sha256:374d1e2ecf78981da94bfaf95366216aaec27c2d6a7b7d5818d92da55aa258d3", 198 | "sha256:769e90caeb1bd07d484821732f931f1ad8916a38e3f3e618644687fc09cb6317", 199 | "sha256:e6c9917a1e3e73e7ea91fdf82d5f151ccfe85bf30cc65cdb892444c02dbb5f74" 200 | ], 201 | "index": "pypi", 202 | "version": "==1.6.0" 203 | }, 204 | "jinja2": { 205 | "hashes": [ 206 | "sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45", 207 | "sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c" 208 | ], 209 | "version": "==3.0.2" 210 | }, 211 | "livereload": { 212 | "hashes": [ 213 | "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869" 214 | ], 215 | "version": "==2.6.3" 216 | }, 217 | "m2r": { 218 | "hashes": [ 219 | "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99" 220 | ], 221 | "index": "pypi", 222 | "version": "==0.2.1" 223 | }, 224 | "markupsafe": { 225 | "hashes": [ 226 | "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", 227 | "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", 228 | "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", 229 | "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", 230 | "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", 231 | "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", 232 | "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", 233 | "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", 234 | "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", 235 | "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", 236 | "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", 237 | "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", 238 | "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", 239 | "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", 240 | "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", 241 | "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", 242 | "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", 243 | "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", 244 | "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", 245 | "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", 246 | "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", 247 | "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", 248 | "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", 249 | "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", 250 | "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", 251 | "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", 252 | "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", 253 | "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", 254 | "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", 255 | "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", 256 | "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", 257 | "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", 258 | "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", 259 | "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", 260 | "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", 261 | "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", 262 | "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", 263 | "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", 264 | "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", 265 | "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", 266 | "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", 267 | "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", 268 | "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", 269 | "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", 270 | "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", 271 | "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", 272 | "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", 273 | "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", 274 | "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", 275 | "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", 276 | "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", 277 | "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", 278 | "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", 279 | "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", 280 | "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", 281 | "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", 282 | "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", 283 | "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", 284 | "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", 285 | "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", 286 | "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", 287 | "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", 288 | "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", 289 | "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", 290 | "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", 291 | "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", 292 | "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", 293 | "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", 294 | "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" 295 | ], 296 | "version": "==2.0.1" 297 | }, 298 | "mccabe": { 299 | "hashes": [ 300 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 301 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 302 | ], 303 | "version": "==0.6.1" 304 | }, 305 | "mistune": { 306 | "hashes": [ 307 | "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e", 308 | "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4" 309 | ], 310 | "version": "==0.8.4" 311 | }, 312 | "packaging": { 313 | "hashes": [ 314 | "sha256:096d689d78ca690e4cd8a89568ba06d07ca097e3306a4381635073ca91479966", 315 | "sha256:14317396d1e8cdb122989b916fa2c7e9ca8e2be9e8060a6eff75b6b7b4d8a7e0" 316 | ], 317 | "index": "pypi", 318 | "version": "==21.2" 319 | }, 320 | "pluggy": { 321 | "hashes": [ 322 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 323 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 324 | ], 325 | "version": "==1.0.0" 326 | }, 327 | "py": { 328 | "hashes": [ 329 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", 330 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" 331 | ], 332 | "version": "==1.11.0" 333 | }, 334 | "pycodestyle": { 335 | "hashes": [ 336 | "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20", 337 | "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f" 338 | ], 339 | "version": "==2.8.0" 340 | }, 341 | "pyflakes": { 342 | "hashes": [ 343 | "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c", 344 | "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e" 345 | ], 346 | "version": "==2.4.0" 347 | }, 348 | "pygments": { 349 | "hashes": [ 350 | "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380", 351 | "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6" 352 | ], 353 | "version": "==2.10.0" 354 | }, 355 | "pyparsing": { 356 | "hashes": [ 357 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 358 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 359 | ], 360 | "version": "==2.4.7" 361 | }, 362 | "pytest": { 363 | "hashes": [ 364 | "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89", 365 | "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134" 366 | ], 367 | "index": "pypi", 368 | "version": "==6.2.5" 369 | }, 370 | "pytest-mock": { 371 | "hashes": [ 372 | "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3", 373 | "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62" 374 | ], 375 | "index": "pypi", 376 | "version": "==3.6.1" 377 | }, 378 | "pytest-pythonpath": { 379 | "hashes": [ 380 | "sha256:63fc546ace7d2c845c1ee289e8f7a6362c2b6bae497d10c716e58e253e801d62" 381 | ], 382 | "index": "pypi", 383 | "version": "==0.7.3" 384 | }, 385 | "pytz": { 386 | "hashes": [ 387 | "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c", 388 | "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326" 389 | ], 390 | "version": "==2021.3" 391 | }, 392 | "readme-renderer": { 393 | "hashes": [ 394 | "sha256:3286806450d9961d6e3b5f8a59f77e61503799aca5155c8d8d40359b4e1e1adc", 395 | "sha256:8299700d7a910c304072a7601eafada6712a5b011a20139417e1b1e9f04645d8" 396 | ], 397 | "version": "==30.0" 398 | }, 399 | "requests": { 400 | "hashes": [ 401 | "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", 402 | "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" 403 | ], 404 | "index": "pypi", 405 | "version": "==2.26.0" 406 | }, 407 | "restview": { 408 | "hashes": [ 409 | "sha256:47929bae12e7cf39cbb93a5a5a16f970941e39301c05cbb4ea398df8c113b1e2", 410 | "sha256:790097eb587c0465126dde73ca06c7a22c5007ce1be4a1de449a13c0767b32dc" 411 | ], 412 | "index": "pypi", 413 | "version": "==2.9.2" 414 | }, 415 | "six": { 416 | "hashes": [ 417 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 418 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 419 | ], 420 | "version": "==1.16.0" 421 | }, 422 | "snowballstemmer": { 423 | "hashes": [ 424 | "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2", 425 | "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914" 426 | ], 427 | "version": "==2.1.0" 428 | }, 429 | "sphinx": { 430 | "hashes": [ 431 | "sha256:94078db9184491e15bce0a56d9186e0aec95f16ac20b12d00e06d4e36f1058a6", 432 | "sha256:98a535c62a4fcfcc362528592f69b26f7caec587d32cd55688db580be0287ae0" 433 | ], 434 | "index": "pypi", 435 | "version": "==4.2.0" 436 | }, 437 | "sphinx-autobuild": { 438 | "hashes": [ 439 | "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac", 440 | "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05" 441 | ], 442 | "index": "pypi", 443 | "version": "==2021.3.14" 444 | }, 445 | "sphinxcontrib-applehelp": { 446 | "hashes": [ 447 | "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", 448 | "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" 449 | ], 450 | "version": "==1.0.2" 451 | }, 452 | "sphinxcontrib-devhelp": { 453 | "hashes": [ 454 | "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", 455 | "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" 456 | ], 457 | "version": "==1.0.2" 458 | }, 459 | "sphinxcontrib-htmlhelp": { 460 | "hashes": [ 461 | "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", 462 | "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" 463 | ], 464 | "version": "==2.0.0" 465 | }, 466 | "sphinxcontrib-jsmath": { 467 | "hashes": [ 468 | "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", 469 | "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" 470 | ], 471 | "version": "==1.0.1" 472 | }, 473 | "sphinxcontrib-qthelp": { 474 | "hashes": [ 475 | "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", 476 | "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" 477 | ], 478 | "version": "==1.0.3" 479 | }, 480 | "sphinxcontrib-serializinghtml": { 481 | "hashes": [ 482 | "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", 483 | "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" 484 | ], 485 | "version": "==1.1.5" 486 | }, 487 | "toml": { 488 | "hashes": [ 489 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 490 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 491 | ], 492 | "version": "==0.10.2" 493 | }, 494 | "tornado": { 495 | "hashes": [ 496 | "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb", 497 | "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c", 498 | "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288", 499 | "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95", 500 | "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558", 501 | "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe", 502 | "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791", 503 | "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d", 504 | "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326", 505 | "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b", 506 | "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4", 507 | "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c", 508 | "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910", 509 | "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5", 510 | "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c", 511 | "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0", 512 | "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675", 513 | "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd", 514 | "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f", 515 | "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c", 516 | "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea", 517 | "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6", 518 | "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05", 519 | "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd", 520 | "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575", 521 | "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a", 522 | "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37", 523 | "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795", 524 | "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f", 525 | "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32", 526 | "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c", 527 | "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01", 528 | "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4", 529 | "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2", 530 | "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921", 531 | "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085", 532 | "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df", 533 | "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102", 534 | "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5", 535 | "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68", 536 | "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5" 537 | ], 538 | "version": "==6.1" 539 | }, 540 | "typing-extensions": { 541 | "hashes": [ 542 | "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e", 543 | "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7", 544 | "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34" 545 | ], 546 | "markers": "python_version < '3.8'", 547 | "version": "==3.10.0.2" 548 | }, 549 | "urllib3": { 550 | "hashes": [ 551 | "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", 552 | "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" 553 | ], 554 | "version": "==1.26.7" 555 | }, 556 | "webencodings": { 557 | "hashes": [ 558 | "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 559 | "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 560 | ], 561 | "version": "==0.5.1" 562 | }, 563 | "zipp": { 564 | "hashes": [ 565 | "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832", 566 | "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc" 567 | ], 568 | "version": "==3.6.0" 569 | } 570 | } 571 | } 572 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================================================= 2 | PythonUp — The Python Runtime Manager (Windows) 3 | ================================================= 4 | 5 | .. image:: https://ci.appveyor.com/api/projects/status/7fdfpbvu2roawg23/branch/master?svg=true 6 | :target: https://ci.appveyor.com/project/uranusjr/pythonup-windows 7 | :alt: Build status 8 | 9 | .. image:: https://readthedocs.org/projects/pythonup-windows/badge/?version=latest 10 | :target: https://pythonup-windows.readthedocs.io/en/latest/ 11 | :alt: Documentation Status 12 | 13 | PythonUp helps your download, configure, install, and manage Python runtimes. 14 | It also provides utilities that can be integrated into your Python-related 15 | development workflows. This is the Windows version. 16 | 17 | .. highlights:: 18 | 19 | macOS or Linux user? Check out `PythonUp for POSIX`_. 20 | 21 | .. _`PythonUp for POSIX`: https://github.com/uranusjr/pythonup-posix 22 | 23 | 24 | Distribution 25 | ============ 26 | 27 | PythonUp for Windows is officially distributed on GitHub. Download installers 28 | from `Releases `_ and 29 | run it. After installation, a ``pythonup`` command will be available in 30 | newly-opened command prompts. 31 | 32 | 33 | Quick Start 34 | =========== 35 | 36 | Install Python 3.6:: 37 | 38 | $ pythonup install 3.6 39 | 40 | Run Python:: 41 | 42 | $ python3 43 | 44 | Install Pipenv to Python 3.6:: 45 | 46 | $ pip3.6 install pipenv 47 | 48 | And use it immediately:: 49 | 50 | $ pipenv --version 51 | pipenv, version 9.0.1 52 | 53 | Install Python 3.5 (32-bit):: 54 | 55 | $ pythonup install 3.5-32 56 | 57 | Switch to a specific version:: 58 | 59 | $ pythonup use 3.5-32 60 | $ python3 --version 61 | Python 3.5.4 62 | 63 | Switch back to 3.6:: 64 | 65 | $ pythonup use 3.6 66 | $ python3 --version 67 | Python 3.6.4 68 | $ python3.5 --version 69 | Python 3.5.4 70 | 71 | Uninstall Python:: 72 | 73 | $ pythonup uninstall 3.5-32 74 | 75 | Use ``--help`` to find more:: 76 | 77 | $ pythonup --help 78 | $ pythonup install --help 79 | 80 | Or read the `Documentation `_. 81 | 82 | Now you’re ready to use CPython on Windows like a first-class citizen, and 83 | ignore people telling you to get a Mac. 84 | 85 | 🤔😉😆 86 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | image: Visual Studio 2017 2 | 3 | max_jobs: 2 4 | 5 | environment: 6 | matrix: 7 | - PYTHON: "C:\\Python36-x64" 8 | INSTALLER_ARCH: "amd64" 9 | RUST_HOST: x86_64-pc-windows-msvc 10 | - PYTHON: "C:\\Python36" 11 | INSTALLER_ARCH: "win32" 12 | RUST_HOST: i686-pc-windows-msvc 13 | 14 | platform: 15 | - x86 16 | - x64 17 | 18 | matrix: 19 | exclude: 20 | - platform: x86 21 | INSTALLER_ARCH: "amd64" 22 | - platform: x64 23 | INSTALLER_ARCH: "win32" 24 | 25 | install: 26 | - SET PATH=%PATH%;C:\Program Files (x86)\NSIS\Bin 27 | 28 | - curl -sSf -o rustup-init.exe https://win.rustup.rs 29 | - .\rustup-init.exe --default-host %RUST_HOST% --default-toolchain stable -y 30 | - SET PATH=%PATH%;C:\Users\appveyor\.cargo\bin 31 | 32 | - SET PATH=%PYTHON%;%PYTHON%\Scripts;%PATH% 33 | - python -m pip install --disable-pip-version-check --upgrade pip 34 | - pip install --upgrade pipenv 35 | - pipenv sync --system --dev 36 | 37 | - SET PATH 38 | 39 | build_script: 40 | - for /f %%i in ('git describe') do @set REPO_GIT_VERSION=%%i 41 | - invoke installers.build --version=%REPO_GIT_VERSION% --no-clean 42 | - SET SETUP_EXE=pythonup-setup-%INSTALLER_ARCH%-%REPO_GIT_VERSION%.exe 43 | 44 | after_build: 45 | - appveyor PushArtifact .\installers\%SETUP_EXE% 46 | 47 | test_script: 48 | - flake8 . 49 | - pytest tests 50 | - invoke shims.test 51 | 52 | - docker build --build-arg SETUP_EXE=%SETUP_EXE% .\installers 53 | -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import invoke 4 | 5 | 6 | DOCSDIR = os.path.dirname(os.path.abspath(__file__)) 7 | 8 | 9 | @invoke.task() 10 | def build(ctx, builder='html'): 11 | with ctx.cd(DOCSDIR): 12 | ctx.run('sphinx-build -b {} source build'.format(builder)) 13 | 14 | 15 | @invoke.task() 16 | def clean(ctx): 17 | with ctx.cd(DOCSDIR): 18 | ctx.run('sphinx-build -M clean source build') 19 | 20 | 21 | @invoke.task() 22 | def watch(ctx, port='', open_browser=False): 23 | cmd = [ 24 | 'sphinx-autobuild', 25 | 'source', 26 | 'build', 27 | '--port={}'.format(port) * bool(port), 28 | '--watch={}'.format(os.path.dirname(DOCSDIR)), 29 | '--open-browser' * open_browser, 30 | ] 31 | with ctx.cd(DOCSDIR): 32 | ctx.run(' '.join(cmd)) 33 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | m2r 2 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # PythonUp (Windows) documentation build configuration file, created by 5 | # sphinx-quickstart on Sat Dec 23 19:05:41 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'm2r', 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | source_suffix = ['.rst', '.md'] 45 | 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = 'PythonUp (Windows)' 52 | copyright = '2017, Tzu-ping Chung' 53 | author = 'Tzu-ping Chung' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = '2.0' 61 | # The full version, including alpha/beta/rc tags. 62 | release = '2.0.0' 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This patterns also effect to html_static_path and html_extra_path 74 | exclude_patterns = [] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = 'sphinx' 78 | 79 | # If true, `todo` and `todoList` produce output, else they produce nothing. 80 | todo_include_todos = False 81 | 82 | 83 | # -- Options for HTML output ---------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = 'alabaster' 89 | 90 | # Theme options are theme-specific and customize the look and feel of a theme 91 | # further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | html_theme_options = { 95 | 'show_related': True, 96 | } 97 | 98 | # Add any paths that contain custom static files (such as style sheets) here, 99 | # relative to this directory. They are copied after the builtin static files, 100 | # so a file named "default.css" will overwrite the builtin "default.css". 101 | # html_static_path = ['_static'] 102 | 103 | # Custom sidebar templates, must be a dictionary that maps document names 104 | # to template names. 105 | # 106 | # This is required for the alabaster theme 107 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 108 | html_sidebars = { 109 | '**': [ 110 | 'relations.html', 111 | 'globaltoc.html', 112 | 'searchbox.html', 113 | ] 114 | } 115 | 116 | 117 | # -- Options for HTMLHelp output ------------------------------------------ 118 | 119 | # Output file base name for HTML help builder. 120 | htmlhelp_basename = 'pythonup-windows-doc' 121 | 122 | 123 | # -- Options for LaTeX output --------------------------------------------- 124 | 125 | latex_elements = { 126 | # The paper size ('letterpaper' or 'a4paper'). 127 | # 128 | # 'papersize': 'letterpaper', 129 | 130 | # The font size ('10pt', '11pt' or '12pt'). 131 | # 132 | # 'pointsize': '10pt', 133 | 134 | # Additional stuff for the LaTeX preamble. 135 | # 136 | # 'preamble': '', 137 | 138 | # Latex figure (float) alignment 139 | # 140 | # 'figure_align': 'htbp', 141 | } 142 | 143 | # Grouping the document tree into LaTeX files. List of tuples 144 | # (source start file, target name, title, 145 | # author, documentclass [howto, manual, or own class]). 146 | latex_documents = [ 147 | (master_doc, 'pythonup-windows.tex', 'PythonUp (Windows) Documentation', 148 | 'Tzu-ping Chung', 'manual'), 149 | ] 150 | 151 | 152 | # -- Options for manual page output --------------------------------------- 153 | 154 | # One entry per manual page. List of tuples 155 | # (source start file, name, description, authors, manual section). 156 | man_pages = [ 157 | (master_doc, 'pythonup-windows', 'PythonUp (Windows) Documentation', 158 | [author], 1) 159 | ] 160 | 161 | 162 | # -- Options for Texinfo output ------------------------------------------- 163 | 164 | # Grouping the document tree into Texinfo files. List of tuples 165 | # (source start file, target name, title, author, 166 | # dir menu entry, description, category) 167 | texinfo_documents = [ 168 | (master_doc, 'PythonUp (Windows)', 'PythonUp (Windows) Documentation', 169 | author, 'PythonUp (Windows)', 'The Python Runtime Manager for Windows.', 170 | 'Miscellaneous'), 171 | ] 172 | -------------------------------------------------------------------------------- /docs/source/develop.rst: -------------------------------------------------------------------------------- 1 | .. _develop: 2 | 3 | ================================== 4 | Contribute to PythonUp for Windows 5 | ================================== 6 | 7 | Development happens on `GitHub `__. 8 | 9 | 10 | Development Guide 11 | ================= 12 | 13 | Requirements 14 | ------------ 15 | 16 | * Windows 17 | * Python 3.6 18 | * Pipenv_ 19 | 20 | .. _Pipenv: https://pipenv.org 21 | 22 | Optional Dependencies 23 | --------------------- 24 | 25 | * Rust_ if you want to build the shims. The Rust development environment needs 26 | to be available in your shell. PythonUp targets the stable channel. 27 | * NSIS_ 3.x if you want to build the installer. ``makensis`` needs to be 28 | available in your shell. 29 | 30 | .. _Rust: https://www.rust-lang.org/install.html 31 | .. _NSIS: http://nsis.sourceforge.net/Download 32 | 33 | Project Setup 34 | ------------- 35 | 36 | Download and enter the project:: 37 | 38 | git clone https://github.com/uranusjr/pythonup-windows.git 39 | cd pythonup-windows 40 | 41 | Set up environment:: 42 | 43 | pipenv sync --dev 44 | 45 | Run Tests 46 | --------- 47 | 48 | Run Python tests:: 49 | 50 | pipenv run pytest tests 51 | 52 | Run Rust tests:: 53 | 54 | pipenv run invoke shims.test 55 | 56 | Unfortunately there are only very limited tests right now. 57 | 58 | Run In-Development PythonUp 59 | --------------------------- 60 | 61 | :: 62 | 63 | pipenv run python -m pythonup [COMMAND] ... 64 | 65 | This should have the same behaviour as an installed command, but within the 66 | confine of the Pipenv-managed virtual environment. 67 | 68 | .. warning:: 69 | 70 | PythonUp depends a lot on the Windows Registry, so certain commands still 71 | have global implications. For example, the ``uninstall`` command will 72 | uninstall Python from your system, and ``use`` will affect your global 73 | using state! 74 | 75 | 76 | Build Installer 77 | --------------- 78 | 79 | :: 80 | 81 | pipenv run invoke installers.build 82 | 83 | You can only build installers of your host’s architecture. Cross compilation 84 | is certainly possible, but I haven’t found the need to set it up. 85 | 86 | After the command finishes you should get an EXE in the ``installers`` 87 | directory. 88 | 89 | Build Documentation 90 | ------------------- 91 | 92 | :: 93 | 94 | pipenv run invoke docs.build 95 | 96 | Documentation is managed with Sphinx_, and hosted on `Read the Docs`_ with a 97 | custom domain. 98 | 99 | .. _Sphinx: http://sphinx-doc.org 100 | .. _`Read the Docs`: https://readthedocs.org 101 | 102 | Source Code Guideline 103 | --------------------- 104 | 105 | Try to follow the code style. For Python code, run the linter to check for 106 | issues before submitting:: 107 | 108 | pipenv run flake8 . 109 | 110 | Format of text files are managed with EditorConfig_. I recommend using one of 111 | the editor plugins to automatically format files. If you can’t/don’t want to 112 | do so, please at least make sure you’re using the correct format before sending 113 | in pull requests. 114 | 115 | .. _EditorConfig: http://editorconfig.org 116 | -------------------------------------------------------------------------------- /docs/source/explain.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Explain PythonUp 3 | ================ 4 | 5 | The idea of PythonUp formed gradually from problems I faced when working on 6 | multiple development machines, constantly switching between Windows and macOS. 7 | I long a more consistent development experience, and a good way to manage my 8 | Python environment on Windows. 9 | 10 | I tried out quite a few solutions. While a lot of them work for myself (to 11 | varying degree), none of them make me feel comfortable because *I can’t teach 12 | anybody about them*, which means they are all somehow wrong. Eventually I 13 | decided I need a complete managing system, which became PythonUp. 14 | 15 | Below are some questions I either faced when discussing the Python setup 16 | problem with others, or through introspection. Each question represents an 17 | alternative solution I tried, but eventually couldn’t feel satisfied with. I 18 | hope they will answer *why* I think PythonUp is the best solution, and you 19 | should adapt it as well. 20 | 21 | 22 | Why Not “Add Python to PATH”? 23 | ============================= 24 | 25 | The standard CPython installer offers this option. It is not checked by 26 | default, but a lot of online tutorials tell you to do so. The CPython core 27 | team is correct; it shouldn’t be checked under most cercumstances. 28 | 29 | CPython’s standard Windows build, unlike on Un\*x, does not provide the 30 | “altinstall” build option. Every CPython distribution on Windows has only one 31 | Python executable, called ``python.exe``, not versioned names such as 32 | ``python3.6.exe``. 33 | 34 | Adding Python to ``PATH`` stops being a good idea the moment you install a 35 | second Python. You can only access one Python at a time, and installed scripts 36 | from different versions start to mix, which is a bad thing. [#]_ It also 37 | become very tedious and delicate very quickly to manipulate the ``PATH`` 38 | environment variable. 39 | 40 | .. [#] This is not a Windows-only problem, but also exactly why tutorials these 41 | days don’t recommand installing Python via `python.org`_, but with 42 | platform-specific tools instead. Windows is the only mainstream 43 | operation system without a good Python verions management tool. 44 | 45 | .. _`python.org`: https://www.python.org 46 | 47 | Why Not the Python Launcher (``py.exe``)? 48 | ========================================= 49 | 50 | Python introduced `PEP 397`_ partly to solve the ``python.exe`` problem (also 51 | to interpret the shebang_ line on Windows). It installs a ``py.exe`` to your 52 | PATH, and instead of invoking ``python.exe`` directly, you should use, for 53 | example:: 54 | 55 | py -3.5 foo.py 56 | 57 | to run ``foo.py`` with Python 3.5. 58 | 59 | This is such a good idea *PythonUp installs the Py Launcher during setup*, and 60 | I encourage you to use it. But PythonUp also solves a few additional use cases 61 | that ``py.exe`` doesn’t: 62 | 63 | * Availability of versioned Python executables, e.g. ``python3.6.exe``. 64 | * Managing commands other than ``python.exe``. 65 | 66 | PythonUp’s implementation also relies on a lot of the same registry values read 67 | by ``py.exe``, which are formally defined in `PEP 514`_, so you can view 68 | PythonUp as a supplement to ``py.exe``, not a replacement. 69 | 70 | .. _`PEP 397`: https://www.python.org/dev/peps/pep-0397/ 71 | .. _`PEP 514`: https://www.python.org/dev/peps/pep-0514/ 72 | .. _shebang: https://en.wikipedia.org/wiki/Shebang_(Unix) 73 | 74 | Why Not Install to All Users? 75 | ============================= 76 | 77 | Authentication on Windows is difficult to manage, especially in the console. 78 | There really is no way to elevate priviledge inside a command prompt, and it 79 | is against common workflow to require opening a new console when you need to 80 | ``pip install`` something. 81 | 82 | It is useful to have a system-wide Python setup. For developers, however, it 83 | is always recommended to install a seperate copy of Python for yourself, so 84 | you can manage it directly, without special priviledge. 85 | 86 | Why Not Chocolatey? 87 | =================== 88 | 89 | Chocolatey_ is a package manager on Windows. A lot of PythonUp’s ideas are 90 | inspired by it: standard Windows installers, interaction-free installation, 91 | and shims for execution in command prompts. It is a very good tool, and I use 92 | it on my Windows machine—alongside PythonUp. 93 | 94 | .. _Chocolatey: https://chocolatey.org 95 | 96 | What PythonUp is to Chocolatey is similar to pyenv to Homebrew on macOS. The 97 | aims are similar, but slightly different, so we can take an approach tailored 98 | to Python distribution. 99 | 100 | Also I’m not very satisfied with Chocolatey’s user story. The setup is slightly 101 | complicated (due to Powershell’s execution policy), and requires administration 102 | priviledge to install packages. This is because it is fulfilling a different 103 | goal from PythonUp’s, but still makes me feel uncomforatble enough not to teach 104 | it to others. 105 | 106 | 107 | Why Not Anaconda? 108 | ================= 109 | 110 | I considered Anaconda very hardly, but eventually decided against it. The 111 | tipping point is how Anaconda manages Python versions similar to virtualenvs, 112 | with manipulation of ``PATH`` and other environment variables. This is the 113 | wrong way to do it, [#]_ and to this day there is still no first-party 114 | Powershell support. [#]_ 115 | 116 | .. [#] |virtualenv-wrong|_ 117 | .. [#] |conda-powershell-issue|_ 118 | 119 | .. |virtualenv-wrong| replace:: Virtualenv’s ``bin/activate`` is doing it wrong 120 | .. _virtualenv-wrong: https://gist.github.com/datagrok/2199506 121 | 122 | .. |conda-powershell-issue| replace:: Powershell activate and deactivate 123 | .. _conda-powershell-issue: ``. This is the standard 22 | “only-for-me” installation location for Python 3.5+, and we retrofit this rule 23 | to older versions as well for consistency. 24 | 25 | 26 | How are executables linked? 27 | --------------------------- 28 | 29 | They are not. Scripts are *copied*. ``.py`` files works as well because they 30 | have appropriate shebang lines, and can be handled by the Python Launcher, as 31 | specified in `PEP 397`_. 32 | 33 | .. _`PEP 397`: https://www.python.org/dev/peps/pep-0397/ 34 | 35 | A few wrapper executables (shims_) are distributed with PythonUp, and are 36 | published into ``PATH`` to stub a few special executables, such as 37 | ``python.exe`` and ``pip.exe``. When invoked, these shims rely on the registry 38 | to launch their real conterparts, and bridge all user interaction to them. 39 | 40 | The shims minimise the need to expose internal DLLs, and, in the case of 41 | ``pip.exe`` etc., provide a chance to hook into extra machinery when you alter 42 | Python installations. This is inspired by pyenv_ and Chocolatey_, and provides 43 | a more seamless experience. 44 | 45 | .. _shims: https://en.wikipedia.org/wiki/Shim_(computing) 46 | .. _pyenv: https://github.com/pyenv/pyenv 47 | .. _Chocolatey: https://chocolatey.org 48 | 49 | 50 | .. Why is this called SNAFU? 51 | ------------------------- 52 | 53 | .. Because Python is hard, Windows is harder, and setting up Windows for 54 | Python development is SNAFU. Or it’s Supernatrual Administration for You. 55 | Mosky says it sounds kind of like snake, so there’s that. 56 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | 3 | Topics 4 | ====== 5 | 6 | .. toctree:: 7 | :maxdepth: 1 8 | 9 | install 10 | manage 11 | misc 12 | history 13 | develop 14 | explain 15 | implement 16 | -------------------------------------------------------------------------------- /docs/source/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | ============== 4 | Install Python 5 | ============== 6 | 7 | PythonUp installs a Python version with a single command. To install 8 | ```` on you machine: [#]_ 9 | 10 | :: 11 | 12 | pythonup install 13 | 14 | .. [#] Use ``pythonup list --all`` to find out what versions are available. See 15 | :ref:`list` for more information about the ``list`` command. 16 | 17 | This automatically downloads the installer, and runs it with minimal user input 18 | possible. [#]_ If there’s need to install without Internet connection, you can 19 | download the installer yourself, and run:: 20 | 21 | pythonup install --file=path\to\installer.exe 22 | 23 | to install directly from that installer. Either way, PythonUp sets up the 24 | Python installation on its own, and let you start using it right away. 25 | 26 | .. [#] Generally only to confirm the UAC dialog, if needed. 27 | 28 | PythonUp provides some extra executables for you. Say you have install Python 29 | 3.6 after you set up PythonUp. Now you can launch Python with:: 30 | 31 | python3 32 | 33 | Install a package, say, Pipenv_, with:: 34 | 35 | pip3 install pipenv 36 | 37 | .. _Pipenv: https://docs.pipenv.org 38 | 39 | and have the ``pipenv`` command available immediately after. 40 | 41 | Upgrade Python 42 | ============== 43 | 44 | PythonUp allows you to upgrade a Python installation to a newer micro version, 45 | e.g. 3.6.3 to 3.6.4. Run the following command to upgrade an installed version 46 | (if available):: 47 | 48 | pythonup upgrade 49 | 50 | It takes some time for the developers to update the definition to a newer 51 | version. If you find a newer version released on `python.org`_ that is not 52 | available in PythonUp, :ref:`send a pull request ` to update the 53 | definition files! 54 | 55 | .. _`python.org`: https://python.org 56 | 57 | Uninstall Python 58 | ================ 59 | 60 | You probably guessed it:: 61 | 62 | pythonup uninstall 63 | 64 | Similar to installing, this does nothing too fancy, but just runs the 65 | standard uninstaller. It does perform some additional cleanup if you are using 66 | the version. See :ref:`manage` about how you can manage/use versions. 67 | -------------------------------------------------------------------------------- /docs/source/manage.rst: -------------------------------------------------------------------------------- 1 | .. _manage: 2 | 3 | ============== 4 | Manage Pythons 5 | ============== 6 | 7 | The real benefits of PythonUp shows when you install more than one Python 8 | versions. Instead of manipulating the ``PATH`` environment variable, simply 9 | run the command with the appropriate version name. 10 | 11 | .. code-block:: powershell 12 | 13 | # Python 3.6 (64-bit on available hosts, 32-bit otherwise). 14 | pythonup install 3.6 15 | python3.6 16 | pip3.6 17 | 18 | # Python 3.5 (force 32-bit). 19 | pythonup install 3.5-32 20 | python3.5-32 21 | pip3.5-32 22 | 23 | # Python 2.7. 24 | pythonup install 2.7 25 | python2.7 26 | pip2.7 27 | 28 | No more ``python.exe`` shadowing because you have multiple versions in 29 | ``PATH``. 30 | 31 | Use Versions 32 | ============ 33 | 34 | When you have multiple Python versions installed, PythonUp only exposes the 35 | above two executables by default. If you want to expose other commands of a 36 | given Python version, You can tell PythonUp to *use* it: [#]_ 37 | 38 | :: 39 | 40 | pythonup use 3.6 41 | 42 | .. [#] PythonUp does this automatically when you install your first ever Python 43 | version. This is why we had access to ``python3`` without using 3.6 in 44 | :ref:`install`. 45 | 46 | So PythonUp publishes (almost) all commands available in Python 3.6, including 47 | 48 | * ``python3.exe`` 49 | * ``pip3.exe`` 50 | * ``easy_install-3.6.exe`` 51 | * All other scripts you installed via ``pip install``. 52 | 53 | As an exception, PythonUp blacklists ``python.exe``, ``pip.exe``, and 54 | ``easy_install.exe`` from being published, to encourage the best practice of 55 | always using versioned Python and Pip commands. 56 | 57 | You can use multiple versions together to expose scripts installed across 58 | them:: 59 | 60 | pythonup use 3.6 2.7 61 | 62 | This exposes commands from both Python 3.6 and 2.7, with 3.6 taking precedence, 63 | i.e. if a given command exists in both versions, the 3.6 one will be called. 64 | 65 | Manage Used Versions 66 | ==================== 67 | 68 | To see what versions are currently in use:: 69 | 70 | pythonup use 71 | 72 | To reset using state (i.e. unuse all versions):: 73 | 74 | pythonup use --reset 75 | -------------------------------------------------------------------------------- /docs/source/misc.rst: -------------------------------------------------------------------------------- 1 | .. _misc: 2 | 3 | ============== 4 | Misc. Commands 5 | ============== 6 | 7 | Upgrade PythonUp Itself 8 | ======================= 9 | 10 | A special ``upgrade`` syntax to upgrade not a Python version, but PythonUp 11 | itself:: 12 | 13 | pythonup upgrade self 14 | 15 | This simply downloads the official installer and runs it. 16 | 17 | 18 | .. _list: 19 | 20 | List Pythons 21 | ============ 22 | 23 | This lists Python versions installed in your system:: 24 | 25 | pythonup list 26 | 27 | To list *all* Python versions available, including uninstalled ones, use:: 28 | 29 | pythonup list --all 30 | 31 | Either way, the output would be something like this:: 32 | 33 | o 2.7 34 | o 3.4 35 | 3.5 36 | * 3.6 37 | 38 | * The ``o`` prefix means the version is installed. 39 | * ``*`` signifies an active version. 40 | * No prefix if the version is not installed. 41 | 42 | 43 | Download Python 44 | =============== 45 | 46 | :: 47 | 48 | pythonup download 49 | 50 | downloads the installer without exicuting it. The installer is saved to the 51 | current working directory by default, but you can also specify another 52 | directory with the ``--dest`` option. 53 | 54 | 55 | Find Python Installation 56 | ======================== 57 | 58 | :: 59 | 60 | pythonup where 61 | 62 | prints where the installed ``python.exe`` really is, usually something 63 | like:: 64 | 65 | C:\Users\username\AppData\Local\Programs\Python\PythonXY\python.exe 66 | 67 | This is useful when you need to pass the real executable somewhere else, e.g. 68 | set the path to an environment variable in a Powershell script. 69 | 70 | 71 | Link Individual Scripts 72 | ======================= 73 | 74 | :: 75 | 76 | pythonup link 77 | 78 | links the specified command to your ``PATH``. Nice to have when you accidetally 79 | break the system. There are ``--overwrite=yes`` and ``--all`` you can use for 80 | even better profit. 81 | -------------------------------------------------------------------------------- /installers/Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile used to test the installer in a clean environment. 2 | # TODO: Write actual tests to run inside the container. 3 | 4 | FROM mcr.microsoft.com/windows/servercore:ltsc2016 5 | 6 | ARG SETUP_EXE 7 | 8 | COPY $SETUP_EXE pythonup-setup.exe 9 | RUN pythonup-setup.exe /S 10 | 11 | RUN pythonup install 3.6 12 | RUN python3.6 --version 13 | 14 | RUN pip3.6 install python-dotenv 15 | RUN where dotenv 16 | 17 | RUN pythonup list 18 | RUN pythonup list --all 19 | 20 | RUN python3 --version 21 | 22 | RUN pip3 install pytest 23 | RUN where pytest 24 | 25 | RUN pythonup use 3.6 26 | RUN pythonup use --reset 27 | RUN pythonup list --all 28 | 29 | RUN pythonup uninstall 3.6 30 | -------------------------------------------------------------------------------- /installers/__init__.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import itertools 3 | import json 4 | import pathlib 5 | import shutil 6 | import struct 7 | import subprocess 8 | import zipfile 9 | 10 | import invoke 11 | import packaging.version 12 | import pkg_resources 13 | import requests 14 | 15 | import shims 16 | 17 | 18 | VERSION = '3.6.8' 19 | 20 | DOWNLOAD_PREFIX = 'https://www.python.org/ftp/python' 21 | 22 | KB_CODE = 'KB2999226' 23 | 24 | WINVERS = [ 25 | '6.0', # Vista. 26 | '6.1', # 7. 27 | '8-RT', # 8. 28 | '8.1', # 8.1. 29 | # Windows 10 does not need patching. 30 | ] 31 | 32 | 33 | def get_python_embed_url(architecture): 34 | return '{pref}/{vers}/python-{vers}-embed-{arch}.zip'.format( 35 | pref=DOWNLOAD_PREFIX, 36 | vers=VERSION, 37 | arch=architecture, 38 | ) 39 | 40 | 41 | def get_py_launcher_url(architecture): 42 | # I dug this URL out of Python's webinstaller build. 43 | # See this part in the build script for description. 44 | # https://github.com/python/cpython/blob/v3.6.3/Tools/msi/buildrelease.bat 45 | return '{pref}/{vers}/{arch}/launcher.msi'.format( 46 | pref=DOWNLOAD_PREFIX, 47 | vers=VERSION, 48 | arch=architecture, 49 | ) 50 | 51 | 52 | def get_kb_msu_url(architecture, wver, warc): 53 | return '{pref}/{vers}/{arch}/Windows{wver}-{code}-{warc}.msu'.format( 54 | pref=DOWNLOAD_PREFIX, 55 | vers=VERSION, 56 | arch=architecture, 57 | code=KB_CODE, 58 | wver=wver, 59 | warc=warc, 60 | ) 61 | 62 | 63 | def get_version(): 64 | with ROOT.parent.joinpath('pythonup', '__init__.py').open() as f: 65 | for line in f: 66 | if line.startswith('__version__'): 67 | vs = line[len('__version__ = '):] 68 | return packaging.version.parse(ast.literal_eval(vs)) 69 | 70 | 71 | def get_latest_python_name(): 72 | 73 | def load_definition(p): 74 | with p.open() as f: 75 | data = json.load(f) 76 | data['name'] = p.stem 77 | return data 78 | 79 | definitions = [ 80 | load_definition(p) 81 | for p in ROOT.parent.joinpath('pythonup', 'versions').iterdir() 82 | if p.suffix == '.json' 83 | ] 84 | 85 | # 1. Prefer newer versions. 86 | # 2. Prefer shorter names because they look "more default". 87 | latest_definition = max( 88 | definitions, 89 | key=lambda d: (d['version_info'], -len(d['name'])), 90 | ) 91 | return latest_definition['name'] 92 | 93 | 94 | ROOT = pathlib.Path(__file__).resolve(strict=True).parent 95 | 96 | ASSETSDIR = ROOT.joinpath('assets') 97 | ASSETSDIR.mkdir(exist_ok=True) 98 | 99 | SHIMSDIR = ROOT.parent.joinpath('shims') 100 | 101 | 102 | def download_file(url, path): 103 | print('Downloading {}'.format(url)) 104 | response = requests.get(url, stream=True) 105 | response.raise_for_status() 106 | path.write_bytes(response.content) 107 | 108 | 109 | def get_py_launcher(arch): 110 | installer_path = ASSETSDIR.joinpath('py-{vers}-{arch}.msi'.format( 111 | vers=VERSION, 112 | arch=arch, 113 | )) 114 | if not installer_path.exists(): 115 | download_file(get_py_launcher_url(arch), installer_path) 116 | return installer_path 117 | 118 | 119 | def get_embed_bundle(arch): 120 | url = get_python_embed_url(arch) 121 | bundle_path = ASSETSDIR.joinpath(url.rsplit('/', 1)[-1]) 122 | if not bundle_path.exists(): 123 | download_file(url, bundle_path) 124 | return bundle_path 125 | 126 | 127 | def get_kb_msu(arch, winver, winarc): 128 | url = get_kb_msu_url(arch, winver, winarc) 129 | msu_path = ASSETSDIR.joinpath(url.rsplit('/', 1)[-1]) 130 | if not msu_path.exists(): 131 | download_file(url, msu_path) 132 | return msu_path 133 | 134 | 135 | def get_dependency_names(): 136 | lock_path = ROOT.parent.joinpath('Pipfile.lock') 137 | with lock_path.open() as f: 138 | data = json.load(f) 139 | return data['default'].keys() 140 | 141 | 142 | class PackageResolutionError(ValueError): 143 | pass 144 | 145 | 146 | def build_package_path(location, name): 147 | path = pathlib.Path(location, name) 148 | if path.is_dir(): 149 | return path 150 | path = pathlib.Path(location, '{}.py'.format(name)) 151 | if path.is_file(): 152 | return path 153 | raise PackageResolutionError(name) 154 | 155 | 156 | def get_package_paths(): 157 | # TODO: This only works for pure Python packages. 158 | # This will fail if we need binary dependencies (e.g. pypiwin32) in the 159 | # future because the host will only have either 32- or 64-bit binary, but 160 | # we need both to build installers for each architecture. We should instead 161 | # download wheels from PyPI, and extract to get the packages. 162 | paths = [] 163 | for name in get_dependency_names(): 164 | dist = pkg_resources.get_distribution(name) 165 | top_level = pathlib.Path(dist.egg_info).joinpath('top_level.txt') 166 | paths.extend( 167 | build_package_path(dist.location, n) 168 | for n in top_level.read_text().split('\n') if n 169 | ) 170 | return paths 171 | 172 | 173 | def build_lib_python(libdir, arch): 174 | pythondir = libdir.joinpath('python') 175 | pythondir.mkdir() 176 | 177 | # Extract Python distribution. 178 | print('Populating Embeddable Python.') 179 | with zipfile.ZipFile(str(get_embed_bundle(arch))) as f: 180 | f.extractall(str(pythondir)) 181 | 182 | # Copy PythonUp. 183 | print('Populate PythonUp.') 184 | shutil.copytree( 185 | str(ROOT.parent.joinpath('pythonup')), 186 | str(pythondir.joinpath('pythonup')), 187 | ) 188 | 189 | # Write runtime configurations. 190 | with pythondir.joinpath('pythonup', 'installation.json').open('w') as f: 191 | json.dump({ 192 | 'base_dir': '..\\..\\..', 193 | 'cmd_dir': '..\\..\\..\\cmd', 194 | 'scripts_dir': '..\\..\\..\\scripts', 195 | 'shims_dir': '..\\..\\shims', 196 | }, f) 197 | 198 | # Copy dependencies. 199 | print('Populate dependencies...') 200 | for path in get_package_paths(): 201 | print(' {}'.format(path.stem)) 202 | if path.is_dir(): 203 | shutil.copytree(str(path), str(pythondir.joinpath(path.name))) 204 | else: 205 | shutil.copy2(str(path), str(pythondir.joinpath(path.name))) 206 | 207 | # Cleanup. 208 | print('Remove junks...') 209 | for p in pythondir.rglob('__pycache__'): 210 | shutil.rmtree(str(p)) 211 | for p in pythondir.rglob('*.py[co]'): 212 | shutil.rmtree(str(p)) 213 | 214 | 215 | SCRIPT_EXTS = ('.py', '.vbs') 216 | 217 | 218 | def build_lib_setup(libdir, arch): 219 | setupdir = libdir.joinpath('setup') 220 | setupdir.mkdir() 221 | 222 | winarcs = { 223 | 'amd64': ['x64'], 224 | 'win32': ['x86', 'x64'], 225 | }[arch] 226 | 227 | # Copy necessary updates. 228 | for winver, winarc in itertools.product(WINVERS, winarcs): 229 | msu_path = get_kb_msu(arch, winver, winarc) 230 | print('Copy {}'.format(msu_path.name)) 231 | shutil.copy2( 232 | str(msu_path), 233 | setupdir.joinpath(msu_path.name), 234 | ) 235 | 236 | # Copy Py launcher MSI. 237 | print('Copy py.msi') 238 | msi = get_py_launcher(arch) 239 | shutil.copy2(str(msi), str(setupdir.joinpath('py.msi'))) 240 | 241 | # Copy setup scripts. 242 | print('Copy setup scripts...') 243 | for path in ROOT.joinpath('lib', 'setup').iterdir(): 244 | if path.suffix not in SCRIPT_EXTS: 245 | continue 246 | name = path.name 247 | print(' {}'.format(name)) 248 | shutil.copy2(str(path), str(setupdir.joinpath(name))) 249 | 250 | 251 | def build_lib_shims(libdir): 252 | shimsdir = libdir.joinpath('shims') 253 | shimsdir.mkdir() 254 | print('Copy shims...') 255 | for path in SHIMSDIR.joinpath('target', 'release').iterdir(): 256 | if path.suffix != '.exe': 257 | continue 258 | name = path.name 259 | print(' {}'.format(name)) 260 | shutil.copy2(str(path), str(shimsdir.joinpath(name))) 261 | 262 | 263 | def build_lib(container, arch): 264 | libdir = container.joinpath('lib') 265 | libdir.mkdir() 266 | build_lib_python(libdir, arch) 267 | build_lib_setup(libdir, arch) 268 | build_lib_shims(libdir) 269 | 270 | 271 | def build_files(arch): 272 | container = ROOT.joinpath('pythonup') 273 | if container.exists(): 274 | shutil.rmtree(str(container)) 275 | container.mkdir() 276 | build_lib(container, arch) 277 | 278 | 279 | def build_installer(outpath): 280 | if outpath.exists(): 281 | outpath.unlink() 282 | print('Building installer.') 283 | subprocess.check_call([ 284 | 'makensis', 285 | '/DPYTHONVERSION={}'.format(get_latest_python_name()), 286 | str(ROOT.joinpath('setup.nsi')), 287 | ], shell=True) 288 | print('pythonup-setup.exe -> {}'.format(outpath)) 289 | shutil.move(str(ROOT.joinpath('pythonup-setup.exe')), str(outpath)) 290 | 291 | 292 | def cleanup(): 293 | container = ROOT.joinpath('pythonup') 294 | if container.exists(): 295 | shutil.rmtree(str(container)) 296 | 297 | 298 | def check_version(v): 299 | version = packaging.version.parse(v) 300 | # Not really a version...Likely a Git hash. 301 | if not isinstance(version, packaging.version.Version): 302 | return 303 | mod_v = get_version() 304 | if mod_v != version: 305 | raise AssertionError( 306 | f'module version does not match installer: {mod_v} != {version}' 307 | ) 308 | 309 | 310 | @invoke.task(pre=[invoke.call(shims.build, release=True)]) 311 | def build(ctx, version=None, clean=True): 312 | arch = { 313 | 8: 'amd64', 314 | 4: 'win32', 315 | }[struct.calcsize('P')] 316 | 317 | if version is None: 318 | version = 'dev' 319 | else: 320 | check_version(version) 321 | 322 | out = 'pythonup-setup-{}-{}.exe'.format(arch, version.strip()) 323 | outpath = pathlib.Path(out) 324 | if not outpath.is_absolute(): 325 | outpath = ROOT.joinpath(outpath) 326 | 327 | build_files(arch) 328 | build_installer(outpath) 329 | if clean: 330 | cleanup() 331 | 332 | 333 | @invoke.task(pre=[invoke.call(shims.clean)]) 334 | def clean(ctx): 335 | cleanup() 336 | -------------------------------------------------------------------------------- /installers/lib/setup/activation.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import winreg 3 | 4 | import click 5 | 6 | from pythonup import metadata, versions 7 | from pythonup.operations.common import get_active_names 8 | from pythonup.operations.link import activate 9 | 10 | 11 | def get_version_or_none(name): 12 | force_32 = not metadata.can_install_64bit() 13 | with contextlib.suppress(versions.VersionNotFoundError): 14 | return versions.get_version(name, force_32=force_32) 15 | return None 16 | 17 | 18 | ACTIVE_PYTHONS_KEY = 'Software\\uranusjr\\PythonUp\\ActivePythonVersions' 19 | 20 | 21 | def _compat_get_active_python_versions(): 22 | """Read old config stored in registry. 23 | 24 | We honour this for users upgrading from old versions, and convert the 25 | value to the new format. 26 | """ 27 | with winreg.OpenKey(winreg.HKEY_CURRENT_USER, ACTIVE_PYTHONS_KEY) as key: 28 | value, _ = winreg.QueryValueEx(key, '') 29 | return value.split(';') if value else [] 30 | 31 | 32 | @click.command() 33 | def main(): 34 | names = get_active_names() 35 | if not names: 36 | with contextlib.suppress(FileNotFoundError): 37 | names = _compat_get_active_python_versions() 38 | versions = [ 39 | v for v in (get_version_or_none(name) for name in names) 40 | if v is not None 41 | ] 42 | with contextlib.suppress(FileNotFoundError, OSError): 43 | winreg.DeleteKey(winreg.HKEY_CURRENT_USER, ACTIVE_PYTHONS_KEY) 44 | activate(versions, allow_empty=True) 45 | 46 | 47 | if __name__ == '__main__': 48 | main() 49 | -------------------------------------------------------------------------------- /installers/lib/setup/discovery.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | import click 4 | 5 | from pythonup.operations.link import link_commands 6 | from pythonup.operations.versions import get_versions 7 | 8 | 9 | @click.command() 10 | def main(): 11 | for version in get_versions(installed_only=True): 12 | with contextlib.suppress(FileNotFoundError): 13 | link_commands(version) 14 | 15 | 16 | if __name__ == '__main__': 17 | main() 18 | -------------------------------------------------------------------------------- /installers/lib/setup/env.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import contextlib 3 | import ctypes 4 | import ctypes.wintypes 5 | import itertools 6 | import pathlib 7 | import winreg 8 | 9 | import click 10 | 11 | 12 | @contextlib.contextmanager 13 | def open_environment_key(access=winreg.KEY_READ): 14 | key = winreg.OpenKey( 15 | winreg.HKEY_CURRENT_USER, 16 | 'Environment', 17 | access=access, 18 | ) 19 | yield key 20 | winreg.CloseKey(key) 21 | 22 | 23 | def get_path_values(): 24 | with open_environment_key() as key: 25 | try: 26 | value, vtype = winreg.QueryValueEx(key, 'PATH') 27 | except FileNotFoundError: 28 | return [], winreg.REG_SZ 29 | if not value: 30 | return [], winreg.REG_SZ 31 | return [v for v in value.split(';') if v], vtype 32 | 33 | 34 | def set_path_values(values, vtype): 35 | joined_value = ';'.join(values) 36 | with open_environment_key(winreg.KEY_SET_VALUE) as key: 37 | winreg.SetValueEx(key, 'PATH', 0, vtype, joined_value) 38 | print('SET PATH={}'.format(joined_value)) 39 | 40 | 41 | def add_paths(instdir): 42 | old_paths, vtype = get_path_values() 43 | paths = get_paths_to_add(instdir) 44 | 45 | path_index_m = collections.OrderedDict(zip(old_paths, itertools.count())) 46 | 47 | # If the order in PATH does not match what we want, delete existing 48 | # values and add again. 49 | current_indexes = [path_index_m.get(p, -1) for p in paths] 50 | if current_indexes != sorted(current_indexes): 51 | for p in paths: 52 | del path_index_m[p] 53 | 54 | for value in paths: 55 | path_index_m[value] = None # Value irrelevant, we want only the key. 56 | 57 | new_paths = list(path_index_m.keys()) 58 | if old_paths != new_paths: 59 | set_path_values(new_paths, vtype) 60 | return True 61 | return False 62 | 63 | 64 | def get_paths_to_add(instdir): 65 | return [ 66 | str(instdir.joinpath('scripts')), 67 | str(instdir.joinpath('cmd')), 68 | ] 69 | 70 | 71 | def install(instdir): 72 | return any([ 73 | add_paths(instdir), 74 | ]) 75 | 76 | 77 | def uninstall(instdir): 78 | current_values, vtype = get_path_values() 79 | values = [ 80 | v for v in current_values 81 | if v not in get_paths_to_add(instdir) 82 | ] 83 | if current_values != values: 84 | set_path_values(values, vtype) 85 | return True 86 | return False 87 | 88 | 89 | SendMessage = ctypes.windll.user32.SendMessageW 90 | SendMessage.argtypes = ( 91 | ctypes.wintypes.HWND, ctypes.wintypes.UINT, 92 | ctypes.wintypes.WPARAM, ctypes.wintypes.LPVOID, 93 | ) 94 | SendMessage.restype = ctypes.wintypes.LPARAM # Synonymous to LRESULT. 95 | HWND_BROADCAST = 0xFFFF 96 | WM_SETTINGCHANGE = 0x1A 97 | 98 | 99 | def publish(): 100 | SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, 'Environment') 101 | 102 | 103 | @click.command() 104 | @click.argument('base', type=click.Path(exists=True, file_okay=False)) 105 | @click.option('--uninstall', 'uninstalling', is_flag=True) 106 | def cli(base, uninstalling): 107 | path = pathlib.Path(base) 108 | if uninstalling: 109 | changed = uninstall(path) 110 | else: 111 | changed = install(path) 112 | if changed: 113 | publish() 114 | 115 | 116 | if __name__ == '__main__': 117 | cli() 118 | -------------------------------------------------------------------------------- /installers/lib/setup/shim.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import click 4 | 5 | 6 | @click.command() 7 | @click.argument('base', type=click.Path(exists=True, file_okay=False)) 8 | def main(base): 9 | instdir = pathlib.Path(base) 10 | shim = instdir.joinpath('cmd', 'pythonup.exe') 11 | print('Writing {}'.format(shim)) 12 | data = instdir.joinpath('lib', 'shims', 'shim.exe').read_bytes() 13 | cmd = '\0'.join([ 14 | str(instdir.joinpath('lib', 'python', 'python.exe')), '-m', 'pythonup', 15 | ]) 16 | data += bytes(reversed((cmd + '\n\n').encode('utf-8'))) 17 | with shim.open('wb') as f: 18 | f.write(data) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /installers/setup.nsi: -------------------------------------------------------------------------------- 1 | !include "MUI2.nsh" 2 | !include "LogicLib.nsh" 3 | !include "WinVer.nsh" 4 | !include "x64.nsh" 5 | 6 | !define MUI_PAGE_CUSTOMFUNCTION_SHOW Welcome 7 | !define MUI_PAGE_CUSTOMFUNCTION_LEAVE WelcomeLeave 8 | !insertmacro MUI_PAGE_WELCOME 9 | 10 | !insertmacro MUI_PAGE_INSTFILES 11 | !insertmacro MUI_UNPAGE_WELCOME 12 | !insertmacro MUI_UNPAGE_INSTFILES 13 | !insertmacro MUI_LANGUAGE "English" 14 | 15 | 16 | !define NAME 'PythonUp: The Python Runtime Manager' 17 | 18 | !define UNINSTALL_REGKEY \ 19 | 'Software\Microsoft\Windows\CurrentVersion\Uninstall\PythonUp' 20 | 21 | !define UNINSTALL_EXE "$INSTDIR\Uninstall.exe" 22 | 23 | !define KBCODE 'KB2999226' 24 | 25 | !define VCRUNTIME 'vcruntime140.dll' 26 | 27 | !ifndef PYTHONVERSION 28 | !error "PYTHONVERSION definition required." 29 | !endif 30 | 31 | 32 | ShowInstDetails hide 33 | ManifestSupportedOS all 34 | 35 | Name "${NAME}" 36 | OutFile "pythonup-setup.exe" 37 | InstallDir "$LOCALAPPDATA\Programs\PythonUp" 38 | 39 | Var InstallsPythonCheckbox 40 | Var InstallsPython 41 | 42 | Function Welcome 43 | ${IfNot} ${AtLeastWinVista} 44 | MessageBox MB_OK "PythonUp only supports Windows Vista or above." 45 | Quit 46 | ${EndIf} 47 | 48 | ${NSD_CreateCheckbox} 120u -18u 50% 12u \ 49 | "Set up Python ${PYTHONVERSION} (needs Internet connection)" 50 | Pop $InstallsPythonCheckbox 51 | SetCtlColors $InstallsPythonCheckbox "" ${MUI_BGCOLOR} 52 | ${NSD_SetState} $InstallsPythonCheckbox $InstallsPython 53 | FunctionEnd 54 | 55 | Function WelcomeLeave 56 | ${NSD_GetState} $InstallsPythonCheckbox $InstallsPython 57 | FunctionEnd 58 | 59 | Function InstallCRTUpdate 60 | ${If} ${AtLeastWin10} 61 | Return 62 | ${ElseIf} ${IsWin8.1} 63 | StrCpy $R0 '8.1' 64 | ${ElseIf} ${IsWin8} 65 | StrCpy $R0 '8-RT' 66 | ${ElseIf} ${IsWin7} 67 | StrCpy $R0 '6.1' 68 | ${ElseIf} ${IsWinVista} 69 | StrCpy $R0 '6.0' 70 | ${EndIf} 71 | 72 | ${If} ${RunningX64} 73 | StrCpy $R1 'x64' 74 | ${Else} 75 | StrCpy $R1 'x86' 76 | ${EndIf} 77 | 78 | DetailPrint "Installing Windows update ${KBCODE}..." 79 | nsExec::ExecToLog "wusa /quiet /norestart \ 80 | $\"$INSTDIR\lib\setup\Windows$R0-${KBCODE}-$R1.msu$\"" 81 | FunctionEnd 82 | 83 | Section "${NAME}" 84 | # Clean up previous installation to prevent script name conflicts (e.g. 85 | # old package is used instead of new module), and clean up old files. 86 | Rmdir /r "$INSTDIR" 87 | 88 | CreateDirectory "$INSTDIR" 89 | SetOutPath "$INSTDIR" 90 | 91 | File /r 'pythonup\*' 92 | CreateDirectory "$INSTDIR\cmd" 93 | CreateDirectory "$INSTDIR\scripts" 94 | 95 | # Ensure appropriate Windows Update for CRT is installed. 96 | Call InstallCRTUpdate 97 | 98 | # Install Py launcher. 99 | DetailPrint "Installing Python Launcher (py.exe)..." 100 | nsExec::ExecToLog "msiexec /i $\"$INSTDIR\lib\setup\py.msi$\" /quiet" 101 | 102 | # Create pythonup.exe shim. 103 | DetailPrint "Creating entry script..." 104 | nsExec::ExecToLog "$\"$INSTDIR\lib\python\python.exe$\" \ 105 | $\"$INSTDIR\lib\setup\shim.py$\" $\"$INSTDIR$\"" 106 | 107 | # Setup environment. 108 | DetailPrint "Configuring environment..." 109 | nsExec::ExecToLog "$\"$INSTDIR\lib\python\python.exe$\" \ 110 | $\"$INSTDIR\lib\setup\env.py$\" $\"$INSTDIR$\"" 111 | 112 | # Link installed Python versions to \cmd. 113 | DetailPrint "Discovering existing Pythons..." 114 | nsExec::ExecToLog "$\"$INSTDIR\lib\python\python.exe$\" \ 115 | $\"$INSTDIR\lib\setup\discovery.py$\"" 116 | 117 | # Re-activate versions. 118 | DetailPrint "Restoring active versions..." 119 | nsExec::ExecToLog "$\"$INSTDIR\lib\python\python.exe$\" \ 120 | $\"$INSTDIR\lib\setup\activation.py$\"" 121 | 122 | # Use installation to install Python (if told to). 123 | ${If} $InstallsPython == ${BST_CHECKED} 124 | DetailPrint "Installing Python ${PYTHONVERSION}..." 125 | nsExec::ExecToLog "$\"$INSTDIR\lib\python\python.exe$\" \ 126 | -m pythonup install ${PYTHONVERSION}" 127 | ${EndIf} 128 | 129 | # Copy DLL required by Rust executables. 130 | # We just use whatever Python provides, for convinience's sake. 131 | CopyFiles "$INSTDIR\lib\python\${VCRUNTIME}" "$INSTDIR\cmd" 132 | 133 | # Write install information. 134 | WriteRegStr HKCU "Software\uranusjr\PythonUp\InstallPath" "" "$INSTDIR" 135 | 136 | # Write uninstaller and register it to Windows. 137 | WriteUninstaller "${UNINSTALL_EXE}" 138 | WriteRegStr HKLM "${UNINSTALL_REGKEY}" "DisplayName" "${NAME}" 139 | WriteRegStr HKLM "${UNINSTALL_REGKEY}" "Publisher" "Tzu-ping Chung" 140 | WriteRegStr HKLM "${UNINSTALL_REGKEY}" "UninstallString" "${UNINSTALL_EXE}" 141 | SectionEnd 142 | 143 | 144 | Section "un.Uninstaller" 145 | nsExec::ExecToLog "$\"$INSTDIR\lib\python\python.exe$\" \ 146 | $\"$INSTDIR\lib\setup\env.py$\" --uninstall $\"$INSTDIR$\"" 147 | Rmdir /r "$INSTDIR" 148 | DeleteRegKey HKCU "Software\uranusjr\PythonUp" 149 | DeleteRegKey HKLM "${UNINSTALL_REGKEY}" 150 | SectionEnd 151 | -------------------------------------------------------------------------------- /pythonup/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.0.11' 2 | -------------------------------------------------------------------------------- /pythonup/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | class PythonUpGroup(click.Group): 5 | """Force command name to 'pythonup'. 6 | """ 7 | def make_context(self, info_name, *args, **kwargs): 8 | return super().make_context('pythonup', *args, **kwargs) 9 | 10 | 11 | @click.group(cls=PythonUpGroup, invoke_without_command=True) 12 | @click.option('--version', is_flag=True, help='Print version and exit.') 13 | @click.pass_context 14 | def cli(ctx, version): 15 | if ctx.invoked_subcommand is None: 16 | if version: 17 | from . import __version__ 18 | click.echo('PythonUp (Windows) {}'.format(__version__)) 19 | else: 20 | click.echo(ctx.get_help(), color=ctx.color) 21 | ctx.exit(1) 22 | 23 | 24 | @cli.command(help='Install a Python version.') 25 | @click.argument('version') 26 | @click.option( 27 | '--use/--no-use', default=None, help='Use version after installation.', 28 | ) 29 | @click.option( 30 | '--file', 'from_file', type=click.Path(exists=True), 31 | help='Specify an installer to not downloading one.', 32 | ) 33 | def install(**kwargs): 34 | from .operations.install import install 35 | install(**kwargs) 36 | 37 | 38 | @cli.command(help='Uninstall a Python version.') 39 | @click.argument('version') 40 | @click.option( 41 | '--file', 'from_file', type=click.Path(exists=True), 42 | help='Specify an uninstaller to not relying on auto-discovery.', 43 | ) 44 | def uninstall(**kwargs): 45 | from .operations.install import uninstall 46 | uninstall(**kwargs) 47 | 48 | 49 | @cli.command(help='Upgrade an installed Python version.') 50 | @click.argument('version') 51 | @click.option('--pre', is_flag=True, help='Include pre-releases.') 52 | @click.option( 53 | '--file', 'from_file', type=click.Path(exists=True), 54 | help='Specify path to installer to not downloading one.', 55 | ) 56 | @click.pass_context 57 | def upgrade(ctx, **kwargs): 58 | from .operations.install import upgrade 59 | upgrade(ctx, **kwargs) 60 | 61 | 62 | @cli.command(help='Download installer of given Python version.') 63 | @click.argument('version') 64 | @click.option( 65 | '--dest', 'dest_dir', type=click.Path(exists=True, file_okay=False), 66 | help='Download installer to this directory.', 67 | ) 68 | @click.option('--force', is_flag=True, help='Overwrite target if exists.') 69 | @click.pass_context 70 | def download(ctx, **kwargs): 71 | from .operations.download import download 72 | download(ctx, **kwargs) 73 | 74 | 75 | @cli.command(help='Set active Python versions.') 76 | @click.argument('version', nargs=-1) 77 | @click.option( 78 | '--add/--reset', default=None, help='Add version to use without removing.', 79 | ) 80 | @click.pass_context 81 | def use(ctx, **kwargs): 82 | from .operations.link import use 83 | use(ctx, **kwargs) 84 | 85 | 86 | @cli.command( 87 | help='Print where the executable of Python version is.', 88 | short_help='Print python.exe location.', 89 | ) 90 | @click.argument('version') 91 | def where(**kwargs): 92 | from .operations.versions import where 93 | where(**kwargs) 94 | 95 | 96 | @cli.command(name='list', help='List Python versions.') 97 | @click.option( 98 | '--all', 'list_all', is_flag=True, 99 | help='List all versions (instead of only installed ones).', 100 | ) 101 | def list_(**kwargs): 102 | from .operations.versions import list_ 103 | list_(**kwargs) 104 | 105 | 106 | @cli.command( 107 | short_help='Link a command from active versions.', 108 | help=('Link a command, or all commands available based on the currently ' 109 | 'used Python version(s).'), 110 | ) 111 | @click.argument('command', required=False) 112 | @click.option( 113 | '--all', 'link_all', is_flag=True, 114 | help='Link all available operations.', 115 | ) 116 | @click.option( 117 | '--overwrite', 118 | type=click.Choice(['yes', 'no', 'smart']), default='yes', 119 | help='What to do when the target exists.', 120 | ) 121 | @click.option( 122 | '--user-friendly/--no-user-friendly', 'user_friendly', 123 | hidden=True, is_flag=True, default=True, 124 | help='Hides non-essential user-friendly messages (internal usage).', 125 | ) 126 | @click.pass_context 127 | def link(ctx, overwrite, **kwargs): 128 | from .operations.link import link, Overwrite 129 | link(ctx, overwrite=Overwrite[overwrite], **kwargs) 130 | 131 | 132 | if __name__ == '__main__': 133 | cli() 134 | -------------------------------------------------------------------------------- /pythonup/configs.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | 4 | 5 | def get_value(key): 6 | with pathlib.Path(__file__).with_name('installation.json').open() as f: 7 | data = json.load(f) 8 | return data[key] 9 | 10 | 11 | def get_directory(key): 12 | path = pathlib.Path(__file__).parent.joinpath(get_value(key)) 13 | path.mkdir(parents=True, exist_ok=True) 14 | return path.resolve(strict=True) 15 | 16 | 17 | def get_scripts_dir_path(): 18 | return get_directory('scripts_dir') 19 | 20 | 21 | def get_cmd_dir_path(): 22 | return get_directory('cmd_dir') 23 | 24 | 25 | def get_linkexe_script_path(): 26 | return get_directory('utils_dir').joinpath('linkexe.vbs') 27 | 28 | 29 | def get_shim_path(): 30 | return get_directory('shims_dir').joinpath('shim.exe') 31 | 32 | 33 | def get_conf_path(): 34 | path = get_directory('base_dir').joinpath('config') 35 | path.touch(mode=0o644, exist_ok=True) 36 | return path 37 | 38 | 39 | def safe_load(f): 40 | try: 41 | return json.load(f) 42 | except json.JSONDecodeError: 43 | return {} 44 | 45 | 46 | def get_active_names(): 47 | with get_conf_path().open() as f: 48 | data = safe_load(f) 49 | return data.get('using', []) 50 | 51 | 52 | def set_active_names(names): 53 | with get_conf_path().open('w+') as f: 54 | data = safe_load(f) 55 | data['using'] = names 56 | json.dump(data, f) 57 | -------------------------------------------------------------------------------- /pythonup/installation.json: -------------------------------------------------------------------------------- 1 | { 2 | "base_dir": "..", 3 | "cmd_dir": "..\\runenv\\cmd", 4 | "scripts_dir": "..\\runenv\\Scripts", 5 | "shims_dir": "..\\stubs" 6 | } 7 | -------------------------------------------------------------------------------- /pythonup/installations.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import contextlib 3 | import itertools 4 | import os 5 | import pathlib 6 | import re 7 | import subprocess 8 | 9 | import attr 10 | 11 | 12 | @attr.s 13 | class Installation: 14 | 15 | path = attr.ib(converter=pathlib.Path) 16 | 17 | @property 18 | def python(self): 19 | return self.path.joinpath('python.exe') 20 | 21 | @property 22 | def scripts_dir(self): 23 | return self.path.joinpath('Scripts') 24 | 25 | @property 26 | def pip(self): 27 | return self.scripts_dir.joinpath('pip.exe') 28 | 29 | def get_version_info(self): 30 | # TODO: Remove stderr redirection after dropping older Pythons. 31 | output = subprocess.check_output( 32 | [str(self.python), '--version'], 33 | stderr=subprocess.STDOUT, encoding='ascii', 34 | ).strip() 35 | match = re.match(r'^Python (\d+)\.(\d+)\.(\d+)$', output) 36 | return tuple(int(x) for x in match.groups()) 37 | 38 | def is_32bit(self): 39 | """Ask the interpreter about its bitness. 40 | 41 | The return value should match :ref:`.metadata.is_python_32bit()`. 42 | """ 43 | return bool(ast.eval_literal(subprocess.check_output([ 44 | self.python, '-c', '"import sys; print(sys.maxsize <= 2 ** 32)"', 45 | ]).strip())) 46 | 47 | def find_script(self, name): 48 | names = itertools.chain([name], [ 49 | '{}{}'.format(name, ext) 50 | for ext in os.environ['PATHEXT'].split(';') 51 | ]) 52 | for name in names: 53 | with contextlib.suppress(FileNotFoundError): 54 | return self.scripts_dir.joinpath(name).resolve(strict=True) 55 | raise FileNotFoundError(name) 56 | -------------------------------------------------------------------------------- /pythonup/metadata.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import struct 3 | import sys 4 | import winreg 5 | 6 | 7 | PYTHON_KEY_PATHS = [ 8 | (winreg.HKEY_CURRENT_USER, 'Software\\Python\\PythonCore'), 9 | (winreg.HKEY_LOCAL_MACHINE, 'Software\\Python\\PythonCore'), 10 | (winreg.HKEY_LOCAL_MACHINE, 'Software\\Wow6432Node\\Python\\PythonCore'), 11 | ] 12 | 13 | 14 | def get_install_path(name): 15 | for root, prefix in PYTHON_KEY_PATHS: 16 | keypath = '{}\\{}\\InstallPath'.format(prefix, name) 17 | try: 18 | key = winreg.OpenKey(root, keypath) 19 | except FileNotFoundError: 20 | continue 21 | install_path, _ = winreg.QueryValueEx(key, '') 22 | winreg.CloseKey(key) 23 | return pathlib.Path(install_path).resolve(strict=True) 24 | raise FileNotFoundError( 25 | 'Software\\Python\\PythonCore\\{}\\InstallPath'.format(name), 26 | ) 27 | 28 | 29 | def find_uninstaller_id(name): 30 | # Look for EVERY entry in the uninstaller list to find one that looks like 31 | # the matching version's uninstaller. This is crazy, but the best way I 32 | # can think of right now. And it's still faster than downloading the MSI. 33 | key = winreg.OpenKey( 34 | winreg.HKEY_LOCAL_MACHINE, 35 | 'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall', 36 | ) 37 | 38 | match = None 39 | subkey_count, _, _ = winreg.QueryInfoKey(key) 40 | for i in range(subkey_count): 41 | sub_name = winreg.EnumKey(key, i) 42 | subkey = winreg.OpenKey(key, sub_name) 43 | try: 44 | display_name, _ = winreg.QueryValueEx(subkey, 'DisplayName') 45 | publisher, _ = winreg.QueryValueEx(subkey, 'Publisher') 46 | except FileNotFoundError: 47 | continue 48 | finally: 49 | winreg.CloseKey(subkey) 50 | if (display_name.startswith('Python {}.'.format(name)) and 51 | publisher == 'Python Software Foundation'): 52 | match = sub_name 53 | break 54 | 55 | winreg.CloseKey(key) 56 | 57 | if not match: 58 | raise FileNotFoundError 59 | return match 60 | 61 | 62 | def get_bundle_cache_path(name): 63 | key = winreg.OpenKey( 64 | winreg.HKEY_CLASSES_ROOT, 65 | 'Installer\\Dependencies\\CPython-{}'.format(name), 66 | ) 67 | guid, _ = winreg.QueryValueEx(key, '') 68 | winreg.CloseKey(key) 69 | 70 | for top_key in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): 71 | key_parts = [ 72 | 'Software', 'Microsoft', 'Windows', 73 | 'CurrentVersion', 'Uninstall', guid, 74 | ] 75 | try: 76 | key = winreg.OpenKey(top_key, '\\'.join(key_parts)) 77 | value, _ = winreg.QueryValueEx(key, 'BundleCachePath') 78 | path = pathlib.Path(value).resolve(strict=True) 79 | except FileNotFoundError: 80 | continue 81 | else: 82 | return path 83 | finally: 84 | winreg.CloseKey(key) 85 | raise FileNotFoundError 86 | 87 | 88 | def can_install_64bit(): 89 | # Check the size of a C pointer to determine architecture. 90 | # https://github.com/kennethreitz-archive/its.py/blob/master/its.py 91 | return struct.calcsize('P') * 8 >= 64 92 | 93 | 94 | def is_python_32bit(): 95 | # Check int size for Python bitness. 96 | # Be aware this is different from `can_install_64bit()`, which checks 97 | # whether the HOST is 64-bit. If you install a 32-bit Python on a 64-bit 98 | # host, this function identifies it as 32-bit, but `can_install_64bit()` 99 | # would still return True. 100 | return sys.maxsize <= 2 ** 32 101 | -------------------------------------------------------------------------------- /pythonup/operations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uranusjr/pythonup-windows/94d27496fe0b27b12664980c4a3a669407bddae2/pythonup/operations/__init__.py -------------------------------------------------------------------------------- /pythonup/operations/common.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import click 4 | 5 | from .. import configs, metadata, versions 6 | 7 | 8 | def check_installation(version, *, installed=True, on_exit=None): 9 | try: 10 | installation = version.get_installation() 11 | except FileNotFoundError: 12 | if not installed: # Expected to be absent. Return None. 13 | return None 14 | message = '{} is not installed.' 15 | else: 16 | if installed: # Expected to be installed. Return the installation. 17 | return installation 18 | message = '{} is already installed.' 19 | click.echo(message.format(version), err=True) 20 | if on_exit: 21 | on_exit() 22 | click.get_current_context().exit(1) 23 | 24 | 25 | def get_active_names(): 26 | return configs.get_active_names() 27 | 28 | 29 | def set_active_versions(versions): 30 | configs.set_active_names([v.name for v in versions]) 31 | 32 | 33 | def get_versions(*, installed_only): 34 | vers = versions.get_versions() 35 | names = set(v.name for v in vers) 36 | 37 | def should_include(version): 38 | if installed_only and not version.is_installed(): 39 | return False 40 | # On a 32-bit host, hide 64-bit names if there is a 32-bit counterpart. 41 | if (not metadata.can_install_64bit() and 42 | not version.name.endswith('-32') and 43 | '{}-32'.format(version.name) in names): 44 | return False 45 | return True 46 | 47 | return [v for v in vers if should_include(v)] 48 | 49 | 50 | def get_version(name): 51 | force_32 = not metadata.can_install_64bit() 52 | try: 53 | version = versions.get_version(name, force_32=force_32) 54 | except versions.VersionNotFoundError: 55 | click.echo('No such version: {}'.format(name), err=True) 56 | click.get_current_context().exit(1) 57 | if version.name != name: 58 | click.echo('Note: Selecting {} instead of {}'.format( 59 | version.name, name, 60 | )) 61 | return version 62 | 63 | 64 | def version_command(*, plural=False, wild_versions=()): 65 | if wild_versions: 66 | def _get_version(n): 67 | if n in wild_versions: 68 | return n 69 | return get_version(n) 70 | else: 71 | _get_version = get_version 72 | 73 | def decorator(f): 74 | 75 | @functools.wraps(f) 76 | def wrapped(*args, version, **kw): 77 | if plural: 78 | kw['versions'] = [_get_version(n) for n in version] 79 | else: 80 | kw['version'] = _get_version(version) 81 | return f(*args, **kw) 82 | 83 | return wrapped 84 | 85 | return decorator 86 | -------------------------------------------------------------------------------- /pythonup/operations/download.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import shutil 3 | 4 | import click 5 | 6 | from .. import utils 7 | 8 | from .common import version_command 9 | 10 | 11 | def download_installer(version): 12 | click.echo('Downloading {}'.format(version.url)) 13 | return utils.download_file(version.url, check=version.check_installer) 14 | 15 | 16 | @version_command() 17 | def download(ctx, version, dest_dir, force): 18 | installer = download_installer(version) 19 | if dest_dir is None: 20 | dest_dir = pathlib.Path.cwd() 21 | target = pathlib.Path(dest_dir, installer.name) 22 | if target.exists() and not force: 23 | click.echo('Target exists: {}'.format(target), err=True) 24 | click.echo('NOTE: Use --force to overwrite destination.', err=True) 25 | ctx.exit(1) 26 | shutil.move(str(installer), str(target)) 27 | click.echo('{} installer is downloaded successfully to {}'.format( 28 | version, target, 29 | )) 30 | -------------------------------------------------------------------------------- /pythonup/operations/install.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import pathlib 3 | 4 | import click 5 | 6 | from .common import ( 7 | check_installation, 8 | get_active_names, get_version, get_versions, version_command, 9 | ) 10 | from .download import download_installer 11 | from .link import ( 12 | activate, link_commands, unlink_commands, update_active_versions, 13 | ) 14 | 15 | 16 | @version_command() 17 | def install(version, use, from_file): 18 | check_installation( 19 | version, installed=False, 20 | on_exit=functools.partial(link_commands, version), 21 | ) 22 | 23 | if from_file is None: 24 | installer_path = download_installer(version) 25 | else: 26 | installer_path = pathlib.Path(from_file) 27 | 28 | if use is None and not get_versions(installed_only=True): 29 | use = True 30 | click.echo('Will use {} after installation.'.format(version)) 31 | 32 | click.echo('Running installer {}'.format(installer_path)) 33 | dirpath = version.install(str(installer_path)) 34 | 35 | link_commands(version) 36 | click.echo('{} is installed successfully to {}'.format( 37 | version, dirpath, 38 | )) 39 | 40 | if use: 41 | versions = [ 42 | get_version(n) 43 | for n in get_active_names() 44 | ] + [version] 45 | activate(versions, allow_empty=True) 46 | 47 | 48 | @version_command() 49 | def uninstall(version, from_file): 50 | check_installation(version, on_exit=functools.partial( 51 | unlink_commands, version, 52 | )) 53 | update_active_versions(remove=[version]) 54 | 55 | if from_file is not None: 56 | uninstaller_path = pathlib.Path(from_file) 57 | else: 58 | try: 59 | uninstaller_path = version.get_cached_uninstaller() 60 | except FileNotFoundError: 61 | uninstaller_path = download_installer(version) 62 | 63 | click.echo('Running uninstaller {}'.format(uninstaller_path)) 64 | version.uninstall(str(uninstaller_path)) 65 | unlink_commands(version) 66 | click.echo('{} is uninstalled successfully.'.format(version)) 67 | 68 | 69 | @version_command(wild_versions=['self']) 70 | def upgrade(ctx, version, pre, from_file): 71 | if version == 'self': 72 | from .releases import self_upgrade 73 | self_upgrade(installer=from_file, pre=pre) 74 | return 75 | 76 | if pre: 77 | click.echo('Installing prereleases is not supported yet.', err=True) 78 | ctx.exit(1) 79 | 80 | installation_vi = check_installation( 81 | version, on_exit=functools.partial(link_commands, version), 82 | ).get_version_info() 83 | if installation_vi >= version.version_info: 84 | click.echo('{} is up to date ({}).'.format( 85 | version, '.'.join(str(i) for i in installation_vi), 86 | )) 87 | return 88 | 89 | if from_file is None: 90 | installer_path = download_installer(version) 91 | else: 92 | installer_path = pathlib.Path(from_file) 93 | 94 | click.echo('Running installer {}'.format(installer_path)) 95 | version.upgrade(str(installer_path)) 96 | 97 | link_commands(version) 98 | click.echo('{} is upgraded successfully at {}'.format( 99 | version, version.get_installation().path, 100 | )) 101 | -------------------------------------------------------------------------------- /pythonup/operations/link.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import enum 3 | import filecmp 4 | import itertools 5 | import shutil 6 | import sys 7 | 8 | import click 9 | 10 | from .. import configs 11 | 12 | from .common import ( 13 | check_installation, get_active_names, get_version, 14 | set_active_versions, version_command, 15 | ) 16 | 17 | 18 | class Overwrite(enum.Enum): 19 | yes = 'yes' 20 | no = 'no' 21 | smart = 'smart' 22 | 23 | 24 | def safe_publish(target, *, overwrite, comparer, writer, quiet): 25 | should_write = (overwrite == Overwrite.yes or ( 26 | overwrite == Overwrite.smart and not (target.exists() and comparer()) 27 | )) 28 | if not should_write: 29 | return False 30 | if not quiet: 31 | click.echo(' {}'.format(target.name)) 32 | try: 33 | writer() 34 | except OSError as e: 35 | click.echo('WARNING: Failed to copy {}.\n{}: {}'.format( 36 | target.name, type(e).__name__, e, 37 | ), err=True) 38 | return False 39 | return True 40 | 41 | 42 | def publish_file(source, target, *, overwrite, quiet): 43 | 44 | def comp(): 45 | return filecmp.cmp(str(source), str(target)) 46 | 47 | def copy(): 48 | shutil.copy2(str(source), str(target)) 49 | 50 | return safe_publish( 51 | target, quiet=quiet, overwrite=overwrite, comparer=comp, writer=copy, 52 | ) 53 | 54 | 55 | def publish_shim(source, target, *, relink, overwrite, quiet): 56 | """Write a shim. 57 | 58 | A shim is an pre-compiled executable, with extra data appended to the end 59 | of it. The extra data contain what command(s) the shim should attempt to 60 | execute when launched. Arguments are seperated by NULL characters, and 61 | commands (if there are multiple) are seperated by line feeds. Two extra 62 | line feeds signify the end of the command sequence. 63 | 64 | The extra data are encoded with UTF-8, and written *backwards* into the 65 | executable. This makes it easier to read data out. 66 | """ 67 | cmds = [[str(source.resolve(strict=True))]] 68 | if relink: 69 | cmds.append([ 70 | sys.executable, '-m', 'pythonup', 71 | 'link', '--all', '--overwrite=smart', '--no-user-friendly', 72 | ]) 73 | data = bytes(reversed( 74 | ('\n'.join('\0'.join(args) for args in cmds) + '\n\n').encode('utf-8') 75 | )) 76 | 77 | def comp(): 78 | return target.read_bytes().endswith(data) 79 | 80 | def write(): 81 | target.write_bytes(configs.get_shim_path().read_bytes() + data) 82 | 83 | return safe_publish( 84 | target, quiet=quiet, overwrite=overwrite, comparer=comp, writer=write, 85 | ) 86 | 87 | 88 | def safe_unlink(p): 89 | if not p.exists(): 90 | return 91 | try: 92 | p.unlink() 93 | except OSError as e: 94 | click.echo('Failed to remove {} ({})'.format(p, e), err=True) 95 | 96 | 97 | def collect_version_scripts(versions): 98 | names = set() 99 | scripts = [] 100 | shims = [] 101 | for version in versions: 102 | version_scripts_dir = version.get_installation().scripts_dir 103 | if not version_scripts_dir.is_dir(): 104 | continue 105 | for path in version_scripts_dir.iterdir(): 106 | blacklisted_stems = { 107 | # Encourage people to always use qualified commands. 108 | 'easy_install', 'pip', 109 | # Fully qualified pip is already populated on installation. 110 | 'pip{}'.format(version.arch_free_name), 111 | } 112 | shimmed_stems = { 113 | # Major version names, e.g. "pip3". 114 | 'pip{}'.format(version.version_info[0]), 115 | # Fully-qualified easy_install. 116 | 'easy_install-{}'.format(version.arch_free_name), 117 | } 118 | if path.name in names or path.stem in blacklisted_stems: 119 | continue 120 | names.add(path.name) 121 | if path.stem in shimmed_stems: 122 | shims.append(path) 123 | else: 124 | scripts.append(path) 125 | return scripts, shims 126 | 127 | 128 | def activate(versions, *, overwrite=Overwrite.yes, 129 | allow_empty=False, quiet=False): 130 | if not allow_empty and not versions: 131 | click.echo('No active versions.', err=True) 132 | click.get_current_context().exit(1) 133 | 134 | source_scripts, shimmed_scripts = collect_version_scripts(versions) 135 | scripts_dir = configs.get_scripts_dir_path() 136 | 137 | using_scripts = set() 138 | 139 | # TODO: Distinguish between `use` and automatic hook after shimmed pip 140 | # execution. The latter should only write scripts that actually chaged, or 141 | # at least should only log those writes (and overwrite others silently). 142 | if source_scripts or shimmed_scripts or versions: 143 | if not quiet: 144 | click.echo('Publishing scripts....') 145 | for source in source_scripts: 146 | target = scripts_dir.joinpath(source.name) 147 | if not source.is_file(): 148 | continue 149 | using_scripts.add(target) 150 | publish_file(source, target, overwrite=overwrite, quiet=quiet) 151 | for source in shimmed_scripts: 152 | target = scripts_dir.joinpath(source.name) 153 | if target in using_scripts: 154 | continue 155 | using_scripts.add(target) 156 | publish_shim( 157 | source, target, relink=True, overwrite=overwrite, quiet=quiet, 158 | ) 159 | for version in versions: 160 | target = version.python_major_command 161 | if target in using_scripts: 162 | continue 163 | using_scripts.add(target) 164 | publish_shim( 165 | version.get_installation().python, target, 166 | relink=False, overwrite=overwrite, quiet=quiet, 167 | ) 168 | 169 | set_active_versions(versions) 170 | 171 | stale_scripts = set(scripts_dir.iterdir()) - using_scripts 172 | if stale_scripts: 173 | if not quiet: 174 | click.echo('Cleaning stale scripts...') 175 | for script in stale_scripts: 176 | if not quiet: 177 | click.echo(' {}'.format(script.name)) 178 | safe_unlink(script) 179 | 180 | 181 | def link_commands(version): 182 | installation = version.get_installation() 183 | for path in version.python_commands: 184 | click.echo('Publishing {}'.format(path.name)) 185 | publish_shim( 186 | installation.python, path, 187 | relink=False, overwrite=Overwrite.yes, quiet=True, 188 | ) 189 | for path in version.pip_commands: 190 | click.echo('Publishing {}'.format(path.name)) 191 | publish_shim( 192 | installation.pip, path, 193 | relink=True, overwrite=Overwrite.yes, quiet=True, 194 | ) 195 | 196 | 197 | def unlink_commands(version): 198 | for p in itertools.chain(version.python_commands, version.pip_commands): 199 | click.echo('Unlinking {}'.format(p.name)) 200 | safe_unlink(p) 201 | 202 | 203 | def update_active_versions(*, remove=frozenset()): 204 | current_active_names = set(get_active_names()) 205 | active_names = [n for n in current_active_names] 206 | for version in remove: 207 | try: 208 | active_names.remove(version.name) 209 | except ValueError: 210 | continue 211 | click.echo('Deactivating {}'.format(version)) 212 | if len(current_active_names) != len(active_names): 213 | activate([get_version(n) for n in active_names], allow_empty=True) 214 | 215 | 216 | @version_command(plural=True) 217 | def use(ctx, versions, add): 218 | if add is None and not versions: 219 | # Bare "use": Display active versions. 220 | names = get_active_names() 221 | if names: 222 | click.echo(' '.join(names)) 223 | else: 224 | click.echo('Not using any versions.', err=True) 225 | return 226 | 227 | for version in versions: 228 | check_installation(version) 229 | 230 | active_versions = [ 231 | get_version(name) 232 | for name in get_active_names() 233 | ] 234 | if add: 235 | active_names = set(v.name for v in active_versions) 236 | new_versions = [] 237 | for v in versions: 238 | if v.name in active_names: 239 | click.echo('Already using {}.'.format(v), err=True) 240 | else: 241 | new_versions.append(v) 242 | versions = active_versions + new_versions 243 | 244 | # Remove duplicate inputs (keep first apperance). 245 | versions = list(collections.OrderedDict( 246 | (version.name, version) for version in versions 247 | ).values()) 248 | 249 | if active_versions == versions: 250 | click.echo('No version changes.', err=True) 251 | return 252 | 253 | if versions: 254 | click.echo('Using: {}'.format(', '.join(v.name for v in versions))) 255 | else: 256 | click.echo('Not using any versions.') 257 | activate(versions, allow_empty=(not add)) 258 | 259 | 260 | def link(ctx, command, link_all, overwrite, user_friendly): 261 | if not link_all and not command: # This mistake is more common. 262 | click.echo(ctx.get_usage(), color=ctx.color) 263 | click.echo('\nError: Missing argument "command".', color=ctx.color) 264 | ctx.exit(1) 265 | if link_all and command: 266 | click.echo('--all cannot be used with a command.', err=True) 267 | ctx.exit(1) 268 | 269 | active_names = get_active_names() 270 | if not active_names: 271 | if user_friendly: 272 | message = ( 273 | 'Not using any versions.\n' 274 | 'HINT: Use "pythonup use" to use a version first.' 275 | ) 276 | click.echo(message, err=True) 277 | ctx.exit(1) 278 | 279 | if link_all: 280 | activate( 281 | [get_version(n) for n in active_names], 282 | overwrite=overwrite, allow_empty=True, 283 | ) 284 | return 285 | 286 | command_name = command # Better variable names. 287 | command = None 288 | for version_name in active_names: 289 | version = get_version(version_name) 290 | try: 291 | command = version.get_installation().find_script(command_name) 292 | except FileNotFoundError: 293 | continue 294 | break 295 | if command is None: 296 | click.echo('Command "{}" not found. Looked in {}: {}'.format( 297 | command_name, 298 | 'version' if len(active_names) == 1 else 'versions', 299 | ', '.join(active_names), 300 | ), err=True) 301 | ctx.exit(1) 302 | 303 | target_name = command.name 304 | target = configs.get_scripts_dir_path().joinpath(target_name) 305 | 306 | # This can be done in publish_file, but we provide a better error message. 307 | if overwrite != Overwrite.yes and target.exists(): 308 | if filecmp.cmp(str(command), str(target)): 309 | return # If the two files are identical, we're good anyway. 310 | click.echo('{} exists. Use --overwrite=yes to overwrite.', err=True) 311 | ctx.exit(1) 312 | 313 | ok = publish_file(command, target, overwrite=Overwrite.yes, quiet=True) 314 | if ok: 315 | click.echo('Linked {} from {}'.format(target_name, version)) 316 | -------------------------------------------------------------------------------- /pythonup/operations/releases.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import warnings 4 | 5 | import click 6 | 7 | from .. import __version__ 8 | from .. import metadata, releases, termui, utils 9 | 10 | 11 | def install_self_upgrade(path): 12 | click.echo('Installing upgrade from {}'.format(path)) 13 | click.echo('PythonUp will terminate now to let the installer run.') 14 | click.echo('Come back after the installation finishes. See ya later!') 15 | 16 | # Launch detached installer and exit, releasing files for writes. 17 | os.startfile(path) 18 | click.get_current_context().exit(0) 19 | 20 | 21 | def self_upgrade(*, installer, pre): 22 | if installer: 23 | if pre: 24 | click.echo('Ignoring --pre flag for upgrading self with --file') 25 | install_self_upgrade(pathlib.Path(installer)) 26 | return 27 | 28 | with warnings.catch_warnings(): 29 | warnings.showwarning = termui.warn 30 | try: 31 | release = releases.get_new_release(__version__, includes_pre=pre) 32 | except releases.ReleaseUpToDate as e: 33 | click.echo('Current verion {} is up to date.'.format(__version__)) 34 | if e.version.is_prerelease and not pre: 35 | click.echo( 36 | "You are on a pre-release. Maybe you want to check for a " 37 | "pre-release update with --pre?", 38 | ) 39 | return 40 | 41 | arch = 'win32' if metadata.is_python_32bit() else 'amd64' 42 | asset = release.get_asset(arch) 43 | if asset is None: 44 | click.echo('No suitable asset to download in {}'.format(release)) 45 | return 46 | 47 | url = asset.browser_download_url 48 | path = utils.download_file(url, check=asset.check_download) 49 | install_self_upgrade(path) 50 | -------------------------------------------------------------------------------- /pythonup/operations/versions.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from .common import ( 4 | check_installation, get_active_names, get_versions, version_command, 5 | ) 6 | 7 | 8 | @version_command() 9 | def where(version): 10 | installation = check_installation(version) 11 | click.echo(str(installation.python)) 12 | 13 | 14 | def list_(list_all): 15 | vers = get_versions(installed_only=(not list_all)) 16 | active_names = set(get_active_names()) 17 | 18 | for v in vers: 19 | marker = ' ' 20 | if v.name in active_names: 21 | marker = '*' 22 | elif v.is_installed(): 23 | marker = 'o' 24 | # TODO: Show '+' for upgradable. 25 | # How should we show an upgradable *and* active version? 26 | click.echo('{} {}'.format(marker, v.name)) 27 | 28 | if not list_all and not vers: 29 | click.echo( 30 | 'No installed versions. Use --all to list all available versions ' 31 | 'for installation.', 32 | err=True, 33 | ) 34 | -------------------------------------------------------------------------------- /pythonup/releases.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import urllib.parse 4 | import warnings 5 | 6 | import attr 7 | import packaging.version 8 | import requests 9 | 10 | 11 | GITHUB_API_TOKEN_KEY = 'PYTHONUP_GITHUB_API_TOKEN' 12 | 13 | 14 | def get_request_headers(): 15 | token = os.environ.get(GITHUB_API_TOKEN_KEY) 16 | if not token: 17 | warnings.warn('{} environment variable not set.'.format( 18 | GITHUB_API_TOKEN_KEY, 19 | )) 20 | return None 21 | return {'Authorazation': 'token {}'.format(token)} 22 | 23 | 24 | def get(endpoint): 25 | url = urllib.parse.urljoin('https://api.github.com', endpoint) 26 | headers = get_request_headers() 27 | resp = requests.get(url, headers=headers) 28 | resp.raise_for_status() 29 | return resp 30 | 31 | 32 | class Parsable: 33 | @classmethod 34 | def parse(cls, data): 35 | extras = {} 36 | attr_names = set(a.name for a in attr.fields(cls)) 37 | for k, v in tuple(data.items()): 38 | if k not in attr_names: 39 | extras[k] = data.pop(k) 40 | instance = cls(**data) 41 | for k, v in extras.items(): 42 | setattr(instance, k, v) 43 | return instance 44 | 45 | 46 | @attr.s 47 | class ReleaseAsset(Parsable): 48 | 49 | browser_download_url = attr.ib(converter=str) 50 | size = attr.ib(converter=int) 51 | 52 | def check_download(self, data): 53 | assert len(data) == self.size, \ 54 | 'expect {} bytes, got {}'.format(self.size, len(data)) 55 | 56 | 57 | def parse_asset_list(data_list): 58 | return [ReleaseAsset.parse(data) for data in data_list] 59 | 60 | 61 | ASSET_NAME_RE = re.compile(r''' 62 | ^pythonup\-setup 63 | \- 64 | (?P\w+) # amd64 or win32 65 | \-.+ # version 66 | \.exe$ 67 | ''', re.VERBOSE) 68 | 69 | 70 | @attr.s 71 | class Release(Parsable): 72 | 73 | name = attr.ib(converter=str) 74 | draft = attr.ib(converter=bool) 75 | prerelease = attr.ib(converter=bool) 76 | tag_name = attr.ib(converter=str) 77 | assets = attr.ib(converter=parse_asset_list) 78 | 79 | def __str__(self): 80 | return self.name 81 | 82 | def get_asset(self, arch): 83 | for asset in self.assets: 84 | match = ASSET_NAME_RE.match(asset.name) 85 | if match and match.group('arch') == arch: 86 | return asset 87 | return None 88 | 89 | 90 | class ReleaseUpToDate(ValueError): 91 | def __init__(self, current): 92 | super().__init__('{} is up to date'.format(current)) 93 | self.version = current 94 | 95 | 96 | def get_releases(): 97 | response = get('/repos/uranusjr/pythonup-windows/releases') 98 | return [Release.parse(data) for data in response.json()] 99 | 100 | 101 | def get_new_release(current, *, includes_pre): 102 | current = packaging.version.parse(current) 103 | for release in get_releases(): 104 | if release.draft or (release.prerelease and not includes_pre): 105 | continue 106 | version = packaging.version.parse(release.tag_name) 107 | if version > current: 108 | return release 109 | raise ReleaseUpToDate(current) 110 | -------------------------------------------------------------------------------- /pythonup/termui.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | import click 4 | 5 | 6 | ETAD_LENGH = len(' 0d 00:00:00') 7 | PERC_LENGH = len(' 100%') 8 | MISC_LENGH = len(' [') + len(']') 9 | 10 | 11 | def progressbar(*, length, label, width=36): 12 | kwargs = { 13 | 'bar_template': '%(label)s [%(bar)s] %(info)s', 14 | 'info_sep': ' ', 15 | 'label': label, 16 | 'length': length, 17 | 'show_eta': True, 18 | 'show_percent': True, 19 | } 20 | 21 | DISCARDABLE_PARTS = sorted([ 22 | (len(label), {'label': ''}), 23 | (ETAD_LENGH, {'show_eta': False}), 24 | (PERC_LENGH, {'show_percent': False}), 25 | ], key=operator.itemgetter(0), reverse=True) 26 | 27 | # The extra 1 column is needed to hold the cursor. 28 | available = click.get_terminal_size()[0] - 1 29 | 30 | # Hide parts until the bar fits terminal. 31 | total = MISC_LENGH + len(label) + width + PERC_LENGH + ETAD_LENGH 32 | for length, update in DISCARDABLE_PARTS: 33 | if total < available: 34 | break 35 | kwargs.update(update) 36 | total -= length 37 | 38 | return click.progressbar(**kwargs) 39 | 40 | 41 | def warn(message, category, filename, lineno, file=None, line=None): 42 | click.echo('WARNING: {}'.format(message), err=True) 43 | -------------------------------------------------------------------------------- /pythonup/utils.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import pathlib 3 | import shutil 4 | import tempfile 5 | 6 | import requests 7 | 8 | from . import termui 9 | 10 | 11 | class DownloadIntegrityError(ValueError): 12 | pass 13 | 14 | 15 | def download_file(url, *, filename=None, container=None, check=None): 16 | response = requests.get(url, stream=True) 17 | response.raise_for_status() 18 | 19 | if not filename: 20 | filename = url.rsplit('/', 1)[-1] 21 | total = response.headers.get('content-length', '') 22 | chunks = [] 23 | 24 | if total.isdigit(): 25 | with termui.progressbar(length=int(total), label=filename) as b: 26 | for chunk in response.iter_content(chunk_size=4096): 27 | chunks.append(chunk) 28 | b.update(len(chunk)) 29 | else: 30 | chunks.append(response.content) 31 | 32 | data = b''.join(chunks) 33 | if callable(check): 34 | try: 35 | check(data) 36 | except AssertionError as e: 37 | raise DownloadIntegrityError(str(e)) 38 | 39 | if container is None: 40 | container = pathlib.Path(tempfile.mkdtemp()) 41 | atexit.register(shutil.rmtree, str(container), ignore_errors=True) 42 | path = container.joinpath(filename) 43 | with path.open('wb') as f: 44 | f.write(data) 45 | return path 46 | -------------------------------------------------------------------------------- /pythonup/versions.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import hashlib 3 | import json 4 | import operator 5 | import os 6 | import pathlib 7 | import re 8 | import subprocess 9 | 10 | import attr 11 | 12 | from . import configs, installations, metadata 13 | 14 | 15 | class VersionNotFoundError(ValueError): 16 | pass 17 | 18 | 19 | class InstallerType(enum.Enum): 20 | # Old MSI installer format used by CPython until the 3.4 line. 21 | # Usage: https://www.python.org/download/releases/2.5/msi/ 22 | cpython_msi = 'cpython_msi' 23 | 24 | # New Python installer introduced in CPython 3.5. 25 | # Usage: https://docs.python.org/3/using/windows.html#installing-without-ui 26 | cpython = 'cpython' 27 | 28 | 29 | VERSIONS_DIR_PATH = pathlib.Path(__file__).with_name('versions').resolve() 30 | 31 | 32 | def load_version_data(name): 33 | try: 34 | with VERSIONS_DIR_PATH.joinpath('{}.json'.format(name)).open() as f: 35 | data = json.load(f) 36 | except FileNotFoundError: 37 | raise VersionNotFoundError(name) 38 | return data 39 | 40 | 41 | @attr.s 42 | class Version: 43 | 44 | name = attr.ib() 45 | url = attr.ib() 46 | md5_sum = attr.ib() 47 | version_info = attr.ib(converter=tuple) 48 | product_codes = attr.ib(default=attr.Factory(dict)) 49 | forced_32 = attr.ib(default=False) 50 | 51 | def __str__(self): 52 | return 'Python {}'.format(self.name) 53 | 54 | @property 55 | def arch_free_name(self): 56 | return self.name.split('-', 1)[0] 57 | 58 | @property 59 | def script_version_names(self): 60 | # Use set to avoid duplicates. 61 | if self.forced_32: 62 | return {self.name, self.arch_free_name} 63 | return {self.name} 64 | 65 | @property 66 | def python_commands(self): 67 | dirpath = configs.get_cmd_dir_path() 68 | return [ 69 | dirpath.joinpath('python{}.exe'.format(name)) 70 | for name in self.script_version_names 71 | ] 72 | 73 | @property 74 | def pip_commands(self): 75 | dirpath = configs.get_cmd_dir_path() 76 | return [ 77 | dirpath.joinpath('pip{}.exe'.format(name)) 78 | for name in self.script_version_names 79 | ] 80 | 81 | @property 82 | def python_major_command(self): 83 | dirpath = configs.get_scripts_dir_path() 84 | return dirpath.joinpath('python{}.exe'.format(self.version_info[0])) 85 | 86 | def get_installation(self): 87 | path = metadata.get_install_path(self.name).resolve(strict=True) 88 | return installations.Installation(path=path) 89 | 90 | def is_installed(self): 91 | try: 92 | exists = metadata.get_install_path(self.name).exists() 93 | except FileNotFoundError: 94 | return False 95 | return exists 96 | 97 | def check_installer(self, data): 98 | checksum = hashlib.md5(data).hexdigest() 99 | assert checksum == self.md5_sum, \ 100 | 'expect checksum {}, got {}'.format(self.md5_sum, checksum) 101 | 102 | def get_target_for_install(self): 103 | return pathlib.Path( 104 | os.environ['LocalAppData'], 'Programs', 'Python', 105 | 'Python{}'.format(self.name.replace('.', '')), 106 | ) 107 | 108 | 109 | class CPythonMSIVersion(Version): 110 | 111 | @classmethod 112 | def load(cls, name, data, *, force_32): 113 | variant = data['x86' if force_32 else 'amd64'] 114 | return cls( 115 | name=name, 116 | version_info=data['version_info'], 117 | url=variant['url'], 118 | md5_sum=variant['md5_sum'], 119 | product_codes=variant.get('product_codes', {}), 120 | ) 121 | 122 | def _run_installer(self, cmd, target_dirpath): 123 | features = ['DefaultFeature', 'PrivateCRT', 'TclTk', 'pip_feature'] 124 | parts = [ # Argument ordering is very important. 125 | # Options and required parameters. 126 | 'msiexec', '/i', '"{}"'.format(cmd), 127 | 128 | # Optional parameters and flags. 129 | '/qb', 'TARGETDIR="{}"'.format(target_dirpath), 130 | 'ADDLOCAL={}'.format(','.join(features)), 131 | 132 | # This does not do what you think. DO NOT SUPPLY IT. 133 | # The installer is per-user by default. 134 | # 'ALLUSERS=0', 135 | ] 136 | subprocess.check_call( 137 | ' '.join(parts), 138 | shell=True, # So we don't need to know what msiexec is. 139 | ) 140 | 141 | def install(self, cmd): 142 | dirpath = self.get_target_for_install() 143 | self._run_installer(cmd, dirpath) 144 | return dirpath 145 | 146 | def upgrade(self, cmd): 147 | # MSI installers don't allow 64-bit and 32-bit to co-exist, so we 148 | # assumed we're using the same architecture as the host. In the case 149 | # of upgrading, however, we need to be absolutely sure so not to break 150 | # the installation. How? Just ask the installation directly. 151 | installation = self.get_installation() 152 | 153 | # Now this is guarenteed to match the current installation's bitness. 154 | version = get_version(self.name, force_32=installation.is_32bit()) 155 | 156 | # Run the installer to upgrade. There's no way to know what was 157 | # installed previously; all we can do is to install what we want to 158 | # where the installation is. This will leave old components (e.g. old 159 | # docs) unmodified and out of sync, but there's nothing we can do. 160 | version._run_installer(cmd, installation.path) 161 | 162 | def get_cached_uninstaller(self): 163 | info = self.get_installation().get_version_info() 164 | try: 165 | return self.product_codes['{0[0]}.{0[1]}.{0[2]}'.format(info)] 166 | except (IndexError, KeyError, TypeError): 167 | return metadata.find_uninstaller_id(self.name) 168 | 169 | def uninstall(self, cmd): 170 | subprocess.check_call('msiexec /x "{}" /qb'.format(cmd), shell=True) 171 | 172 | 173 | class CPythonVersion(Version): 174 | 175 | @classmethod 176 | def load(cls, name, data, *, force_32): 177 | forced_32 = False 178 | if force_32 and not name.endswith('-32'): 179 | name = '{}-32'.format(name) 180 | data = load_version_data(name) 181 | forced_32 = True 182 | return cls( 183 | name=name, 184 | version_info=data['version_info'], 185 | url=data['url'], 186 | md5_sum=data['md5_sum'], 187 | forced_32=forced_32, 188 | ) 189 | 190 | def install(self, cmd): 191 | dirpath = self.get_target_for_install() 192 | subprocess.check_call([ 193 | cmd, '/passive', 'InstallAllUsers=0', 194 | 'DefaultJustForMeTargetDir={}'.format(dirpath), 195 | 'AssociateFiles=0', 'PrependPath=0', 'Shortcuts=0', 196 | 'Include_doc=0', 'Include_launcher=0', 'Include_test=0', 197 | 'Include_tools=0', 'InstallLauncherAllUsers=0', 198 | ]) 199 | return dirpath 200 | 201 | def upgrade(self, cmd): 202 | # The installer handles all feature detection for us. 203 | subprocess.check_call([cmd, '/passive']) 204 | 205 | def get_cached_uninstaller(self): 206 | return metadata.get_bundle_cache_path(self.name) 207 | 208 | def uninstall(self, cmd): 209 | subprocess.check_call([cmd, '/uninstall', '/passive']) 210 | 211 | 212 | def get_version(name, *, force_32): 213 | data = load_version_data(name) 214 | installer_type = InstallerType(data['type']) 215 | klass = { 216 | InstallerType.cpython_msi: CPythonMSIVersion, 217 | InstallerType.cpython: CPythonVersion, 218 | }[installer_type] 219 | return klass.load(name, data, force_32=force_32) 220 | 221 | 222 | VERSION_NAME_RE = re.compile(r'^\d+\.\d+(?:\-32)?$') 223 | 224 | 225 | def get_versions(): 226 | versions = ( 227 | get_version(p.stem, force_32=False) 228 | for p in VERSIONS_DIR_PATH.iterdir() 229 | if p.suffix == '.json' and VERSION_NAME_RE.match(p.stem) 230 | ) 231 | return sorted(versions, key=operator.attrgetter("version_info")) 232 | -------------------------------------------------------------------------------- /pythonup/versions/2.7.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython_msi", 3 | "version_info": [2, 7, 15], 4 | "amd64": { 5 | "url": "https://www.python.org/ftp/python/2.7.15/python-2.7.15.amd64.msi", 6 | "md5_sum": "0ffa44a86522f9a37b916b361eebc552", 7 | "product_codes": { 8 | "2.7.13": "{4A656C6C-D24A-473F-9747-3A8D00907A04}", 9 | "2.7.14": "{0398A685-FD8D-46B3-9816-C47319B0CF5F}", 10 | "2.7.15": "{16CD92A4-0152-4CB7-8FD6-9788D3363617}" 11 | } 12 | }, 13 | "x86": { 14 | "url": "https://www.python.org/ftp/python/2.7.15/python-2.7.15.msi", 15 | "md5_sum": "023e49c9fba54914ebc05c4662a93ffe", 16 | "product_codes": { 17 | "2.7.14": "{0398A685-FD8D-46B3-9816-C47319B0CF5E}", 18 | "2.7.15": "{16CD92A4-0152-4CB7-8FD6-9788D3363616}" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pythonup/versions/3.10-32.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 10, 6 | 1 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.10.1/python-3.10.1.exe", 9 | "md5_sum": "0b8c2ba677af4f47e534c7eee1c3cb03" 10 | } 11 | -------------------------------------------------------------------------------- /pythonup/versions/3.10.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 10, 6 | 1 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.10.1/python-3.10.1-amd64.exe", 9 | "md5_sum": "0e1c3a6ee3a05b5c4cd3d43fce8311a1" 10 | } 11 | -------------------------------------------------------------------------------- /pythonup/versions/3.4.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython_msi", 3 | "version_info": [3, 4, 4], 4 | "amd64": { 5 | "url": "https://www.python.org/ftp/python/3.4.4/python-3.4.4.amd64.msi", 6 | "md5_sum": "963f67116935447fad73e09cc561c713", 7 | "product_codes:": { 8 | "3.4.4": "{56EBF7CF-F2B2-30ED-9DE5-307FC2CE3449}" 9 | } 10 | }, 11 | "x86": { 12 | "url": "https://www.python.org/ftp/python/3.4.4/python-3.4.4.msi", 13 | "md5_sum": "e96268f7042d2a3d14f7e23b2535738b" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pythonup/versions/3.5-32.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [3, 5, 4], 4 | "url": "https://www.python.org/ftp/python/3.5.4/python-3.5.4.exe", 5 | "md5_sum": "9693575358f41f452d03fd33714f223f" 6 | } 7 | -------------------------------------------------------------------------------- /pythonup/versions/3.5.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [3, 5, 4], 4 | "url": "https://www.python.org/ftp/python/3.5.4/python-3.5.4-amd64.exe", 5 | "md5_sum": "4276742a4a75a8d07260f13fe956eec4" 6 | } 7 | -------------------------------------------------------------------------------- /pythonup/versions/3.6-32.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 6, 6 | 8 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.6.8/python-3.6.8.exe", 9 | "md5_sum": "9c7b1ebdd3a8df0eebfda2f107f1742c" 10 | } 11 | -------------------------------------------------------------------------------- /pythonup/versions/3.6.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 6, 6 | 8 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.6.8/python-3.6.8-amd64.exe", 9 | "md5_sum": "72f37686b7ab240ef70fdb931bdf3cb5" 10 | } 11 | -------------------------------------------------------------------------------- /pythonup/versions/3.7-32.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 7, 6 | 9 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.7.9/python-3.7.9.exe", 9 | "md5_sum": "1e6d31c98c68c723541f0821b3c15d52" 10 | } 11 | -------------------------------------------------------------------------------- /pythonup/versions/3.7.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 7, 6 | 9 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.7.9/python-3.7.9-amd64.exe", 9 | "md5_sum": "7083fed513c3c9a4ea655211df9ade27" 10 | } 11 | -------------------------------------------------------------------------------- /pythonup/versions/3.8-32.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 8, 6 | 10 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.8.10/python-3.8.10.exe", 9 | "md5_sum": "b355cfc84b681ace8908ae50908e8761" 10 | } 11 | -------------------------------------------------------------------------------- /pythonup/versions/3.8.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 8, 6 | 10 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.8.10/python-3.8.10-amd64.exe", 9 | "md5_sum": "62cf1a12a5276b0259e8761d4cf4fe42" 10 | } 11 | -------------------------------------------------------------------------------- /pythonup/versions/3.9-32.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 9, 6 | 9 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.9.9/python-3.9.9.exe", 9 | "md5_sum": "41566bd99961047c8332d46bd3dd90fc" 10 | } 11 | -------------------------------------------------------------------------------- /pythonup/versions/3.9.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "cpython", 3 | "version_info": [ 4 | 3, 5 | 9, 6 | 9 7 | ], 8 | "url": "https://www.python.org/ftp/python/3.9.9/python-3.9.9-amd64.exe", 9 | "md5_sum": "a09ef64c9ea2e7d9a04a2cafb833aa7b" 10 | } 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tools:pytest] 2 | python_paths = . 3 | show_missing = true 4 | 5 | [flake8] 6 | exclude = 7 | .git, 8 | __pycache__, 9 | installers/pythonup 10 | -------------------------------------------------------------------------------- /shims/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "shim" 5 | version = "0.1.0" 6 | dependencies = [ 7 | "winapi 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", 8 | ] 9 | 10 | [[package]] 11 | name = "winapi" 12 | version = "0.3.4" 13 | source = "registry+https://github.com/rust-lang/crates.io-index" 14 | dependencies = [ 15 | "winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", 16 | "winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", 17 | ] 18 | 19 | [[package]] 20 | name = "winapi-i686-pc-windows-gnu" 21 | version = "0.4.0" 22 | source = "registry+https://github.com/rust-lang/crates.io-index" 23 | 24 | [[package]] 25 | name = "winapi-x86_64-pc-windows-gnu" 26 | version = "0.4.0" 27 | source = "registry+https://github.com/rust-lang/crates.io-index" 28 | 29 | [metadata] 30 | "checksum winapi 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "04e3bd221fcbe8a271359c04f21a76db7d0c6028862d1bb5512d85e1e2eb5bb3" 31 | "checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 32 | "checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 33 | -------------------------------------------------------------------------------- /shims/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shim" 3 | version = "0.1.0" 4 | authors = ["Tzu-ping Chung "] 5 | 6 | [[bin]] 7 | name = "shim" 8 | 9 | [dependencies] 10 | winapi = { version = "0.3.2", features = ["consoleapi", "jobapi2", "wincon"] } 11 | -------------------------------------------------------------------------------- /shims/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import invoke 4 | 5 | 6 | SHIMSDIR = os.path.dirname(os.path.abspath(__file__)) 7 | 8 | 9 | @invoke.task() 10 | def build(ctx, release=False, verbose=False): 11 | build_params = [ 12 | '--release' * release, 13 | '--verbose' * verbose, 14 | ] 15 | with ctx.cd(SHIMSDIR): 16 | ctx.run('cargo build {}'.format(' '.join(build_params))) 17 | 18 | 19 | @invoke.task() 20 | def clean(ctx): 21 | with ctx.cd(SHIMSDIR): 22 | ctx.run('cargo clean') 23 | 24 | 25 | @invoke.task() 26 | def test(ctx): 27 | with ctx.cd(SHIMSDIR): 28 | ctx.run('cargo test') 29 | -------------------------------------------------------------------------------- /shims/src/cmds.rs: -------------------------------------------------------------------------------- 1 | use std::env::current_exe; 2 | use std::fs::File; 3 | use std::io::{self, Read}; 4 | use std::path::PathBuf; 5 | use std::vec::IntoIter; 6 | 7 | #[derive(Debug)] 8 | pub struct Command { 9 | exe: Option, 10 | args: Vec, 11 | } 12 | 13 | impl Command { 14 | pub fn new() -> Self { 15 | Command { exe: None, args: vec![] } 16 | } 17 | 18 | pub fn arg(&mut self, arg: String) { 19 | match self.exe { 20 | Some(_) => self.args.push(arg), 21 | None => self.exe = Some(PathBuf::from(arg)), 22 | } 23 | } 24 | 25 | pub fn exe(&self) -> Option<&PathBuf> { 26 | self.exe.as_ref() 27 | } 28 | 29 | pub fn args(&self) -> &Vec { 30 | &self.args 31 | } 32 | } 33 | 34 | pub struct Commands { 35 | iter: IntoIter>, 36 | done: bool, 37 | } 38 | 39 | // Helper to push a byte array as argument into command. 40 | macro_rules! push_arg { 41 | ( $command:expr, $bytes:expr ) => { 42 | match String::from_utf8($bytes) { 43 | Ok(arg) => { $command.arg(arg); }, 44 | Err(e) => { 45 | eprintln!("bad shim: {}", e); 46 | return None; 47 | }, 48 | } 49 | }; 50 | } 51 | 52 | impl Commands { 53 | pub fn from(bytes: Vec>) -> Self { 54 | Commands { iter: bytes.into_iter(), done: false } 55 | } 56 | 57 | pub fn from_current_exe() -> Result { 58 | let exe = current_exe().map_err(|e| e.to_string())?; 59 | let file = File::open(&exe).map_err(|e| e.to_string())?; 60 | 61 | let mut bytes: Vec<_> = io::BufReader::new(file).bytes().collect(); 62 | bytes.reverse(); 63 | 64 | Ok(Self::from(bytes)) 65 | } 66 | 67 | fn read_next_command(&mut self) -> Option { 68 | let mut command = Command::new(); 69 | let mut bytes = vec![]; 70 | while let Some(result) = self.iter.next() { match result { 71 | Ok(byte) => match byte { 72 | 10 => { // Line feed signifies end of command. 73 | if !bytes.is_empty() { 74 | push_arg!(command, bytes); 75 | } 76 | return match command.exe() { 77 | Some(_) => Some(command), 78 | None => None, 79 | }; 80 | }, 81 | 0 => { // Null signifies end of argument. 82 | push_arg!(command, bytes); 83 | bytes = vec![]; 84 | }, 85 | _ => { bytes.push(byte); }, 86 | }, 87 | Err(e) => { 88 | eprintln!("shim read failure: {}", e); 89 | return None; 90 | }, 91 | }} 92 | None 93 | } 94 | } 95 | 96 | impl Iterator for Commands { 97 | type Item = Command; 98 | 99 | fn next(&mut self) -> Option { 100 | if self.done { 101 | return None; 102 | } 103 | match self.read_next_command() { 104 | r @ Some(_) => r, 105 | None => { self.done = true; None }, 106 | } 107 | } 108 | } 109 | 110 | 111 | #[cfg(test)] 112 | mod command_test { 113 | use std::path::PathBuf; 114 | use super::Command; 115 | 116 | #[test] 117 | fn new() { 118 | let command = Command::new(); 119 | assert_eq!(command.exe(), None); 120 | assert_eq!(command.args(), &Vec::::new()); 121 | } 122 | 123 | #[test] 124 | fn push_exe() { 125 | let mut command = Command::new(); 126 | command.arg(String::from("C:\\python.exe")); 127 | assert_eq!(command.exe(), Some(&PathBuf::from("C:\\python.exe"))); 128 | assert_eq!(command.args(), &Vec::::new()); 129 | } 130 | 131 | #[test] 132 | fn push_exe_arg() { 133 | let mut command = Command::new(); 134 | command.arg(String::from("C:\\python.exe")); 135 | command.arg(String::from("-OO")); 136 | command.arg(String::from("-c")); 137 | command.arg(String::from("import sys; print(sys.executable)")); 138 | assert_eq!(command.exe(), Some(&PathBuf::from("C:\\python.exe"))); 139 | assert_eq!(command.args(), &vec![ 140 | String::from("-OO"), 141 | String::from("-c"), 142 | String::from("import sys; print(sys.executable)"), 143 | ]); 144 | } 145 | } 146 | 147 | 148 | #[cfg(test)] 149 | mod commands_test { 150 | use std::path::PathBuf; 151 | use super::Commands; 152 | 153 | #[test] 154 | fn consume_all() { 155 | let mut commands = Commands::from("\ 156 | C:\\python.exe\n\ 157 | C:\\python.exe\0--version\n\ 158 | C:\\python.exe\0-OO\0-c\0import sys; print(sys.executable)\n\ 159 | ".bytes().map(|b| Ok(b)).collect()); 160 | 161 | let command = commands.next().unwrap(); 162 | assert_eq!(command.exe(), Some(&PathBuf::from("C:\\python.exe"))); 163 | assert_eq!(command.args(), &Vec::::new()); 164 | 165 | let command = commands.next().unwrap(); 166 | assert_eq!(command.exe(), Some(&PathBuf::from("C:\\python.exe"))); 167 | assert_eq!(command.args(), &vec![String::from("--version")]); 168 | 169 | let command = commands.next().unwrap(); 170 | assert_eq!(command.exe(), Some(&PathBuf::from("C:\\python.exe"))); 171 | assert_eq!(command.args(), &vec![ 172 | String::from("-OO"), 173 | String::from("-c"), 174 | String::from("import sys; print(sys.executable)"), 175 | ]); 176 | 177 | assert_eq!(commands.next().is_none(), true); 178 | } 179 | 180 | #[test] 181 | fn avoid_garbage() { 182 | let mut commands = Commands::from( 183 | "C:\\python.exe\n\n12345".bytes().map(|b| Ok(b)).collect(), 184 | ); 185 | 186 | let command = commands.next().unwrap(); 187 | assert_eq!(command.exe(), Some(&PathBuf::from("C:\\python.exe"))); 188 | assert_eq!(command.args(), &Vec::::new()); 189 | 190 | assert_eq!(commands.next().is_none(), true); 191 | assert_eq!(commands.next().is_none(), true); 192 | assert_eq!(commands.next().is_none(), true); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /shims/src/main.rs: -------------------------------------------------------------------------------- 1 | mod cmds; 2 | mod procs; 3 | 4 | use std::process::{abort, exit}; 5 | use self::cmds::Commands; 6 | use self::procs::{setup, run}; 7 | 8 | macro_rules! run_or_exit { 9 | ( $cmd:expr, $own_args:expr ) => { 10 | match run($cmd.exe().unwrap(), $cmd.args(), $own_args) { 11 | Ok(code) => if code != 0 { exit(code) }, 12 | Err(e) => { eprintln!("{}", e); abort(); }, 13 | } 14 | }; 15 | } 16 | 17 | fn main() { 18 | let mut cmds = Commands::from_current_exe().unwrap_or_else(|e| { 19 | eprintln!("{}", e); 20 | abort(); 21 | }); 22 | 23 | setup().unwrap_or_else(|e| { eprintln!("{}", e); abort(); }); 24 | match cmds.next() { 25 | Some(cmd) => run_or_exit!(cmd, true), 26 | None => { abort(); }, 27 | }; 28 | for cmd in cmds { 29 | run_or_exit!(cmd, false); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /shims/src/procs.rs: -------------------------------------------------------------------------------- 1 | /// Interface to run a child process for the shim. 2 | /// 3 | /// Most of this module, especially the part setting up the shild process, is 4 | /// based on how Pip creates an EXE launcher for console scripts, from 5 | /// [distlib], developed in C by Vinay Sajip. 6 | /// 7 | /// [distlib]: https://github.com/vsajip/distlib/blob/master/PC/launcher.c 8 | 9 | extern crate winapi; 10 | 11 | use std::{env, mem}; 12 | use std::os::windows::io::AsRawHandle; 13 | use std::path::PathBuf; 14 | use std::process::{Child, Command}; 15 | 16 | use self::winapi::ctypes::c_void; 17 | use self::winapi::shared::minwindef::{BOOL, DWORD, LPVOID, TRUE}; 18 | use self::winapi::um::consoleapi::SetConsoleCtrlHandler; 19 | use self::winapi::um::jobapi2::{ 20 | AssignProcessToJobObject, CreateJobObjectW, QueryInformationJobObject, 21 | SetInformationJobObject}; 22 | use self::winapi::um::wincon::{CTRL_C_EVENT, GenerateConsoleCtrlEvent}; 23 | use self::winapi::um::winnt::{ 24 | JOBOBJECT_EXTENDED_LIMIT_INFORMATION, 25 | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, 26 | JobObjectExtendedLimitInformation}; 27 | 28 | 29 | static mut PID: u32 = 0; 30 | 31 | unsafe extern "system" fn handle_ctrl(etype: DWORD) -> BOOL { 32 | if etype == CTRL_C_EVENT && PID != 0 { 33 | // FIXME: Why does this work? The two arguments of 34 | // GenerateConsoleCtrlEvent are dwCtrlEvent and dwProcessGroupId, so 35 | // we're passing them backwards... But this is what what Python's 36 | // launchers do, and IT ACTUALLY WORKS. I'm letting it stand for now. 37 | GenerateConsoleCtrlEvent(PID, CTRL_C_EVENT); 38 | } 39 | TRUE 40 | } 41 | 42 | unsafe fn setup_child(child: &mut Child) -> Result<(), &'static str> { 43 | PID = child.id(); 44 | 45 | let job = CreateJobObjectW(0 as *mut _, 0 as *const _); 46 | let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = mem::zeroed(); 47 | 48 | let mut ok; 49 | 50 | ok = QueryInformationJobObject( 51 | job, JobObjectExtendedLimitInformation, 52 | &mut info as *mut _ as LPVOID, 53 | mem::size_of_val(&info) as DWORD, 54 | 0 as *mut _, 55 | ); 56 | if ok != TRUE { 57 | return Err("job information query error"); 58 | } 59 | 60 | info.BasicLimitInformation.LimitFlags = 61 | info.BasicLimitInformation.LimitFlags | 62 | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | 63 | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK; 64 | ok = SetInformationJobObject( 65 | job, JobObjectExtendedLimitInformation, 66 | &mut info as *mut _ as LPVOID, 67 | mem::size_of_val(&info) as DWORD, 68 | ); 69 | if ok != TRUE { 70 | return Err("job information set error"); 71 | } 72 | 73 | AssignProcessToJobObject(job, child.as_raw_handle() as *mut c_void); 74 | 75 | Ok(()) 76 | } 77 | 78 | /// Run the child process, and return its result. 79 | /// 80 | /// The child's exit code is returned, or an error message if the child fails 81 | /// to launch, or does not exit cleanly. If `with_own_args` is `true`, the 82 | /// child process is launched with arguments passed to the parent process, 83 | /// appende after `args`. 84 | pub fn run(exe: &PathBuf, args: &Vec, with_own_args: bool) 85 | -> Result { 86 | 87 | let mut cmd = Command::new(exe); 88 | cmd.args(args); 89 | 90 | // Hand over arguments passed to the shim (args[0] not included). 91 | if with_own_args { 92 | cmd.args(env::args().skip(1)); 93 | } 94 | 95 | let mut child = cmd.spawn().map_err(|e| { 96 | format!("failed to spawn child: {}", e) 97 | })?; 98 | 99 | unsafe { setup_child(&mut child)? }; 100 | 101 | let result = child.wait().map_err(|e| { 102 | format!("failed to wait for child: {}", e) 103 | })?; 104 | 105 | // Doc seems to suggest this won't happen on Windows, but I'm not sure. 106 | // 137 is a common value seen with SIGKILL terminated programs. 107 | Ok(result.code().unwrap_or(137)) 108 | } 109 | 110 | /// Set up the parent process. 111 | /// 112 | /// This should be called before running the child, to set up the CTRL handler 113 | /// so the parent passes on the CTRL-C event to its child. 114 | pub fn setup() -> Result<(), &'static str> { 115 | let ok = unsafe { SetConsoleCtrlHandler(Some(handle_ctrl), TRUE) }; 116 | if ok == TRUE { 117 | Ok(()) 118 | } else { 119 | Err("control handler set error") 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /stubs/README.txt: -------------------------------------------------------------------------------- 1 | Files with extension "exe" in this directory are not actually executables, but 2 | only placeholders to make link commands (e.g. "use" and "link") work during 3 | development without needing to compile the shims. 4 | 5 | The "shims_dir" key in "installation.json" point here. It will be overwritten 6 | by the installer in real deployment to point to the real shims. 7 | -------------------------------------------------------------------------------- /stubs/shim.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uranusjr/pythonup-windows/94d27496fe0b27b12664980c4a3a669407bddae2/stubs/shim.exe -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import invoke 2 | 3 | import docs 4 | import installers 5 | import shims 6 | 7 | namespace = invoke.Collection(docs, installers, shims) 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import unittest.mock 2 | import sys 3 | 4 | 5 | def pytest_collectstart(): 6 | sys.modules['winreg'] = unittest.mock.Mock() 7 | -------------------------------------------------------------------------------- /tests/test_installations.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import uuid 3 | 4 | import pytest 5 | 6 | import pythonup.installations 7 | 8 | 9 | @pytest.fixture 10 | def instpath(tmpdir): 11 | return pathlib.Path(str(tmpdir.mkdir(str(uuid.uuid4())))) 12 | 13 | 14 | @pytest.fixture 15 | def installation(instpath): 16 | return pythonup.installations.Installation(instpath) 17 | 18 | 19 | def test_scripts_dir(instpath, installation): 20 | assert installation.scripts_dir == instpath.joinpath('Scripts') 21 | 22 | 23 | def test_python(instpath, installation): 24 | assert installation.python == instpath.joinpath('python.exe') 25 | 26 | 27 | def test_pip(instpath, installation): 28 | assert installation.pip == instpath.joinpath('Scripts', 'pip.exe') 29 | -------------------------------------------------------------------------------- /tests/test_versions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | import re 4 | 5 | import pytest 6 | 7 | import pythonup.versions 8 | 9 | 10 | version_paths = list(pythonup.versions.VERSIONS_DIR_PATH.iterdir()) 11 | version_names = [p.stem for p in version_paths] 12 | 13 | 14 | @pytest.mark.parametrize('path', version_paths, ids=version_names) 15 | def test_version_definitions(path): 16 | assert path.suffix == '.json', '{} has wrong extension'.format(path) 17 | assert pythonup.versions.VERSION_NAME_RE.match(path.stem), \ 18 | '{} has invalid name'.format(path) 19 | 20 | with path.open() as f: 21 | data = json.load(f) 22 | 23 | schema = data.pop('type') 24 | possible_types = pythonup.versions.InstallerType.__members__ 25 | assert schema in possible_types 26 | 27 | assert isinstance(data.pop('version_info'), list) 28 | 29 | if schema == 'cpython_msi': 30 | for key in ('x86', 'amd64'): 31 | d = data.pop(key) 32 | assert d.pop('url') 33 | assert re.match(r'^[a-f\d]{32}$', d.pop('md5_sum')) 34 | elif schema == 'cpython': 35 | assert data.pop('url') 36 | assert re.match(r'^[a-f\d]{32}$', data.pop('md5_sum')) 37 | 38 | assert not data, 'superfulous keys: {}'.format(', '.join(data.keys())) 39 | 40 | 41 | def test_get_version_cpython_msi(): 42 | version = pythonup.versions.get_version('3.4', force_32=False) 43 | assert version == pythonup.versions.CPythonMSIVersion( 44 | name='3.4', 45 | url='https://www.python.org/ftp/python/3.4.4/python-3.4.4.amd64.msi', 46 | md5_sum='963f67116935447fad73e09cc561c713', 47 | version_info=(3, 4, 4), 48 | ) 49 | 50 | 51 | def test_get_version_cpython_msi_switch(): 52 | version = pythonup.versions.get_version('3.4', force_32=True) 53 | assert version == pythonup.versions.CPythonMSIVersion( 54 | name='3.4', 55 | url='https://www.python.org/ftp/python/3.4.4/python-3.4.4.msi', 56 | md5_sum='e96268f7042d2a3d14f7e23b2535738b', 57 | version_info=(3, 4, 4), 58 | ) 59 | 60 | 61 | def test_get_version_cpython(): 62 | version = pythonup.versions.get_version('3.5', force_32=False) 63 | assert version == pythonup.versions.CPythonVersion( 64 | name='3.5', 65 | url='https://www.python.org/ftp/python/3.5.4/python-3.5.4-amd64.exe', 66 | md5_sum='4276742a4a75a8d07260f13fe956eec4', 67 | version_info=(3, 5, 4), 68 | ) 69 | 70 | 71 | def test_get_version_cpython_switch(): 72 | version = pythonup.versions.get_version('3.5', force_32=True) 73 | assert version == pythonup.versions.CPythonVersion( 74 | name='3.5-32', 75 | url='https://www.python.org/ftp/python/3.5.4/python-3.5.4.exe', 76 | md5_sum='9693575358f41f452d03fd33714f223f', 77 | version_info=(3, 5, 4), 78 | forced_32=True, 79 | ) 80 | 81 | 82 | def test_get_version_not_found(): 83 | with pytest.raises(pythonup.versions.VersionNotFoundError) as ctx: 84 | pythonup.versions.get_version('2.8', force_32=False) 85 | assert str(ctx.value) == '2.8' 86 | 87 | 88 | @pytest.mark.parametrize('name, force_32, result', [ 89 | ('3.6', False, 'Python 3.6'), 90 | ('3.6', True, 'Python 3.6-32'), 91 | ('3.4', False, 'Python 3.4'), 92 | ('3.4', True, 'Python 3.4'), 93 | ]) 94 | def test_str(name, force_32, result): 95 | version = pythonup.versions.get_version(name, force_32=force_32) 96 | assert str(version) == result 97 | 98 | 99 | @pytest.mark.parametrize('name, force_32, cmd', [ 100 | ('3.6', False, 'python3.exe'), 101 | ('3.6', True, 'python3.exe'), 102 | ('2.7', False, 'python2.exe'), 103 | ('2.7', True, 'python2.exe'), 104 | ]) 105 | def test_python_major_command(mocker, name, force_32, cmd): 106 | mocker.patch.object(pythonup.versions, 'configs', **{ 107 | 'get_scripts_dir_path.return_value': pathlib.Path(), 108 | }) 109 | version = pythonup.versions.get_version(name, force_32=force_32) 110 | assert version.python_major_command == pathlib.Path(cmd) 111 | 112 | 113 | @pytest.mark.parametrize('name, force_32, result', [ 114 | ('3.6', False, '3.6'), 115 | ('3.6', True, '3.6'), 116 | ('3.4', False, '3.4'), 117 | ('3.4', True, '3.4'), 118 | ]) 119 | def test_arch_free_name(name, force_32, result): 120 | version = pythonup.versions.get_version(name, force_32=force_32) 121 | assert version.arch_free_name == result 122 | 123 | 124 | @pytest.mark.parametrize('name, force_32, result', [ 125 | ('3.6', False, {'3.6'}), 126 | ('3.6', True, {'3.6', '3.6-32'}), 127 | ('3.6-32', False, {'3.6-32'}), 128 | ('3.4', False, {'3.4'}), 129 | ('3.4', True, {'3.4'}), 130 | ]) 131 | def test_script_version_names(name, force_32, result): 132 | version = pythonup.versions.get_version(name, force_32=force_32) 133 | assert version.script_version_names == result 134 | 135 | 136 | def test_is_installed(tmpdir, mocker): 137 | mock_metadata = mocker.patch.object(pythonup.versions, 'metadata', **{ 138 | 'get_install_path.return_value': pathlib.Path(str(tmpdir)), 139 | }) 140 | version = pythonup.versions.get_version('3.6', force_32=False) 141 | assert version.is_installed() 142 | mock_metadata.get_install_path.assert_called_once_with('3.6') 143 | -------------------------------------------------------------------------------- /tools/check_md5.py: -------------------------------------------------------------------------------- 1 | """Quick way to verify I provided matching download URL and MD5. 2 | """ 3 | 4 | import hashlib 5 | import json 6 | import pathlib 7 | import sys 8 | 9 | import requests 10 | 11 | 12 | def download_data(url): 13 | print('Downloading', url, '... ', end='', flush=True) 14 | response = requests.get(url) 15 | response.raise_for_status() 16 | print('Done') 17 | return response.content 18 | 19 | 20 | def check_download(url, version_info, md5sum): 21 | prefix = 'https://www.python.org/ftp/python/{v}/python-{v}'.format( 22 | v='.'.join(str(s) for s in version_info), 23 | ) 24 | if not url.startswith(prefix): 25 | raise ValueError('{} is not from version {}'.format(url, version_info)) 26 | 27 | checksum = hashlib.md5(download_data(url)).hexdigest() 28 | if checksum != md5sum: 29 | raise AssertionError('{} != {} (expected)'.format(checksum, md5sum)) 30 | 31 | 32 | def check_cpython(info): 33 | check_download(info['url'], info['version_info'], info['md5_sum']) 34 | 35 | 36 | def check_cpython_msi(info): 37 | for variant in ['x86', 'amd64']: 38 | check_download( 39 | info[variant]['url'], 40 | info['version_info'], 41 | info[variant]['md5_sum'], 42 | ) 43 | 44 | 45 | CHECKERS = { 46 | 'cpython': check_cpython, 47 | 'cpython_msi': check_cpython_msi, 48 | } 49 | 50 | 51 | def main(): 52 | if len(sys.argv) < 2: 53 | raise ValueError('no versions provided') 54 | for v in sys.argv[1:]: 55 | path = pathlib.Path(__file__).parent.parent.joinpath( 56 | 'pythonup', 'versions', '{}.json'.format(v), 57 | ) 58 | with path.open() as f: 59 | info = json.load(f) 60 | CHECKERS[info['type']](info) 61 | print('Version', v, 'is OK!') 62 | 63 | 64 | if __name__ == '__main__': 65 | main() 66 | -------------------------------------------------------------------------------- /tools/get_msi_product_code.py: -------------------------------------------------------------------------------- 1 | """Retrieves product code from the MSI to be vendored in version information. 2 | """ 3 | import msilib 4 | import sys 5 | 6 | 7 | def main(): 8 | try: 9 | msi_s = sys.argv[1] 10 | except IndexError: 11 | msi_s = input('Paste MSI path: ') 12 | db = msilib.OpenDatabase(msi_s, msilib.MSIDBOPEN_READONLY) 13 | 14 | v = db.OpenView("select Value from Property where Property='ProductCode'") 15 | v.Execute(None) 16 | 17 | col_index = 0 18 | col_info = v.GetColumnInfo(msilib.MSICOLINFO_NAMES) 19 | while True: 20 | if col_info.GetString(col_index) == 'Value': 21 | break 22 | col_index += 1 23 | 24 | row = v.Fetch() 25 | guid = row.GetString(col_index) 26 | print('Product code:', guid) 27 | 28 | v.Close() 29 | 30 | 31 | if __name__ == '__main__': 32 | main() 33 | -------------------------------------------------------------------------------- /tools/update_version.py: -------------------------------------------------------------------------------- 1 | """Update entry in pythonup/versions. Only works with new styles installers. 2 | """ 3 | 4 | import argparse 5 | import contextlib 6 | import operator 7 | import json 8 | import os 9 | import pathlib 10 | import re 11 | import sys 12 | import urllib.parse 13 | 14 | import requests 15 | 16 | 17 | BLACKLISTED_IDS = { 18 | 287, # 3.7.2 has broken build tool discovery. (bpo-35699) 19 | } 20 | 21 | 22 | def _get_version(s): 23 | match = re.match(r'^(\d+)\.(\d+)$', s) 24 | if not match: 25 | raise argparse.ArgumentTypeError('should be an X.Y version number') 26 | x_y = tuple(map(int, match.groups())) 27 | if x_y < (3, 5): 28 | raise argparse.ArgumentTypeError('only 3.5+ is supported') 29 | return x_y 30 | 31 | 32 | def get_parser(): 33 | parser = argparse.ArgumentParser() 34 | parser.add_argument( 35 | 'version', type=_get_version, help="Name of version, in X.Y", 36 | ) 37 | return parser 38 | 39 | 40 | def parse_arguments(argv=None): 41 | parser = get_parser() 42 | return parser.parse_args(argv) 43 | 44 | 45 | def _get_page(url, params): 46 | headers = {'Accept': 'application/json'} 47 | response = requests.get(url, params=params, headers=headers) 48 | try: 49 | response.raise_for_status() 50 | except Exception: 51 | print('ERROR:', response.text) 52 | raise 53 | return response.json() 54 | 55 | 56 | class CollectionIterator: 57 | """Iterator for Collection. 58 | """ 59 | def __init__(self, url, params): 60 | self._params = params 61 | self._set_page(url) 62 | 63 | def __repr__(self): 64 | return 'CollectionIterator(url={!r})'.format(self._url) 65 | 66 | def __iter__(self): 67 | return self 68 | 69 | def __next__(self): 70 | with contextlib.suppress(StopIteration): 71 | return next(self._iter) 72 | path = self._meta['next'] 73 | if not path: 74 | raise StopIteration 75 | self._set_page(urllib.parse.urljoin(self._url, path)) 76 | return next(self._iter) 77 | 78 | def _set_page(self, url): 79 | self._url = url 80 | page = _get_page(url, self._params) 81 | self._meta = page['meta'] 82 | self._iter = iter(page['objects']) 83 | 84 | 85 | class Collection: 86 | """A collection returned by Tastypie's list view. 87 | """ 88 | def __init__(self, url, params=None): 89 | self._url = url 90 | self._params = params 91 | 92 | def __repr__(self): 93 | return 'Collection(url={!r})'.format(self._url) 94 | 95 | def __iter__(self): 96 | return CollectionIterator(self._url, self._params) 97 | 98 | 99 | def _get_endpoint(*parts): 100 | return 'https://www.python.org/api/v1/{}/'.format('/'.join(parts)) 101 | 102 | 103 | def iter_releases(version): 104 | url = _get_endpoint('downloads', 'release') 105 | params = { 106 | 'is_published': True, 107 | 'pre_release': False, 108 | } 109 | name_prefix = 'Python {}.{}.'.format(version[0], version[1]) 110 | for dataset in Collection(url, params): 111 | if not dataset['name'].startswith(name_prefix): 112 | continue 113 | yield dataset 114 | 115 | 116 | def parse_release_id(dataset): 117 | return int(dataset['resource_uri'].rsplit('/', 2)[-2]) 118 | 119 | 120 | def is_windows_installer(name): 121 | # Used for 3.9.1 onwards. 122 | if name.startswith('windows installer '): 123 | return True 124 | # Used for 3.9.0 and prior. 125 | if name.endswith(' executable installer'): 126 | return True 127 | return False 128 | 129 | 130 | def iter_installer_files(release): 131 | url = _get_endpoint('downloads', 'release_file') 132 | params = {'release': release['id']} 133 | for dataset in Collection(url, params): 134 | if is_windows_installer(dataset['name'].lower()): 135 | yield dataset 136 | 137 | 138 | def get_latest_release(version): 139 | datasets = list(iter_releases(version)) 140 | for dataset in datasets: 141 | dataset['id'] = parse_release_id(dataset) 142 | datasets = sorted(datasets, key=operator.itemgetter('id'), reverse=True) 143 | 144 | for dataset in datasets: 145 | print('Checking {} ...'.format(dataset['name']), end=' ') 146 | if dataset['id'] in BLACKLISTED_IDS: 147 | print('Blacklisted') 148 | continue 149 | installer_files = list(iter_installer_files(dataset)) 150 | if not installer_files: 151 | print('No installers') 152 | continue 153 | print('OK') 154 | return (dataset, installer_files) 155 | 156 | print('No available versions') 157 | return None 158 | 159 | 160 | def parse_version_info(name): 161 | match = re.match(r'^Python (\d+)\.(\d+)\.(\d+)$', name) 162 | if not match: 163 | raise ValueError('cannot parse version from {}'.format(name)) 164 | return list(map(int, match.groups())) 165 | 166 | 167 | VERSIONS_DIR = pathlib.Path(os.path.abspath(__file__)).parent.parent.joinpath( 168 | 'pythonup', 'versions', 169 | ) 170 | 171 | 172 | def detect_newline(f): 173 | newline = f.newlines 174 | if isinstance(newline, str): 175 | return newline 176 | return '\n' 177 | 178 | 179 | def write_version_file(suffix, version_info, dataset): 180 | filename = '{0[0]}.{0[1]}{1}.json'.format(version_info, suffix) 181 | path = VERSIONS_DIR.joinpath(filename) 182 | data = { 183 | 'type': 'cpython', 184 | 'version_info': version_info, 185 | 'url': dataset['url'], 186 | 'md5_sum': dataset['md5_sum'], 187 | } 188 | if path.exists(): 189 | with path.open() as f: 190 | try: 191 | curr = json.load(f) 192 | except ValueError: 193 | curr = {} 194 | if curr == data: 195 | print('Spec {} is up-to-date'.format(filename)) 196 | return 197 | newline = detect_newline(f) 198 | else: 199 | newline = '\n' 200 | with path.open('w', newline=newline) as f: 201 | json.dump(data, f, indent='\t') 202 | f.write('\n') # Trailing newline. 203 | print('Spec {} written'.format(filename)) 204 | 205 | 206 | def is_64_bit(name): 207 | if '(64-bit)' in name: # Used for 3.9.1 and later. 208 | return True 209 | if ' x86-64 ' in name: # Used for 3.9.0 and prior. 210 | return True 211 | return False 212 | 213 | 214 | def is_32_bit(name): 215 | if '(32-bit)' in name: # Used for 3.9.1 and later. 216 | return True 217 | if ' x86 ' in name: # Used for 3.9.0 and prior. 218 | return True 219 | return False 220 | 221 | 222 | def write_version_files(release, files): 223 | version_info = parse_version_info(release['name']) 224 | for dataset in files: 225 | if is_64_bit(dataset['name']): 226 | write_version_file('', version_info, dataset) 227 | elif is_32_bit(dataset['name']): 228 | write_version_file('-32', version_info, dataset) 229 | else: 230 | print('Unrecognized file {}'.format(dataset['name'])) 231 | 232 | 233 | def main(): 234 | options = parse_arguments() 235 | try: 236 | release, files = get_latest_release(options.version) 237 | except TypeError: 238 | sys.exit(1) 239 | write_version_files(release, files) 240 | 241 | 242 | if __name__ == '__main__': 243 | main() 244 | --------------------------------------------------------------------------------