Skip to content

Commit 9a26a74

Browse files
committed
Implement Python management
1 parent 7dac985 commit 9a26a74

32 files changed

Lines changed: 1608 additions & 46 deletions

docs/config/hatch.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,14 @@ Any type of environment that is not explicitly defined will default to `<DATA_DI
205205
python = "..."
206206
```
207207

208-
This determines where to install specific versions of Python, with the full path being `<VALUE>/pythons`.
208+
This determines where to install specific versions of Python.
209209

210-
The following values have special meanings.
210+
The following values have special meanings:
211211

212212
| Value | Path |
213213
| --- | --- |
214-
| `isolated` (default) | `<DATA_DIR>/pythons` |
215-
| `shared` | `~/.pythons` |
214+
| `shared` (default) | `~/.pythons` |
215+
| `isolated` | `<DATA_DIR>/pythons` |
216216

217217
## Terminal
218218

hatch.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,20 @@ HATCH_BUILD_CLEAN = "true"
123123
build = "python -m build backend"
124124
publish = "hatch publish backend/dist"
125125
version = "cd backend && hatch version {args}"
126-
update-data = [
126+
127+
[envs.upkeep]
128+
detached = true
129+
dependencies = [
130+
"httpx",
131+
]
132+
[envs.upkeep.scripts]
133+
update-hatch = [
134+
"update-distributions",
135+
]
136+
update-hatchling = [
127137
"update-licenses",
128138
]
139+
update-distributions = "python scripts/update_distributions.py"
129140
update-licenses = "python backend/scripts/update_licenses.py"
130141

131142
[envs.release]

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ dependencies = [
5151
"tomlkit>=0.11.1",
5252
"userpath~=1.7",
5353
"virtualenv>=20.16.2",
54+
"zstandard<1",
5455
]
5556
dynamic = ["version"]
5657

scripts/update_distributions.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from ast import literal_eval
5+
from collections import defaultdict
6+
7+
import httpx
8+
from utils import ROOT
9+
10+
URL = 'https://raw.githubusercontent.com/ofek/pyapp/master/build.rs'
11+
OUTPUT_FILE = ROOT / 'src' / 'hatch' / 'python' / 'distributions.py'
12+
ARCHES = {('linux', 'x86'): 'i686', ('windows', 'x86_64'): 'amd64', ('windows', 'x86'): 'i386'}
13+
14+
# system, architecture, ABI, variant
15+
MAX_IDENTIFIER_COMPONENTS = 4
16+
17+
18+
def parse_distributions(contents: str, constant: str):
19+
match = re.search(f'^const {constant}.+?^];$', contents, flags=re.DOTALL | re.MULTILINE)
20+
if not match:
21+
message = f'Could not find {constant} in {URL}'
22+
raise ValueError(message)
23+
24+
block = match.group(0).replace('",\n', '",')
25+
for line in block.splitlines()[1:-1]:
26+
line = line.strip()
27+
if not line or line.startswith('//'):
28+
continue
29+
30+
identifier, *data, source = literal_eval(line[:-1])
31+
os, arch = data[:2]
32+
if arch == 'powerpc64':
33+
arch = 'ppc64le'
34+
35+
# Force everything to have a variant to maintain structure
36+
if len(data) != MAX_IDENTIFIER_COMPONENTS:
37+
data.append('')
38+
39+
data[1] = ARCHES.get((os, arch), arch)
40+
yield identifier, tuple(data), source
41+
42+
43+
def main():
44+
response = httpx.get(URL)
45+
response.raise_for_status()
46+
47+
contents = response.text
48+
distributions = defaultdict(list)
49+
ordering_data = defaultdict(dict)
50+
51+
for i, distribution_type in enumerate(('DEFAULT_CPYTHON_DISTRIBUTIONS', 'DEFAULT_PYPY_DISTRIBUTIONS')):
52+
for identifier, data, source in parse_distributions(contents, distribution_type):
53+
ordering_data[i][identifier] = None
54+
distributions[identifier].append((data, source))
55+
56+
ordered = [identifier for identifiers in ordering_data.values() for identifier in reversed(identifiers)]
57+
output = [
58+
'from __future__ import annotations',
59+
'',
60+
'# fmt: off',
61+
'ORDERED_DISTRIBUTIONS: tuple[str, ...] = (',
62+
]
63+
for identifier in ordered:
64+
output.append(f' {identifier!r},')
65+
output.append(')')
66+
67+
output.append('DISTRIBUTIONS: dict[str, dict[tuple[str, ...], str]] = {')
68+
for identifier, data in distributions.items():
69+
output.append(f' {identifier!r}: {{')
70+
71+
for d, source in data:
72+
output.append(f' {d!r}:')
73+
output.append(f' {source!r},')
74+
75+
output.append(' },')
76+
77+
output.append('}')
78+
output.append('')
79+
output = '\n'.join(output)
80+
81+
with open(OUTPUT_FILE, 'w') as f:
82+
f.write(output)
83+
84+
85+
if __name__ == '__main__':
86+
main()

src/hatch/cli/__init__.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from hatch.cli.new import new
1313
from hatch.cli.project import project
1414
from hatch.cli.publish import publish
15+
from hatch.cli.python import python
1516
from hatch.cli.run import run
1617
from hatch.cli.shell import shell
1718
from hatch.cli.status import status
@@ -22,7 +23,9 @@
2223
from hatch.utils.fs import Path
2324

2425

25-
@click.group(context_settings={'help_option_names': ['-h', '--help']}, invoke_without_command=True)
26+
@click.group(
27+
context_settings={'help_option_names': ['-h', '--help'], 'max_content_width': 120}, invoke_without_command=True
28+
)
2629
@click.option(
2730
'--env',
2831
'-e',
@@ -37,20 +40,6 @@
3740
envvar=ConfigEnvVars.PROJECT,
3841
help='The name of the project to work on [env var: `HATCH_PROJECT`]',
3942
)
40-
@click.option(
41-
'--color/--no-color',
42-
default=None,
43-
help='Whether or not to display colored output (default is auto-detection) [env vars: `FORCE_COLOR`/`NO_COLOR`]',
44-
)
45-
@click.option(
46-
'--interactive/--no-interactive',
47-
envvar=AppEnvVars.INTERACTIVE,
48-
default=None,
49-
help=(
50-
'Whether or not to allow features like prompts and progress bars (default is auto-detection) '
51-
'[env var: `HATCH_INTERACTIVE`]'
52-
),
53-
)
5443
@click.option(
5544
'--verbose',
5645
'-v',
@@ -65,6 +54,20 @@
6554
count=True,
6655
help='Decrease verbosity (can be used additively) [env var: `HATCH_QUIET`]',
6756
)
57+
@click.option(
58+
'--color/--no-color',
59+
default=None,
60+
help='Whether or not to display colored output (default is auto-detection) [env vars: `FORCE_COLOR`/`NO_COLOR`]',
61+
)
62+
@click.option(
63+
'--interactive/--no-interactive',
64+
envvar=AppEnvVars.INTERACTIVE,
65+
default=None,
66+
help=(
67+
'Whether or not to allow features like prompts and progress bars (default is auto-detection) '
68+
'[env var: `HATCH_INTERACTIVE`]'
69+
),
70+
)
6871
@click.option(
6972
'--data-dir',
7073
envvar=ConfigEnvVars.DATA,
@@ -83,7 +86,7 @@
8386
)
8487
@click.version_option(version=__version__, prog_name='Hatch')
8588
@click.pass_context
86-
def hatch(ctx: click.Context, env_name, project, color, interactive, verbose, quiet, data_dir, cache_dir, config_file):
89+
def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interactive, data_dir, cache_dir, config_file):
8790
"""
8891
\b
8992
_ _ _ _
@@ -194,6 +197,7 @@ def hatch(ctx: click.Context, env_name, project, color, interactive, verbose, qu
194197
hatch.add_command(new)
195198
hatch.add_command(project)
196199
hatch.add_command(publish)
200+
hatch.add_command(python)
197201
hatch.add_command(run)
198202
hatch.add_command(shell)
199203
hatch.add_command(status)

src/hatch/cli/application.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import sys
5+
from functools import cached_property
56
from typing import TYPE_CHECKING, cast
67

78
from hatch.cli.terminal import Terminal
@@ -214,6 +215,27 @@ def get_env_directory(self, environment_type):
214215
else:
215216
return self.data_dir / 'env' / environment_type
216217

218+
def get_python_manager(self, directory: str | None = None):
219+
from hatch.python.core import PythonManager
220+
221+
configured_dir = directory or self.config.dirs.python
222+
if configured_dir == 'shared':
223+
return PythonManager(Path.home() / '.pythons')
224+
elif configured_dir == 'isolated':
225+
return PythonManager(self.data_dir / 'pythons')
226+
else:
227+
return PythonManager(Path(configured_dir).expand())
228+
229+
@cached_property
230+
def shell_data(self) -> tuple[str, str]:
231+
import shellingham
232+
233+
try:
234+
return shellingham.detect_shell()
235+
except shellingham.ShellDetectionFailure:
236+
path = self.platform.default_shell
237+
return Path(path).stem, path
238+
217239
def abort(self, text='', code=1, **kwargs):
218240
if text:
219241
self.display_error(text, **kwargs)

src/hatch/cli/python/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import click
2+
3+
from hatch.cli.python.install import install
4+
from hatch.cli.python.remove import remove
5+
from hatch.cli.python.show import show
6+
from hatch.cli.python.update import update
7+
8+
9+
@click.group(short_help='Manage Python installations')
10+
def python():
11+
pass
12+
13+
14+
python.add_command(install)
15+
python.add_command(remove)
16+
python.add_command(show)
17+
python.add_command(update)

src/hatch/cli/python/install.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import click
6+
7+
if TYPE_CHECKING:
8+
from hatch.cli.application import Application
9+
10+
11+
def ensure_path_public(path: str, shells: list[str]) -> None:
12+
import userpath
13+
14+
if userpath.in_current_path(path) or userpath.in_new_path(path, shells):
15+
return
16+
17+
userpath.append(path, shells=shells)
18+
19+
20+
@click.command(short_help='Install Python distributions')
21+
@click.argument('names', required=True, nargs=-1)
22+
@click.option('--private', is_flag=True, help='Do not add distributions to the user PATH')
23+
@click.option('--update', '-u', is_flag=True, help='Update existing installations')
24+
@click.option(
25+
'--dir', '-d', 'directory', help='The directory in which to install distributions, overriding configuration'
26+
)
27+
@click.pass_obj
28+
def install(app: Application, *, names: tuple[str, ...], private: bool, update: bool, directory: str | None):
29+
"""
30+
Install Python distributions.
31+
32+
You may select `all` to install all compatible distributions:
33+
34+
\b
35+
```
36+
hatch python install all
37+
```
38+
"""
39+
from hatch.errors import PythonDistributionResolutionError, PythonDistributionUnknownError
40+
from hatch.python.distributions import ORDERED_DISTRIBUTIONS
41+
from hatch.python.resolve import get_distribution
42+
43+
shells = []
44+
if not private and not app.platform.windows:
45+
shell_name, _ = app.shell_data
46+
shells.append(shell_name)
47+
48+
manager = app.get_python_manager(directory)
49+
installed = manager.get_installed()
50+
selection = ORDERED_DISTRIBUTIONS if 'all' in names else names
51+
unknown = []
52+
compatible = []
53+
incompatible = []
54+
for name in selection:
55+
if name in installed:
56+
compatible.append(name)
57+
continue
58+
59+
try:
60+
get_distribution(name)
61+
except PythonDistributionUnknownError:
62+
unknown.append(name)
63+
except PythonDistributionResolutionError:
64+
incompatible.append(name)
65+
else:
66+
compatible.append(name)
67+
68+
if unknown:
69+
app.abort(f'Unknown distributions: {", ".join(unknown)}')
70+
elif incompatible and (not compatible or 'all' not in names):
71+
app.abort(f'Incompatible distributions: {", ".join(incompatible)}')
72+
73+
for name in compatible:
74+
needs_update = False
75+
if name in installed:
76+
needs_update = installed[name].needs_update()
77+
if not needs_update:
78+
app.display_warning(f'The latest version is already installed: {name}')
79+
continue
80+
elif not (update or app.confirm(f'Update {name}?')):
81+
app.abort(f'Distribution is already installed: {name}')
82+
83+
with app.status(f'{"Updating" if needs_update else "Installing"} {name}'):
84+
dist = manager.install(name)
85+
if not private:
86+
ensure_path_public(str(dist.python_path.parent), shells=shells)
87+
88+
app.display_success(f'{"Updated" if needs_update else "Installed"} {name} @ {dist.path}')

src/hatch/cli/python/remove.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import click
6+
7+
if TYPE_CHECKING:
8+
from hatch.cli.application import Application
9+
10+
11+
@click.command(short_help='Remove Python distributions')
12+
@click.argument('names', required=True, nargs=-1)
13+
@click.option('--dir', '-d', 'directory', help='The directory in which distributions reside')
14+
@click.pass_obj
15+
def remove(app: Application, *, names: tuple[str, ...], directory: str | None):
16+
"""
17+
Remove Python distributions.
18+
19+
You may select `all` to remove all installed distributions:
20+
21+
\b
22+
```
23+
hatch python install all
24+
```
25+
"""
26+
manager = app.get_python_manager(directory)
27+
installed = manager.get_installed()
28+
selection = tuple(installed) if 'all' in names else names
29+
for name in selection:
30+
if name not in installed:
31+
app.display_warning(f'Distribution is not installed: {name}')
32+
continue
33+
34+
dist = installed[name]
35+
with app.status(f'Removing {name}'):
36+
manager.remove(dist)

0 commit comments

Comments
 (0)