pystand 1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pystand-1.0.dist-info/METADATA +361 -0
- pystand-1.0.dist-info/RECORD +6 -0
- pystand-1.0.dist-info/WHEEL +5 -0
- pystand-1.0.dist-info/entry_points.txt +2 -0
- pystand-1.0.dist-info/top_level.txt +1 -0
- pystand.py +659 -0
@@ -0,0 +1,361 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: pystand
|
3
|
+
Version: 1.0
|
4
|
+
Summary: Install Python versions from python-build-standalone project
|
5
|
+
Author-email: Mark Blakeney <mark.blakeney@bullet-systems.net>
|
6
|
+
License: GPLv3
|
7
|
+
Project-URL: Homepage, https://github.com/bulletmark/pystand
|
8
|
+
Keywords: python-build-standalone,pyenv,hatch,pdm
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
10
|
+
Requires-Python: >=3.8
|
11
|
+
Description-Content-Type: text/markdown
|
12
|
+
Requires-Dist: argcomplete
|
13
|
+
Requires-Dist: packaging
|
14
|
+
Requires-Dist: platformdirs
|
15
|
+
Requires-Dist: pygithub
|
16
|
+
|
17
|
+
## PYSTAND - Install Python Versions From The Python-Build-Standalone Project
|
18
|
+
[](https://pypi.org/project/pystand/)
|
19
|
+
[](https://aur.archlinux.org/packages/pystand/)
|
20
|
+
|
21
|
+
[`pystand`][pystand] is a command line tool to facilitate the
|
22
|
+
installation and update of pre-built Python versions from the
|
23
|
+
[`python-build-standalone`][pbs] project. The following commands are
|
24
|
+
provided:
|
25
|
+
|
26
|
+
|Command |Description |
|
27
|
+
|---------|----------------------------------------------------------------------|
|
28
|
+
|`install`|Install one or more versions from a python-build-standalone release |
|
29
|
+
|`update` |Update one, more, or all versions to another release |
|
30
|
+
|`remove` |Remove/uninstall one, more, or all versions |
|
31
|
+
|`list` |List installed versions and show which have an update available |
|
32
|
+
|`show` |Show versions available from a release |
|
33
|
+
|`path` |Show path prefix to installed version base directory |
|
34
|
+
|
35
|
+
By default, Python versions are sourced from the latest
|
36
|
+
`python-build-standalone` [release][pbs-rel] available but you can
|
37
|
+
optionally specify any older release. The required
|
38
|
+
[distribution](https://gregoryszorc.com/docs/python-build-standalone/main/running.html)
|
39
|
+
for your machine architecture is normally auto-detected but can be
|
40
|
+
overridden if required.
|
41
|
+
|
42
|
+
Some simple usage examples are:
|
43
|
+
|
44
|
+
```sh
|
45
|
+
$ pystand install 3.12
|
46
|
+
Version 3.12.3 @ 20240415 installed.
|
47
|
+
|
48
|
+
$ ls -l $(pystand path 3.12)/bin
|
49
|
+
total 4136
|
50
|
+
lrwxrwxrwx 1 user user 9 May 30 22:23 2to3 -> 2to3-3.12
|
51
|
+
-rwxrwxr-x 1 user user 128 Jan 1 10:00 2to3-3.12
|
52
|
+
lrwxrwxrwx 1 user user 8 May 30 22:23 idle3 -> idle3.12
|
53
|
+
-rwxrwxr-x 1 user user 126 Jan 1 10:00 idle3.12
|
54
|
+
-rwxrwxr-x 1 user user 256 Jan 1 10:00 pip
|
55
|
+
-rwxrwxr-x 1 user user 256 Jan 1 10:00 pip3
|
56
|
+
-rwxrwxr-x 1 user user 256 Jan 1 10:00 pip3.12
|
57
|
+
lrwxrwxrwx 1 user user 9 May 30 22:23 pydoc3 -> pydoc3.12
|
58
|
+
-rwxrwxr-x 1 user user 111 Jan 1 10:00 pydoc3.12
|
59
|
+
lrwxrwxrwx 1 user user 10 May 30 22:23 python -> python3.12
|
60
|
+
lrwxrwxrwx 1 user user 10 May 30 22:23 python3 -> python3.12
|
61
|
+
-rwxrwxr-x 1 user user 4206512 Jan 1 10:00 python3.12
|
62
|
+
-rwxrwxr-x 1 user user 3078 Jan 1 10:00 python3.12-config
|
63
|
+
lrwxrwxrwx 1 user user 17 May 30 22:23 python3-config -> python3.12-config
|
64
|
+
|
65
|
+
$ pystand install 3.10
|
66
|
+
Version 3.10.14 @ 20240415 installed.
|
67
|
+
|
68
|
+
$ pystand list
|
69
|
+
3.10.14 @ 20240415 distribution="x86_64-unknown-linux-gnu"
|
70
|
+
3.12.3 @ 20240415 distribution="x86_64-unknown-linux-gnu"
|
71
|
+
|
72
|
+
$ pystand show
|
73
|
+
3.8.19 @ 20240415 distribution="x86_64-unknown-linux-gnu"
|
74
|
+
3.9.19 @ 20240415 distribution="x86_64-unknown-linux-gnu"
|
75
|
+
3.10.14 @ 20240415 distribution="x86_64-unknown-linux-gnu" (installed)
|
76
|
+
3.11.9 @ 20240415 distribution="x86_64-unknown-linux-gnu"
|
77
|
+
3.12.3 @ 20240415 distribution="x86_64-unknown-linux-gnu" (installed)
|
78
|
+
|
79
|
+
$ pystand remove 3.10
|
80
|
+
Version 3.10.14 @ 20240415 removed.
|
81
|
+
|
82
|
+
$ pystand list
|
83
|
+
3.12.3 @ 20240415 distribution="x86_64-unknown-linux-gnu"
|
84
|
+
```
|
85
|
+
|
86
|
+
Here are some examples showing how to use an installed version ..
|
87
|
+
|
88
|
+
```sh
|
89
|
+
# Use uv to create a virtual environment to be run with pystand installed python:
|
90
|
+
$ uv venv -p $(pystand path 3.12) myenv
|
91
|
+
|
92
|
+
# Create a regular virtual environment to be run with pystand installed python:
|
93
|
+
$ $(pystand path -p 3.12) -m venv myenv
|
94
|
+
|
95
|
+
# Use pipx to install a package to be run with pystand installed python:
|
96
|
+
$ pipx install --python $(pystand path -p 3.12) cowsay
|
97
|
+
```
|
98
|
+
|
99
|
+
See detailed usage information in the [Usage](#usage) section that
|
100
|
+
follows.
|
101
|
+
|
102
|
+
Note that unlike nearly all similar tools such as [`pyenv`][pyenv], [`pdm
|
103
|
+
python`][pdmpy], and [`hatch python`][hatchpy], `pystand` directly
|
104
|
+
checks the [`python-build-standalone`][pbs] github site to fetch for new
|
105
|
+
[releases][pbs-rel] but those other tools require a software update
|
106
|
+
before they can see new releases. This means that Python updates are
|
107
|
+
available more quickly from `pystand` than other tools.
|
108
|
+
|
109
|
+
This utility has been developed and tested on Linux but should also work
|
110
|
+
on macOS and Windows although has not been tried on those platforms. The
|
111
|
+
latest documentation and code is available at
|
112
|
+
https://github.com/bulletmark/pystand.
|
113
|
+
|
114
|
+
## Usage
|
115
|
+
|
116
|
+
Type `pystand` or `pystand -h` to view the usage summary:
|
117
|
+
|
118
|
+
```
|
119
|
+
usage: pystand [-h] [-D DISTRIBUTION] [-B BASE_DIR] [-C CACHE_MINUTES]
|
120
|
+
[--purge-days PURGE_DAYS] [-V]
|
121
|
+
{install,update,remove,list,show,path} ...
|
122
|
+
|
123
|
+
Command line tool to install pre-built Python versions from the python-build-
|
124
|
+
standalone project.
|
125
|
+
|
126
|
+
options:
|
127
|
+
-h, --help show this help message and exit
|
128
|
+
-D DISTRIBUTION, --distribution DISTRIBUTION
|
129
|
+
python-build-standalone "*-install_only" distribution,
|
130
|
+
e.g. "x86_64-unknown-linux-gnu". Default is auto-
|
131
|
+
detected (detected as "x86_64-unknown-linux-gnu" for
|
132
|
+
this current host).
|
133
|
+
-B BASE_DIR, --base-dir BASE_DIR
|
134
|
+
specify pystand base dir for storing versions and
|
135
|
+
metadata. Default is "$HOME/.local/share/pystand"
|
136
|
+
-C CACHE_MINUTES, --cache-minutes CACHE_MINUTES
|
137
|
+
cache latest release tag fetch for this many minutes,
|
138
|
+
before rechecking for latest. Default is 60 minutes
|
139
|
+
--purge-days PURGE_DAYS
|
140
|
+
cache release file lists for this number of days after
|
141
|
+
last version referencing it is removed. Default is 30
|
142
|
+
days
|
143
|
+
-V show pystand version
|
144
|
+
|
145
|
+
Commands:
|
146
|
+
{install,update,remove,list,show,path}
|
147
|
+
install Install one or more versions from a python-build-
|
148
|
+
standalone release.
|
149
|
+
update Update one, more, or all versions to another release.
|
150
|
+
remove Remove/uninstall one, more, or all versions.
|
151
|
+
list List installed versions and show which have an update
|
152
|
+
available.
|
153
|
+
show Show versions available from a release.
|
154
|
+
path Show path prefix to installed version base directory.
|
155
|
+
|
156
|
+
Note you can set default starting global options in
|
157
|
+
$HOME/.config/pystand-flags.conf.
|
158
|
+
```
|
159
|
+
|
160
|
+
Type `pystand <command> -h` to see specific help/usage for any
|
161
|
+
individual command:
|
162
|
+
|
163
|
+
### Command `install`
|
164
|
+
|
165
|
+
```
|
166
|
+
usage: pystand install [-h] [-r RELEASE] [-f] version [version ...]
|
167
|
+
|
168
|
+
Install one or more versions from a python-build-standalone release.
|
169
|
+
|
170
|
+
positional arguments:
|
171
|
+
version version to install. E.g. 3.12 or 3.12.3
|
172
|
+
|
173
|
+
options:
|
174
|
+
-h, --help show this help message and exit
|
175
|
+
-r RELEASE, --release RELEASE
|
176
|
+
install from specified python-build-standalone release
|
177
|
+
(e.g. 20240415), default is latest release
|
178
|
+
-f, --force force install even if already installed
|
179
|
+
```
|
180
|
+
|
181
|
+
### Command `update`
|
182
|
+
|
183
|
+
```
|
184
|
+
usage: pystand update [-h] [-r RELEASE] [-a] [--skip] [version ...]
|
185
|
+
|
186
|
+
Update one, more, or all versions to another release.
|
187
|
+
|
188
|
+
positional arguments:
|
189
|
+
version version to update (or to skip for --all --skip)
|
190
|
+
|
191
|
+
options:
|
192
|
+
-h, --help show this help message and exit
|
193
|
+
-r RELEASE, --release RELEASE
|
194
|
+
update to specified release (e.g. 20240415), default
|
195
|
+
is latest release
|
196
|
+
-a, --all update ALL versions
|
197
|
+
--skip skip the specified versions when updating all (only
|
198
|
+
can be specified with --all)
|
199
|
+
```
|
200
|
+
|
201
|
+
### Command `remove`
|
202
|
+
|
203
|
+
```
|
204
|
+
usage: pystand remove [-h] [-a] [--skip] [-r RELEASE] [version ...]
|
205
|
+
|
206
|
+
Remove/uninstall one, more, or all versions.
|
207
|
+
|
208
|
+
positional arguments:
|
209
|
+
version version to remove (or to skip for --all --skip)
|
210
|
+
|
211
|
+
options:
|
212
|
+
-h, --help show this help message and exit
|
213
|
+
-a, --all remove ALL versions
|
214
|
+
--skip skip the specified versions when removing all (only
|
215
|
+
can be specified with --all)
|
216
|
+
-r RELEASE, --release RELEASE
|
217
|
+
only remove versions if from specified release (e.g.
|
218
|
+
20240415)
|
219
|
+
```
|
220
|
+
|
221
|
+
### Command `list`
|
222
|
+
|
223
|
+
```
|
224
|
+
usage: pystand list [-h] [-v] [-r RELEASE] [version ...]
|
225
|
+
|
226
|
+
List installed versions and show which have an update available.
|
227
|
+
|
228
|
+
positional arguments:
|
229
|
+
version only list specified version, else all
|
230
|
+
|
231
|
+
options:
|
232
|
+
-h, --help show this help message and exit
|
233
|
+
-v, --verbose explicitly report why a version is not eligible for
|
234
|
+
update
|
235
|
+
-r RELEASE, --release RELEASE
|
236
|
+
use specified release (e.g. 20240415) for verbose
|
237
|
+
compare, default is latest release
|
238
|
+
```
|
239
|
+
|
240
|
+
### Command `show`
|
241
|
+
|
242
|
+
```
|
243
|
+
usage: pystand show [-h] [-d] [release]
|
244
|
+
|
245
|
+
Show versions available from a release.
|
246
|
+
|
247
|
+
positional arguments:
|
248
|
+
release python-build-standalone release to show (e.g.
|
249
|
+
20240415), default is latest release
|
250
|
+
|
251
|
+
options:
|
252
|
+
-h, --help show this help message and exit
|
253
|
+
-d, --distributions also show all available distributions for each version
|
254
|
+
from the release
|
255
|
+
```
|
256
|
+
|
257
|
+
### Command `path`
|
258
|
+
|
259
|
+
```
|
260
|
+
usage: pystand path [-h] [-p] version
|
261
|
+
|
262
|
+
Show path prefix to installed version base directory.
|
263
|
+
|
264
|
+
positional arguments:
|
265
|
+
version version to return path for
|
266
|
+
|
267
|
+
options:
|
268
|
+
-h, --help show this help message and exit
|
269
|
+
-p, --python-path return full path to python executable
|
270
|
+
```
|
271
|
+
|
272
|
+
## Installation and Upgrade
|
273
|
+
|
274
|
+
Python 3.8 or later is required. Arch Linux users can install [`pystand`
|
275
|
+
from the AUR](https://aur.archlinux.org/packages/pystand) and skip this
|
276
|
+
section.
|
277
|
+
|
278
|
+
The easiest way to install [`pystand`][pystand] is to use [`pipx`][pipx]
|
279
|
+
(or [`pipxu`][pipxu]).
|
280
|
+
|
281
|
+
```sh
|
282
|
+
$ pipx install pystand
|
283
|
+
```
|
284
|
+
|
285
|
+
To upgrade:
|
286
|
+
|
287
|
+
```sh
|
288
|
+
$ pipx upgrade pystand
|
289
|
+
```
|
290
|
+
|
291
|
+
To uninstall:
|
292
|
+
|
293
|
+
```sh
|
294
|
+
$ pipx uninstall pystand
|
295
|
+
```
|
296
|
+
|
297
|
+
## Extrapolation of Python Versions
|
298
|
+
|
299
|
+
`pystand` extrapolates any version text you specify on the command line
|
300
|
+
to the latest available corresponding installed or release version. For
|
301
|
+
example, if you specify `pystand install 3.12` then `pystand` will look
|
302
|
+
in the release files to find the latest (i.e. highest) available
|
303
|
+
version of `3.12`, e.g. `3.12.3` (at the time of writing), and will install
|
304
|
+
that. Of course you can specify the exact version if you wish, e.g.
|
305
|
+
`3.12.3` but generally you don't need to bother. This is true for any
|
306
|
+
command that takes a version argument so be aware that this may be
|
307
|
+
confusing if there are multiple same Python minor versions, e.g.
|
308
|
+
`3.12.1` and `3.12.3`, installed from different releases. So in that
|
309
|
+
case you should specify the exact version because e.g. `pystand remove
|
310
|
+
3.12` will remove `3.12.3` which may not be what you want.
|
311
|
+
|
312
|
+
Note, consistent with this, you actually don't need to specify a
|
313
|
+
minor version, e.g. `pystand install 3` would also install `3.12.3`
|
314
|
+
(assuming `3.12.3` is the latest available version for Python 3).
|
315
|
+
|
316
|
+
## Command Default Options
|
317
|
+
|
318
|
+
You can add default global options to a personal configuration file
|
319
|
+
`~/.config/pystand-flags.conf`. If that file exists then each line of
|
320
|
+
options will be concatenated and automatically prepended to your
|
321
|
+
`pystand` command line arguments. Comments in the file (i.e. `#` and
|
322
|
+
anything after on a line) are ignored. Type `pystand` to see all
|
323
|
+
supported options.
|
324
|
+
|
325
|
+
The global options: `--distribution`, `--base-dir`, `--cache-minutes`,
|
326
|
+
`--purge-days` are the only sensible candidates to consider setting as
|
327
|
+
defaults.
|
328
|
+
|
329
|
+
## Command Line Tab Completion
|
330
|
+
|
331
|
+
Command line shell [tab
|
332
|
+
completion](https://en.wikipedia.org/wiki/Command-line_completion) is
|
333
|
+
automatically enabled on `pystand` commands and options using
|
334
|
+
[`argcomplete`](https://github.com/kislyuk/argcomplete). You may need to
|
335
|
+
first (once-only) [activate argcomplete global
|
336
|
+
completion](https://github.com/kislyuk/argcomplete#global-completion).
|
337
|
+
|
338
|
+
## License
|
339
|
+
|
340
|
+
Copyright (C) 2024 Mark Blakeney. This program is distributed under the
|
341
|
+
terms of the GNU General Public License. This program is free software:
|
342
|
+
you can redistribute it and/or modify it under the terms of the GNU
|
343
|
+
General Public License as published by the Free Software Foundation,
|
344
|
+
either version 3 of the License, or any later version. This program is
|
345
|
+
distributed in the hope that it will be useful, but WITHOUT ANY
|
346
|
+
WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
347
|
+
FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License at
|
348
|
+
<http://www.gnu.org/licenses/> for more details.
|
349
|
+
|
350
|
+
[pystand]: https://github.com/bulletmark/pystand
|
351
|
+
[pbs]: https://github.com/indygreg/python-build-standalone
|
352
|
+
[pbs-rel]: https://github.com/indygreg/python-build-standalone/releases
|
353
|
+
[pipx]: https://github.com/pypa/pipx
|
354
|
+
[pipxu]: https://github.com/bulletmark/pipxu
|
355
|
+
[pyenv]: https://github.com/pyenv/pyenv
|
356
|
+
[pdm]: https://pdm-project.org/
|
357
|
+
[pdmpy]: https://pdm-project.org/en/latest/usage/project/#install-python-interpreters-with-pdm
|
358
|
+
[hatch]: https://hatch.pypa.io/
|
359
|
+
[hatchpy]: https://hatch.pypa.io/latest/tutorials/python/manage/
|
360
|
+
|
361
|
+
<!-- vim: se ai syn=markdown: -->
|
@@ -0,0 +1,6 @@
|
|
1
|
+
pystand.py,sha256=fWBjn-KMOGv-oR3Q2XYfaKE5ZtHUDXRq2Kx__mnd6Es,23997
|
2
|
+
pystand-1.0.dist-info/METADATA,sha256=_GdO43TfIj8kSEHfU2suk-xq0Dh07rKsa29Z6vIfnhA,13674
|
3
|
+
pystand-1.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
4
|
+
pystand-1.0.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
|
5
|
+
pystand-1.0.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
|
6
|
+
pystand-1.0.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
pystand
|
pystand.py
ADDED
@@ -0,0 +1,659 @@
|
|
1
|
+
#!/usr/bin/python3
|
2
|
+
# PYTHON_ARGCOMPLETE_OK
|
3
|
+
'''
|
4
|
+
Command line tool to install pre-built Python versions from the
|
5
|
+
python-build-standalone project.
|
6
|
+
'''
|
7
|
+
from __future__ import annotations
|
8
|
+
|
9
|
+
import json
|
10
|
+
import os
|
11
|
+
import platform
|
12
|
+
import re
|
13
|
+
import shlex
|
14
|
+
import shutil
|
15
|
+
import sys
|
16
|
+
import time
|
17
|
+
import urllib.request
|
18
|
+
from argparse import ArgumentParser, Namespace
|
19
|
+
from collections import defaultdict
|
20
|
+
from pathlib import Path
|
21
|
+
from typing import Iterable, Iterator, Optional
|
22
|
+
|
23
|
+
import argcomplete
|
24
|
+
import platformdirs
|
25
|
+
from packaging.version import Version
|
26
|
+
|
27
|
+
REPO_OWNER = 'indygreg'
|
28
|
+
REPO = 'python-build-standalone'
|
29
|
+
GITHUB_REPO = f'{REPO_OWNER}/{REPO}'
|
30
|
+
LATEST_RELEASE_URL = f'https://raw.githubusercontent.com/{GITHUB_REPO}'\
|
31
|
+
'/latest-release/latest-release.json'
|
32
|
+
|
33
|
+
PROG = Path(__file__).stem
|
34
|
+
CNFFILE = platformdirs.user_config_path(f'{PROG}-flags.conf')
|
35
|
+
|
36
|
+
# Default distributions for various platforms
|
37
|
+
DISTRIBUTIONS = {
|
38
|
+
('Linux', 'x86_64'): 'x86_64-unknown-linux-gnu',
|
39
|
+
('Linux', 'aarch64'): 'aarch64-unknown-linux-gnu',
|
40
|
+
('Linux', 'armv7l'): 'armv7-unknown-linux-gnueabihf',
|
41
|
+
('Linux', 'armv8l'): 'armv7-unknown-linux-gnueabihf',
|
42
|
+
('Darwin', 'x86_64'): 'x86_64-apple-darwin',
|
43
|
+
('Darwin', 'aarch64'): 'aarch64-apple-darwin',
|
44
|
+
('Windows', 'x86_64'): 'x86_64-pc-windows-msvc',
|
45
|
+
('Windows', 'i686'): 'i686-pc-windows-msvc',
|
46
|
+
}
|
47
|
+
|
48
|
+
def is_admin() -> bool:
|
49
|
+
'Check if we are running as root'
|
50
|
+
if platform.system() == 'Windows':
|
51
|
+
import ctypes
|
52
|
+
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore
|
53
|
+
|
54
|
+
return os.geteuid() == 0
|
55
|
+
|
56
|
+
def get_version() -> str:
|
57
|
+
'Return the version of this package'
|
58
|
+
from importlib.metadata import version
|
59
|
+
try:
|
60
|
+
ver = version(PROG)
|
61
|
+
except Exception:
|
62
|
+
ver = 'unknown'
|
63
|
+
|
64
|
+
return ver
|
65
|
+
|
66
|
+
def fmt(version, release) -> str:
|
67
|
+
'Return a formatted release version string'
|
68
|
+
return f'{version} @ {release}'
|
69
|
+
|
70
|
+
def get_json(file: Path) -> dict:
|
71
|
+
'Get JSON data from given file'
|
72
|
+
try:
|
73
|
+
with file.open() as fp:
|
74
|
+
return json.load(fp)
|
75
|
+
except Exception:
|
76
|
+
pass
|
77
|
+
|
78
|
+
return {}
|
79
|
+
|
80
|
+
def set_json(file: Path, data: dict) -> Optional[str]:
|
81
|
+
'Set JSON data to given file'
|
82
|
+
try:
|
83
|
+
with file.open('w') as fp:
|
84
|
+
json.dump(data, fp, indent=2)
|
85
|
+
except Exception as e:
|
86
|
+
return str(e)
|
87
|
+
|
88
|
+
return None
|
89
|
+
|
90
|
+
# The gh handle is an opaque github instance handle
|
91
|
+
get_gh_handle = None
|
92
|
+
|
93
|
+
def get_gh(args: Namespace):
|
94
|
+
'Return a GitHub handle'
|
95
|
+
# The gh handle is a global to lazily create it only if/when needed
|
96
|
+
global get_gh_handle
|
97
|
+
if get_gh_handle:
|
98
|
+
return get_gh_handle
|
99
|
+
|
100
|
+
from github import Github
|
101
|
+
get_gh_handle = Github() # type: ignore
|
102
|
+
return get_gh_handle
|
103
|
+
|
104
|
+
def rm_path(path: Path) -> None:
|
105
|
+
'Remove the given path'
|
106
|
+
if path.is_symlink():
|
107
|
+
path.unlink()
|
108
|
+
elif path.is_dir():
|
109
|
+
shutil.rmtree(path)
|
110
|
+
elif path.exists():
|
111
|
+
path.unlink()
|
112
|
+
|
113
|
+
class VersionMatcher:
|
114
|
+
'Match a version string to a list of versions'
|
115
|
+
def __init__(self, seq: Iterable[str]) -> None:
|
116
|
+
self.seq = sorted(seq, key=Version, reverse=True)
|
117
|
+
|
118
|
+
def match(self, version: str, *,
|
119
|
+
upconvert_minor: bool = False) -> Optional[str]:
|
120
|
+
'Return full version string given a [possibly] part version prefix'
|
121
|
+
if version in self.seq:
|
122
|
+
return version
|
123
|
+
|
124
|
+
if upconvert_minor:
|
125
|
+
version = version.rsplit('.', 1)[0]
|
126
|
+
|
127
|
+
if not version.endswith('.'):
|
128
|
+
version += '.'
|
129
|
+
|
130
|
+
for full_version in self.seq:
|
131
|
+
if full_version.startswith(version):
|
132
|
+
return full_version
|
133
|
+
|
134
|
+
return None
|
135
|
+
|
136
|
+
def iter_versions(args: Namespace) -> Iterator[Path]:
|
137
|
+
'Iterate over all version dirs'
|
138
|
+
for f in args._versions.iterdir():
|
139
|
+
if f.is_dir() and not f.is_symlink() and not f.name.startswith('.'):
|
140
|
+
yield f
|
141
|
+
|
142
|
+
def get_version_names(args: Namespace) -> list[str]:
|
143
|
+
'Return a list of validated version names based on command line args'
|
144
|
+
if args.all:
|
145
|
+
if not args.skip and args.version:
|
146
|
+
args.parser.error('Can not specify versions with '
|
147
|
+
'--all unless also specifying --skip.')
|
148
|
+
else:
|
149
|
+
if args.skip:
|
150
|
+
args.parser.error('--skip can only be specified with --all.')
|
151
|
+
|
152
|
+
if not args.version:
|
153
|
+
args.parser.error('Must specify at least one version, or --all.')
|
154
|
+
|
155
|
+
all_names = set(f.name for f in iter_versions(args))
|
156
|
+
|
157
|
+
# Upconvert all user specified partial version names to full version names
|
158
|
+
matcher = VersionMatcher(all_names)
|
159
|
+
versions = [(matcher.match(v) or v) for v in args.version]
|
160
|
+
|
161
|
+
given = set(versions)
|
162
|
+
|
163
|
+
if (unknown := given - all_names):
|
164
|
+
s = 's' if len(unknown) > 1 else ''
|
165
|
+
unknowns = [f'"{u}"' for u in unknown]
|
166
|
+
sys.exit(f'Error: version{s} {", ".join(unknowns)} not found.')
|
167
|
+
|
168
|
+
return sorted(all_names - given, key=Version) if args.all else versions
|
169
|
+
|
170
|
+
def get_latest_release_tag(args: Namespace) -> str:
|
171
|
+
'Return the latest release tag'
|
172
|
+
if args._latest_release.exists():
|
173
|
+
stat = args._latest_release.stat()
|
174
|
+
if time.time() < (stat.st_mtime + int(args.cache_minutes * 60)):
|
175
|
+
return args._latest_release.read_text().strip()
|
176
|
+
|
177
|
+
# Note this simple URL fetch is much faster than using the GitHub
|
178
|
+
# API, and has no rate-limits, so we use it to get the latest
|
179
|
+
# release tag.
|
180
|
+
try:
|
181
|
+
with urllib.request.urlopen(LATEST_RELEASE_URL) as url:
|
182
|
+
data = json.load(url)
|
183
|
+
except Exception:
|
184
|
+
sys.exit('Failed to fetch latest release tag.')
|
185
|
+
|
186
|
+
tag = data.get('tag')
|
187
|
+
|
188
|
+
if not tag:
|
189
|
+
sys.exit('Latest release tag timestamp file is corrupted.')
|
190
|
+
|
191
|
+
args._latest_release.write_text(tag + '\n')
|
192
|
+
return tag
|
193
|
+
|
194
|
+
def get_release_files(args, tag, implementation: str = None) -> dict:
|
195
|
+
'Return the release files for the given tag'
|
196
|
+
# Look for tag data in our release cache
|
197
|
+
jfile = args._releases / tag
|
198
|
+
if not (files := get_json(jfile)):
|
199
|
+
# Not in cache so fetch it (and also store in cache)
|
200
|
+
gh = get_gh(args)
|
201
|
+
try:
|
202
|
+
release = gh.get_repo(GITHUB_REPO).get_release(tag)
|
203
|
+
except Exception:
|
204
|
+
return {}
|
205
|
+
|
206
|
+
# Iterate over the release assets and store the files in a dict to
|
207
|
+
# return
|
208
|
+
end = '-install_only.tar.gz'
|
209
|
+
for file in release.get_assets():
|
210
|
+
name = file.name
|
211
|
+
if not name.endswith(end):
|
212
|
+
continue
|
213
|
+
|
214
|
+
name = name[:-len(end)]
|
215
|
+
impl_ver, rest = name.split('+', maxsplit=1)
|
216
|
+
impl, ver = impl_ver.split('-', maxsplit=1)
|
217
|
+
rest = rest.split('-', maxsplit=1)[1]
|
218
|
+
|
219
|
+
if impl not in files:
|
220
|
+
files[impl] = defaultdict(dict)
|
221
|
+
|
222
|
+
files[impl][ver][rest] = file.browser_download_url
|
223
|
+
|
224
|
+
if error := set_json(jfile, files):
|
225
|
+
sys.exit(f'Failed to write release {tag} file {jfile}: {error}')
|
226
|
+
|
227
|
+
return files.get(implementation, {}) if implementation else files
|
228
|
+
|
229
|
+
def purge_unused_releases(args: Namespace) -> None:
|
230
|
+
'Purge old releases that are no longer needed and have expired'
|
231
|
+
releases = set(f.name for f in args._releases.iterdir())
|
232
|
+
keep = set()
|
233
|
+
if args._latest_release.exists():
|
234
|
+
keep.add(args._latest_release.read_text().strip())
|
235
|
+
|
236
|
+
for version in iter_versions(args):
|
237
|
+
if (release := get_json(version / args._data).get('release')):
|
238
|
+
keep.add(release)
|
239
|
+
|
240
|
+
for release in releases - keep:
|
241
|
+
rdir = args._releases / release
|
242
|
+
stat = rdir.stat()
|
243
|
+
if time.time() > (stat.st_mtime + args.purge_days * 86400):
|
244
|
+
rdir.unlink()
|
245
|
+
|
246
|
+
class COMMAND:
|
247
|
+
'Base class for all commands'
|
248
|
+
commands = []
|
249
|
+
|
250
|
+
@classmethod
|
251
|
+
def add(cls, parent) -> None:
|
252
|
+
'Append parent command to internal list'
|
253
|
+
cls.commands.append(parent)
|
254
|
+
|
255
|
+
def get_title(desc: str) -> str:
|
256
|
+
'Return single title line from description'
|
257
|
+
res = []
|
258
|
+
for line in desc.splitlines():
|
259
|
+
line = line.strip()
|
260
|
+
res.append(line)
|
261
|
+
if line.endswith('.'):
|
262
|
+
return ' '. join(res)
|
263
|
+
|
264
|
+
sys.exit('Must end description with a full stop.')
|
265
|
+
|
266
|
+
def remove(args: Namespace, version: str) -> None:
|
267
|
+
'Remove a version'
|
268
|
+
vdir = args._versions / version
|
269
|
+
if not vdir.exists():
|
270
|
+
return
|
271
|
+
|
272
|
+
# Touch the associated release file to ensure it lives until the
|
273
|
+
# full purge time has expired if this was the last version using it
|
274
|
+
if release := get_json(vdir / args._data).get('release'):
|
275
|
+
(args._releases / release).touch()
|
276
|
+
|
277
|
+
shutil.rmtree(vdir)
|
278
|
+
|
279
|
+
def install(args: Namespace, vdir: Path, release: str, distribution: str,
|
280
|
+
files: dict) -> Optional[str]:
|
281
|
+
'Install a version'
|
282
|
+
version = vdir.name
|
283
|
+
|
284
|
+
if not (file := files[version].get(distribution)):
|
285
|
+
return f'Arch "{distribution}" not found for release '\
|
286
|
+
f'{release} version {version}.'
|
287
|
+
|
288
|
+
tmpdir = args._versions / f'.{version}-tmp'
|
289
|
+
rm_path(tmpdir)
|
290
|
+
tmpdir.mkdir()
|
291
|
+
tmpdir_py = tmpdir / 'python'
|
292
|
+
error = None
|
293
|
+
|
294
|
+
try:
|
295
|
+
urllib.request.urlretrieve(file, tmpdir / 'tmp.tar.gz')
|
296
|
+
shutil.unpack_archive(tmpdir / 'tmp.tar.gz', tmpdir)
|
297
|
+
except Exception as e:
|
298
|
+
error = f'Failed to fetch "{version}": {e}'
|
299
|
+
|
300
|
+
if not error:
|
301
|
+
data = {'release': release, 'distribution': distribution}
|
302
|
+
if (error := set_json(tmpdir_py / args._data, data)):
|
303
|
+
error = f'Failed to write {version} data file: {error}'
|
304
|
+
|
305
|
+
if not error:
|
306
|
+
remove(args, version)
|
307
|
+
tmpdir_py.replace(vdir)
|
308
|
+
|
309
|
+
shutil.rmtree(tmpdir)
|
310
|
+
return error
|
311
|
+
|
312
|
+
def main() -> Optional[str]:
|
313
|
+
'Main code'
|
314
|
+
distro_default = DISTRIBUTIONS.get((platform.system(), platform.machine()))
|
315
|
+
distro_help = distro_default if distro_default else '?unknown?'
|
316
|
+
|
317
|
+
base_dir = Path('/opt' if is_admin() else
|
318
|
+
platformdirs.user_data_dir()) / PROG
|
319
|
+
|
320
|
+
# Parse arguments
|
321
|
+
opt = ArgumentParser(description=__doc__,
|
322
|
+
epilog='Note you can set default starting global options '
|
323
|
+
f'in {CNFFILE}.')
|
324
|
+
|
325
|
+
# Set up main/global arguments
|
326
|
+
opt.add_argument('-D', '--distribution',
|
327
|
+
help=f'{REPO} "*-install_only" '
|
328
|
+
'distribution, e.g. "x86_64-unknown-linux-gnu". '
|
329
|
+
f'Default is auto-detected (detected as "{distro_help}" '
|
330
|
+
'for this current host).')
|
331
|
+
opt.add_argument('-B', '--base-dir', default=str(base_dir),
|
332
|
+
help=f'specify {PROG} base dir for storing '
|
333
|
+
'versions and metadata. Default is "%(default)s"')
|
334
|
+
opt.add_argument('-C', '--cache-minutes', default=60, type=float,
|
335
|
+
help='cache latest release tag fetch for this many '
|
336
|
+
'minutes, before rechecking for latest. '
|
337
|
+
'Default is %(default)d minutes')
|
338
|
+
opt.add_argument('--purge-days', default=30, type=int,
|
339
|
+
help='cache release file lists for this number '
|
340
|
+
'of days after last version referencing it is removed. '
|
341
|
+
'Default is %(default)d days')
|
342
|
+
opt.add_argument('-V', action='store_true',
|
343
|
+
help=f'show {PROG} version')
|
344
|
+
cmd = opt.add_subparsers(title='Commands', dest='cmdname')
|
345
|
+
|
346
|
+
# Add each command ..
|
347
|
+
for cls in COMMAND.commands:
|
348
|
+
name = cls.__name__[1:]
|
349
|
+
|
350
|
+
if hasattr(cls, 'doc'):
|
351
|
+
desc = cls.doc.strip()
|
352
|
+
elif cls.__doc__:
|
353
|
+
desc = cls.__doc__.strip()
|
354
|
+
else:
|
355
|
+
return f'Must define a docstring for command class "{name}".'
|
356
|
+
|
357
|
+
title = get_title(desc)
|
358
|
+
cmdopt = cmd.add_parser(name, description=desc, help=title)
|
359
|
+
|
360
|
+
# Set up this commands own arguments, if it has any
|
361
|
+
if hasattr(cls, 'init'):
|
362
|
+
cls.init(cmdopt)
|
363
|
+
|
364
|
+
# Set the function to call
|
365
|
+
cmdopt.set_defaults(func=cls.run, name=name, parser=cmdopt)
|
366
|
+
|
367
|
+
# Command arguments are now defined, so we can set up argcomplete
|
368
|
+
argcomplete.autocomplete(opt)
|
369
|
+
|
370
|
+
# Merge in default args from user config file. Then parse the
|
371
|
+
# command line.
|
372
|
+
cnffile = CNFFILE.expanduser()
|
373
|
+
if cnffile.is_file():
|
374
|
+
with cnffile.open() as fp:
|
375
|
+
lines = [re.sub(r'#.*$', '', line).strip() for line in fp]
|
376
|
+
cnflines = ' '.join(lines).strip()
|
377
|
+
else:
|
378
|
+
cnflines = ''
|
379
|
+
|
380
|
+
args = opt.parse_args(shlex.split(cnflines) + sys.argv[1:])
|
381
|
+
|
382
|
+
if args.V:
|
383
|
+
print(get_version())
|
384
|
+
|
385
|
+
if 'func' not in args:
|
386
|
+
if not args.V:
|
387
|
+
opt.print_help()
|
388
|
+
return None
|
389
|
+
|
390
|
+
distribution = args.distribution or distro_default
|
391
|
+
if not distribution:
|
392
|
+
sys.exit('Unknown system + machine distribution. Please specify '
|
393
|
+
'using -D/--distribution option.')
|
394
|
+
|
395
|
+
# Keep some useful info in the namespace passed to the command
|
396
|
+
base_dir = Path(args.base_dir).expanduser()
|
397
|
+
|
398
|
+
args._distribution = distribution
|
399
|
+
args._data = f'{PROG}.json'
|
400
|
+
args._latest_release = base_dir / 'latest_release'
|
401
|
+
args._latest_release.parent.mkdir(parents=True, exist_ok=True)
|
402
|
+
args._versions = base_dir / 'versions'
|
403
|
+
args._versions.mkdir(parents=True, exist_ok=True)
|
404
|
+
args._releases = base_dir / 'releases'
|
405
|
+
args._releases.mkdir(parents=True, exist_ok=True)
|
406
|
+
|
407
|
+
result = args.func(args)
|
408
|
+
purge_unused_releases(args)
|
409
|
+
return result
|
410
|
+
|
411
|
+
@COMMAND.add
|
412
|
+
class _install(COMMAND):
|
413
|
+
doc = f'Install one or more versions from a {REPO} release.'
|
414
|
+
|
415
|
+
@staticmethod
|
416
|
+
def init(parser: ArgumentParser) -> None:
|
417
|
+
parser.add_argument('-r', '--release',
|
418
|
+
help=f'install from specified {REPO} '
|
419
|
+
'release (e.g. 20240415), '
|
420
|
+
'default is latest release')
|
421
|
+
parser.add_argument('-f', '--force', action='store_true',
|
422
|
+
help='force install even if already installed')
|
423
|
+
parser.add_argument('version', nargs='+',
|
424
|
+
help='version to install. E.g. 3.12 or 3.12.3')
|
425
|
+
|
426
|
+
@staticmethod
|
427
|
+
def run(args: Namespace) -> Optional[str]:
|
428
|
+
release = args.release or get_latest_release_tag(args)
|
429
|
+
files = get_release_files(args, release, 'cpython')
|
430
|
+
if not files:
|
431
|
+
return f'Release "{release}" not found.'
|
432
|
+
|
433
|
+
matcher = VersionMatcher(files)
|
434
|
+
for version in args.version:
|
435
|
+
full_version = matcher.match(version)
|
436
|
+
if not full_version:
|
437
|
+
return f'Version {fmt(version, release)} not found.'
|
438
|
+
|
439
|
+
version = full_version
|
440
|
+
vdir = args._versions / version
|
441
|
+
|
442
|
+
if vdir.exists() and not args.force:
|
443
|
+
return f'Version "{version}" is already installed.'
|
444
|
+
|
445
|
+
if error := install(args, vdir, release, args._distribution, files):
|
446
|
+
return error
|
447
|
+
|
448
|
+
print(f'Version {fmt(version, release)} installed.')
|
449
|
+
|
450
|
+
@COMMAND.add
|
451
|
+
class _update(COMMAND):
|
452
|
+
'Update one, more, or all versions to another release.'
|
453
|
+
@staticmethod
|
454
|
+
def init(parser: ArgumentParser) -> None:
|
455
|
+
parser.add_argument('-r', '--release',
|
456
|
+
help='update to specified release (e.g. 20240415), '
|
457
|
+
'default is latest release')
|
458
|
+
parser.add_argument('-a', '--all', action='store_true',
|
459
|
+
help='update ALL versions')
|
460
|
+
parser.add_argument('--skip', action='store_true',
|
461
|
+
help='skip the specified versions when '
|
462
|
+
'updating all (only can be specified with --all)')
|
463
|
+
parser.add_argument('version', nargs='*',
|
464
|
+
help='version to update (or to skip for '
|
465
|
+
'--all --skip)')
|
466
|
+
|
467
|
+
@staticmethod
|
468
|
+
def run(args: Namespace) -> Optional[str]:
|
469
|
+
release_target = args.release or get_latest_release_tag(args)
|
470
|
+
files = get_release_files(args, release_target, 'cpython')
|
471
|
+
if not files:
|
472
|
+
return f'Release "{release_target}" not found.'
|
473
|
+
|
474
|
+
matcher = VersionMatcher(files)
|
475
|
+
for version in get_version_names(args):
|
476
|
+
if not (data := get_json(args._versions / version / args._data)):
|
477
|
+
continue
|
478
|
+
|
479
|
+
release = data.get('release')
|
480
|
+
if release == release_target:
|
481
|
+
continue
|
482
|
+
|
483
|
+
nextver = matcher.match(version, upconvert_minor=True)
|
484
|
+
new_vdir = args._versions / nextver
|
485
|
+
if nextver != version and new_vdir.exists():
|
486
|
+
continue
|
487
|
+
|
488
|
+
distribution = data.get('distribution')
|
489
|
+
if not distribution or distribution not in files.get(nextver, {}):
|
490
|
+
continue
|
491
|
+
|
492
|
+
print(f'{fmt(version, release)} updating to '
|
493
|
+
f'{fmt(nextver, release_target)} '
|
494
|
+
f'distribution="{distribution}" ..')
|
495
|
+
|
496
|
+
if error := install(args, new_vdir, release_target, distribution,
|
497
|
+
files):
|
498
|
+
return error
|
499
|
+
|
500
|
+
if nextver != version:
|
501
|
+
remove(args, version)
|
502
|
+
|
503
|
+
@COMMAND.add
|
504
|
+
class _remove(COMMAND):
|
505
|
+
'Remove/uninstall one, more, or all versions.'
|
506
|
+
@staticmethod
|
507
|
+
def init(parser: ArgumentParser) -> None:
|
508
|
+
parser.add_argument('-a', '--all', action='store_true',
|
509
|
+
help='remove ALL versions')
|
510
|
+
parser.add_argument('--skip', action='store_true',
|
511
|
+
help='skip the specified versions when '
|
512
|
+
'removing all (only can be specified with --all)')
|
513
|
+
parser.add_argument('-r', '--release',
|
514
|
+
help='only remove versions if from '
|
515
|
+
'specified release (e.g. 20240415)')
|
516
|
+
parser.add_argument('version', nargs='*',
|
517
|
+
help='version to remove (or to skip for '
|
518
|
+
'--all --skip)')
|
519
|
+
|
520
|
+
@staticmethod
|
521
|
+
def run(args: Namespace) -> Optional[str]:
|
522
|
+
for version in get_version_names(args):
|
523
|
+
dfile = args._versions / version / args._data
|
524
|
+
release = get_json(dfile).get('release') or '?'
|
525
|
+
if not args.release or release == args.release:
|
526
|
+
remove(args, version)
|
527
|
+
print(f'Version {fmt(version, release)} removed.')
|
528
|
+
|
529
|
+
@COMMAND.add
|
530
|
+
class _list(COMMAND):
|
531
|
+
'List installed versions and show which have an update available.'
|
532
|
+
@staticmethod
|
533
|
+
def init(parser: ArgumentParser) -> None:
|
534
|
+
parser.add_argument('-v', '--verbose', action='store_true',
|
535
|
+
help='explicitly report why a version is '
|
536
|
+
'not eligible for update')
|
537
|
+
parser.add_argument('-r', '--release',
|
538
|
+
help='use specified release (e.g. 20240415) for '
|
539
|
+
'verbose compare, default is latest release')
|
540
|
+
parser.add_argument('version', nargs='*',
|
541
|
+
help='only list specified version, else all')
|
542
|
+
|
543
|
+
@staticmethod
|
544
|
+
def run(args: Namespace) -> Optional[str]:
|
545
|
+
release_target = args.release or get_latest_release_tag(args)
|
546
|
+
files = get_release_files(args, release_target, 'cpython')
|
547
|
+
if not files:
|
548
|
+
return f'Release "{release_target}" not found.'
|
549
|
+
|
550
|
+
matcher = VersionMatcher(files)
|
551
|
+
args.all = not args.version
|
552
|
+
args.skip = False
|
553
|
+
for version in get_version_names(args):
|
554
|
+
vdir = args._versions / version
|
555
|
+
if not (data := get_json(vdir / args._data)):
|
556
|
+
continue
|
557
|
+
|
558
|
+
release = data.get('release')
|
559
|
+
distribution = data.get('distribution')
|
560
|
+
upd = ''
|
561
|
+
app = ''
|
562
|
+
if release_target and release != release_target:
|
563
|
+
nextver = matcher.match(version, upconvert_minor=True)
|
564
|
+
new_vdir = args._versions / nextver
|
565
|
+
if nextver != version and new_vdir.exists():
|
566
|
+
if args.verbose:
|
567
|
+
nrelease = get_json(
|
568
|
+
new_vdir / args._data).get('release', '?')
|
569
|
+
app = f' not eligible for '\
|
570
|
+
f'update because {fmt(nextver, nrelease)} '\
|
571
|
+
'is already installed.'
|
572
|
+
else:
|
573
|
+
# May not be updatable if newer release does not support
|
574
|
+
# this same distribution anymore
|
575
|
+
if nextver and distribution in files.get(nextver, {}):
|
576
|
+
upd = f' updatable to {fmt(nextver, release_target)}'
|
577
|
+
elif args.verbose:
|
578
|
+
app = f' not eligible for update because '\
|
579
|
+
f'{fmt(nextver, release_target)} does '\
|
580
|
+
f'not provide distribution="{distribution}".'
|
581
|
+
|
582
|
+
print(f'{fmt(version, release)}{upd} '
|
583
|
+
f'distribution="{distribution}"{app}')
|
584
|
+
|
585
|
+
@COMMAND.add
|
586
|
+
class _show(COMMAND):
|
587
|
+
'Show versions available from a release.'
|
588
|
+
@staticmethod
|
589
|
+
def init(parser: ArgumentParser) -> None:
|
590
|
+
parser.add_argument('-d', '--distributions', action='store_true',
|
591
|
+
help='also show all available distributions for '
|
592
|
+
'each version from the release')
|
593
|
+
parser.add_argument('release', nargs='?',
|
594
|
+
help=f'{REPO} release to show (e.g. 20240415), '
|
595
|
+
'default is latest release')
|
596
|
+
|
597
|
+
@staticmethod
|
598
|
+
def run(args: Namespace) -> None:
|
599
|
+
release = args.release or get_latest_release_tag(args)
|
600
|
+
files = get_release_files(args, release, 'cpython')
|
601
|
+
if not files:
|
602
|
+
sys.exit(f'Error: release "{release}" not found.')
|
603
|
+
|
604
|
+
installed = {}
|
605
|
+
for vdir in iter_versions(args):
|
606
|
+
data = get_json(vdir / args._data)
|
607
|
+
if data.get('release') == release and \
|
608
|
+
(distro := data.get('distribution')):
|
609
|
+
installed[vdir.name] = distro
|
610
|
+
|
611
|
+
installable = False
|
612
|
+
for version in sorted(files, key=Version):
|
613
|
+
installed_distribution = installed.get(version)
|
614
|
+
for distribution in files[version]:
|
615
|
+
app = ' (installed)' \
|
616
|
+
if distribution == installed_distribution else ''
|
617
|
+
if args.distributions or app \
|
618
|
+
or distribution == args._distribution:
|
619
|
+
if distribution == args._distribution:
|
620
|
+
installable = True
|
621
|
+
|
622
|
+
print(f'{fmt(version, release)} '
|
623
|
+
f'distribution="{distribution}"{app}')
|
624
|
+
if not installable:
|
625
|
+
print(f'Warning: no distribution="{args._distribution}" '
|
626
|
+
'versions found in ' f'release "{release}".')
|
627
|
+
|
628
|
+
@COMMAND.add
|
629
|
+
class _path(COMMAND):
|
630
|
+
'Show path prefix to installed version base directory.'
|
631
|
+
@staticmethod
|
632
|
+
def init(parser: ArgumentParser) -> None:
|
633
|
+
parser.add_argument('-p', '--python-path', action='store_true',
|
634
|
+
help='return full path to python executable')
|
635
|
+
parser.add_argument('version', help='version to return path for')
|
636
|
+
|
637
|
+
@staticmethod
|
638
|
+
def run(args: Namespace) -> Optional[str]:
|
639
|
+
matcher = VersionMatcher([f.name for f in iter_versions(args)])
|
640
|
+
version = matcher.match(args.version) or args.version
|
641
|
+
path = args._versions / version
|
642
|
+
if not path.is_dir():
|
643
|
+
return f'Version "{version}" is not installed.'
|
644
|
+
|
645
|
+
if args.python_path:
|
646
|
+
subpath = path / 'bin' / 'python'
|
647
|
+
if subpath.exists():
|
648
|
+
print(subpath)
|
649
|
+
else:
|
650
|
+
subpath = path / 'python.exe'
|
651
|
+
if subpath.exists():
|
652
|
+
print(subpath)
|
653
|
+
else:
|
654
|
+
return f'Error: Can not find python executable in "{path}"'
|
655
|
+
else:
|
656
|
+
print(path)
|
657
|
+
|
658
|
+
if __name__ == '__main__':
|
659
|
+
sys.exit(main())
|