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.
@@ -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
+ [![PyPi](https://img.shields.io/pypi/v/pystand)](https://pypi.org/project/pystand/)
19
+ [![AUR](https://img.shields.io/aur/version/pystand)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pystand = pystand:main
@@ -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())