bex-hooks-python 0.1.0__tar.gz
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.
- bex_hooks_python-0.1.0/LICENSE +21 -0
- bex_hooks_python-0.1.0/PKG-INFO +73 -0
- bex_hooks_python-0.1.0/README.md +58 -0
- bex_hooks_python-0.1.0/pyproject.toml +23 -0
- bex_hooks_python-0.1.0/src/bex_hooks/hooks/python/__init__.py +16 -0
- bex_hooks_python-0.1.0/src/bex_hooks/hooks/python/_interface.py +98 -0
- bex_hooks_python-0.1.0/src/bex_hooks/hooks/python/setup.py +363 -0
- bex_hooks_python-0.1.0/src/bex_hooks/hooks/python/utils.py +155 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucino772
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bex-hooks-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: bex-hooks-python
|
|
5
|
+
Author: Lucino772
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: pydantic>=2.11.4
|
|
10
|
+
Requires-Dist: stdlibx-compose>=0.1.0,<1
|
|
11
|
+
Requires-Dist: stdlibx-option>=0.1.0,<1
|
|
12
|
+
Requires-Dist: stdlibx-result>=0.1.0,<1
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# bex-hooks-python
|
|
17
|
+
|
|
18
|
+
Python-related hooks for **bex**. This package provides hooks to create and manage Python virtual environments and their dependencies.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
`bex-hooks-python` is available on PyPI.
|
|
22
|
+
|
|
23
|
+
Add the plugin package to the `requirements` section of your `bex` bootstrap header:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
# /// bootstrap
|
|
27
|
+
# requires-python: ">=3.11,<3.12"
|
|
28
|
+
# requirements: |
|
|
29
|
+
# bex-hooks
|
|
30
|
+
# bex-hooks-python
|
|
31
|
+
# entrypoint: bex_hooks.exec:main
|
|
32
|
+
# ///
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then enable the plugin in your configuration:
|
|
36
|
+
```yaml
|
|
37
|
+
config:
|
|
38
|
+
plugins:
|
|
39
|
+
- bex_hooks.hooks.python
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Hooks
|
|
43
|
+
|
|
44
|
+
### `python/setup-python`
|
|
45
|
+
|
|
46
|
+
Sets up a Python virtual environment for a specified version and synchronizes its dependencies using `uv`. When both `requirements` and `requirements_file` are provided, their contents are merged into a single set of requirements.
|
|
47
|
+
|
|
48
|
+
#### Arguments
|
|
49
|
+
|
|
50
|
+
| Name | Type | Default | Description |
|
|
51
|
+
|---------------------|---------------|:------------:|---------------------------------------------------------------------------------------------------------|
|
|
52
|
+
| `version` | `str` | *(required)* | Python version to provision (e.g. `">=3.11,<3.12"`) |
|
|
53
|
+
| `uv` | `str \| None` | `None` | Version of `uv` to use |
|
|
54
|
+
| `requirements` | `str` | `""` | Inline requirements (e.g. `"requests==2.32.0"`). |
|
|
55
|
+
| `requirements_file` | `list[str]` | `[]` | One or more requirements file paths. |
|
|
56
|
+
| `activate_env` | `bool` | `False` | If `True`, activates the environment for subsequent steps. |
|
|
57
|
+
| `set_python_path` | `bool` | `False` | If `True`, sets `PYTHONPATH` to the virtual environment. |
|
|
58
|
+
| `inexact` | `bool` | `False` | If `True`, tells `uv` not to remove dependencies that are present but not declared in the requirements. |
|
|
59
|
+
|
|
60
|
+
#### Example
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
hooks:
|
|
64
|
+
- id: python/setup-python
|
|
65
|
+
version: ">=3.11,<3.12"
|
|
66
|
+
uv: "0.4.0"
|
|
67
|
+
requirements_file:
|
|
68
|
+
- requirements.txt
|
|
69
|
+
requirements: |
|
|
70
|
+
requests==2.32.0
|
|
71
|
+
activate_env: true
|
|
72
|
+
inexact: true
|
|
73
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# bex-hooks-python
|
|
2
|
+
|
|
3
|
+
Python-related hooks for **bex**. This package provides hooks to create and manage Python virtual environments and their dependencies.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
`bex-hooks-python` is available on PyPI.
|
|
7
|
+
|
|
8
|
+
Add the plugin package to the `requirements` section of your `bex` bootstrap header:
|
|
9
|
+
|
|
10
|
+
```yaml
|
|
11
|
+
# /// bootstrap
|
|
12
|
+
# requires-python: ">=3.11,<3.12"
|
|
13
|
+
# requirements: |
|
|
14
|
+
# bex-hooks
|
|
15
|
+
# bex-hooks-python
|
|
16
|
+
# entrypoint: bex_hooks.exec:main
|
|
17
|
+
# ///
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then enable the plugin in your configuration:
|
|
21
|
+
```yaml
|
|
22
|
+
config:
|
|
23
|
+
plugins:
|
|
24
|
+
- bex_hooks.hooks.python
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Hooks
|
|
28
|
+
|
|
29
|
+
### `python/setup-python`
|
|
30
|
+
|
|
31
|
+
Sets up a Python virtual environment for a specified version and synchronizes its dependencies using `uv`. When both `requirements` and `requirements_file` are provided, their contents are merged into a single set of requirements.
|
|
32
|
+
|
|
33
|
+
#### Arguments
|
|
34
|
+
|
|
35
|
+
| Name | Type | Default | Description |
|
|
36
|
+
|---------------------|---------------|:------------:|---------------------------------------------------------------------------------------------------------|
|
|
37
|
+
| `version` | `str` | *(required)* | Python version to provision (e.g. `">=3.11,<3.12"`) |
|
|
38
|
+
| `uv` | `str \| None` | `None` | Version of `uv` to use |
|
|
39
|
+
| `requirements` | `str` | `""` | Inline requirements (e.g. `"requests==2.32.0"`). |
|
|
40
|
+
| `requirements_file` | `list[str]` | `[]` | One or more requirements file paths. |
|
|
41
|
+
| `activate_env` | `bool` | `False` | If `True`, activates the environment for subsequent steps. |
|
|
42
|
+
| `set_python_path` | `bool` | `False` | If `True`, sets `PYTHONPATH` to the virtual environment. |
|
|
43
|
+
| `inexact` | `bool` | `False` | If `True`, tells `uv` not to remove dependencies that are present but not declared in the requirements. |
|
|
44
|
+
|
|
45
|
+
#### Example
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
hooks:
|
|
49
|
+
- id: python/setup-python
|
|
50
|
+
version: ">=3.11,<3.12"
|
|
51
|
+
uv: "0.4.0"
|
|
52
|
+
requirements_file:
|
|
53
|
+
- requirements.txt
|
|
54
|
+
requirements: |
|
|
55
|
+
requests==2.32.0
|
|
56
|
+
activate_env: true
|
|
57
|
+
inexact: true
|
|
58
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bex-hooks-python"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "bex-hooks-python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICEN[CS]E*"]
|
|
8
|
+
authors = [ { name = "Lucino772" } ]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.28.1",
|
|
12
|
+
"pydantic>=2.11.4",
|
|
13
|
+
"stdlibx-compose>=0.1.0,<1",
|
|
14
|
+
"stdlibx-option>=0.1.0,<1",
|
|
15
|
+
"stdlibx-result>=0.1.0,<1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[tool.uv.build-backend]
|
|
19
|
+
module-name = "bex_hooks.hooks.python"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from bex_hooks.hooks.python.setup import setup_python
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
|
|
10
|
+
from bex_hooks.hooks.python._interface import HookFunc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_hooks() -> Mapping[str, HookFunc]:
|
|
14
|
+
return {
|
|
15
|
+
"python/setup-python": setup_python,
|
|
16
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal, NoReturn, Protocol, Self, TypeGuard
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# --- Helpers ---
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Context:
|
|
14
|
+
working_dir: str
|
|
15
|
+
metadata: Mapping[str, Any]
|
|
16
|
+
environ: Mapping[str, str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_token_cancelled(
|
|
20
|
+
token: CancellationToken,
|
|
21
|
+
) -> TypeGuard[CancelledCancellationToken]:
|
|
22
|
+
return token.is_cancelled()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --- Typing ---
|
|
26
|
+
class HookFunc(Protocol):
|
|
27
|
+
def __call__(
|
|
28
|
+
self,
|
|
29
|
+
token: CancellationToken,
|
|
30
|
+
args: Mapping[str, Any],
|
|
31
|
+
ctx: ContextLike,
|
|
32
|
+
*,
|
|
33
|
+
ui: UI,
|
|
34
|
+
) -> ContextLike: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ContextLike(Protocol):
|
|
38
|
+
@property
|
|
39
|
+
def working_dir(self) -> str: ...
|
|
40
|
+
@property
|
|
41
|
+
def metadata(self) -> Mapping[str, Any]: ...
|
|
42
|
+
@property
|
|
43
|
+
def environ(self) -> Mapping[str, str]: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CancellationToken(Protocol):
|
|
47
|
+
def register(self, fn: Callable[[Exception], None]) -> None: ...
|
|
48
|
+
def is_cancelled(self) -> bool: ...
|
|
49
|
+
def get_error(self) -> Exception | None: ...
|
|
50
|
+
def raise_if_cancelled(self): ...
|
|
51
|
+
def wait(self, timeout: float | None) -> Exception | None: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CancelledCancellationToken(CancellationToken, Protocol):
|
|
55
|
+
def is_cancelled(self) -> Literal[True]: ...
|
|
56
|
+
def get_error(self) -> Exception: ...
|
|
57
|
+
def raise_if_cancelled(self) -> NoReturn: ...
|
|
58
|
+
def wait(self, timeout: float | None) -> Exception: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UI(Protocol):
|
|
62
|
+
def scope(self, status: str) -> UIScope: ...
|
|
63
|
+
def progress(self) -> UIProgress: ...
|
|
64
|
+
def log(self, *objects: Any, end: str = "\n") -> None: ...
|
|
65
|
+
def print(self, *objects: Any, end: str = "\n") -> None: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class UIScope(Protocol):
|
|
69
|
+
def update(self, status: str | None) -> None: ...
|
|
70
|
+
def __enter__(self) -> Self: ...
|
|
71
|
+
def __exit__(
|
|
72
|
+
self,
|
|
73
|
+
exc_type: type[BaseException] | None,
|
|
74
|
+
exc_val: BaseException | None,
|
|
75
|
+
exc_tb: TracebackType | None,
|
|
76
|
+
) -> None: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UIProgress(Protocol):
|
|
80
|
+
def add_task(self, description: str, /, *, total: float | None = None) -> Any: ...
|
|
81
|
+
def update(
|
|
82
|
+
self,
|
|
83
|
+
token: Any,
|
|
84
|
+
/,
|
|
85
|
+
*,
|
|
86
|
+
description: str | None = None,
|
|
87
|
+
total: float | None = None,
|
|
88
|
+
completed: float | None = None,
|
|
89
|
+
advance: float | None = None,
|
|
90
|
+
) -> None: ...
|
|
91
|
+
def advance(self, token: Any, advance: float) -> None: ...
|
|
92
|
+
def __enter__(self) -> Self: ...
|
|
93
|
+
def __exit__(
|
|
94
|
+
self,
|
|
95
|
+
exc_type: type[BaseException] | None,
|
|
96
|
+
exc_val: BaseException | None,
|
|
97
|
+
exc_tb: TracebackType | None,
|
|
98
|
+
) -> None: ...
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import glob
|
|
5
|
+
import itertools
|
|
6
|
+
import logging
|
|
7
|
+
import platform
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import sysconfig
|
|
12
|
+
import tarfile
|
|
13
|
+
import zipfile
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from string import Template
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
from urllib.parse import urljoin
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from pydantic import BaseModel, Field
|
|
22
|
+
|
|
23
|
+
from bex_hooks.hooks.python._interface import Context
|
|
24
|
+
from bex_hooks.hooks.python.utils import (
|
|
25
|
+
append_path,
|
|
26
|
+
download_file,
|
|
27
|
+
prepend_path,
|
|
28
|
+
wait_process,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Iterable, Mapping
|
|
33
|
+
|
|
34
|
+
from bex_hooks.hooks.python._interface import UI, CancellationToken, ContextLike
|
|
35
|
+
|
|
36
|
+
_UV_RELEASES_URL = "https://api.github.com/repos/astral-sh/uv/releases"
|
|
37
|
+
_UV_DOWNLOAD_URL = "https://github.com/astral-sh/uv/releases/download/{version}/"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Args(BaseModel):
|
|
41
|
+
version: str
|
|
42
|
+
uv_version: str | None = Field(default=None, alias="uv")
|
|
43
|
+
requirements: str = Field(default="")
|
|
44
|
+
requirements_file: list[str] = Field(default_factory=list)
|
|
45
|
+
activate_env: bool = Field(default=False)
|
|
46
|
+
set_python_path: bool = Field(default=False)
|
|
47
|
+
inexact: bool = Field(default=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def setup_python(
|
|
51
|
+
token: CancellationToken, args: Mapping[str, Any], ctx: ContextLike, *, ui: UI
|
|
52
|
+
) -> ContextLike:
|
|
53
|
+
data = _Args.model_validate(args, from_attributes=False)
|
|
54
|
+
|
|
55
|
+
bex_dir = Path(ctx.working_dir) / ".bex"
|
|
56
|
+
root_dir = Path(ctx.working_dir) / "python"
|
|
57
|
+
root_dir.mkdir(exist_ok=True)
|
|
58
|
+
|
|
59
|
+
uv = _download_uv(token, ui, bex_dir / "cache" / "uv", version=data.uv_version)
|
|
60
|
+
if uv is None:
|
|
61
|
+
msg = "Failed to download uv"
|
|
62
|
+
raise RuntimeError(msg)
|
|
63
|
+
|
|
64
|
+
req_files = list(
|
|
65
|
+
itertools.chain(
|
|
66
|
+
*[
|
|
67
|
+
glob.iglob(
|
|
68
|
+
Template(file).substitute(
|
|
69
|
+
{
|
|
70
|
+
"working_dir": ctx.working_dir,
|
|
71
|
+
"metadata": ctx.metadata,
|
|
72
|
+
"environ": ctx.environ,
|
|
73
|
+
}
|
|
74
|
+
),
|
|
75
|
+
recursive=True,
|
|
76
|
+
)
|
|
77
|
+
for file in data.requirements_file
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
for file in req_files:
|
|
82
|
+
ui.log("Discovered requirement file: {}".format(file))
|
|
83
|
+
|
|
84
|
+
python_bin = _create_isolated_environment(
|
|
85
|
+
token,
|
|
86
|
+
ctx,
|
|
87
|
+
root_dir,
|
|
88
|
+
uv,
|
|
89
|
+
data.version,
|
|
90
|
+
data.requirements,
|
|
91
|
+
req_files,
|
|
92
|
+
data.inexact,
|
|
93
|
+
ui,
|
|
94
|
+
)
|
|
95
|
+
if python_bin is None:
|
|
96
|
+
msg = "Failed to create python virtual environment"
|
|
97
|
+
raise RuntimeError(msg)
|
|
98
|
+
|
|
99
|
+
# Configure PYTHONPATH environment variables
|
|
100
|
+
_python_path = _get_python_path(python_bin)
|
|
101
|
+
|
|
102
|
+
_metadata = dict(ctx.metadata)
|
|
103
|
+
_environ = dict(ctx.environ)
|
|
104
|
+
_metadata["python_bin"] = str(python_bin)
|
|
105
|
+
|
|
106
|
+
venv_dir = root_dir / ".venv"
|
|
107
|
+
if data.activate_env is True:
|
|
108
|
+
_environ["VIRTUAL_ENV"] = str(venv_dir)
|
|
109
|
+
_environ["VENV_DIR"] = str(venv_dir)
|
|
110
|
+
_environ["PATH"] = prepend_path(
|
|
111
|
+
_environ["PATH"],
|
|
112
|
+
str(venv_dir / ("Scripts" if platform.system() == "Windows" else "bin")),
|
|
113
|
+
)
|
|
114
|
+
_environ["VIRTUAL_ENV_PROMPT"] = Path(ctx.working_dir).name
|
|
115
|
+
if "PYTHONHOME" in _environ:
|
|
116
|
+
del _environ["PYTHONHOME"]
|
|
117
|
+
|
|
118
|
+
if data.set_python_path is True and _python_path:
|
|
119
|
+
_environ["PYTHONPATH"] = append_path(
|
|
120
|
+
_environ.get("PYTHONPATH", ""), str(Path(_python_path))
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return Context(ctx.working_dir, _metadata, _environ)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _create_isolated_environment(
|
|
127
|
+
token: CancellationToken,
|
|
128
|
+
ctx: ContextLike,
|
|
129
|
+
root_dir: Path,
|
|
130
|
+
uv_bin: Path,
|
|
131
|
+
python_specifier: str,
|
|
132
|
+
requirements: str,
|
|
133
|
+
req_files: Iterable[str],
|
|
134
|
+
inexact: bool, # noqa: FBT001
|
|
135
|
+
ui: UI,
|
|
136
|
+
):
|
|
137
|
+
logger = logging.getLogger("bex_hooks.hooks.python")
|
|
138
|
+
|
|
139
|
+
venv_dir = root_dir / ".venv"
|
|
140
|
+
requirements_in = root_dir / "requirements.in"
|
|
141
|
+
requirements_txt = root_dir / "requirements.txt"
|
|
142
|
+
python_bin = (
|
|
143
|
+
venv_dir
|
|
144
|
+
/ ("Scripts" if platform.system() == "Windows" else "bin")
|
|
145
|
+
/ ("python.exe" if platform.system() == "Windows" else "python")
|
|
146
|
+
)
|
|
147
|
+
with ui.scope("[not dim]Updating virtual environment[/not dim]"):
|
|
148
|
+
create_venc_rc = wait_process(
|
|
149
|
+
token,
|
|
150
|
+
[
|
|
151
|
+
str(uv_bin),
|
|
152
|
+
"venv",
|
|
153
|
+
"--allow-existing",
|
|
154
|
+
"--no-project",
|
|
155
|
+
"--seed",
|
|
156
|
+
"--python",
|
|
157
|
+
python_specifier,
|
|
158
|
+
"--python-preference",
|
|
159
|
+
"only-managed",
|
|
160
|
+
str(venv_dir),
|
|
161
|
+
],
|
|
162
|
+
callback=logger.debug,
|
|
163
|
+
)
|
|
164
|
+
if create_venc_rc != 0:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
logger.info("Refreshed virtual environment")
|
|
168
|
+
|
|
169
|
+
full_requirements = requirements
|
|
170
|
+
for file in req_files:
|
|
171
|
+
full_requirements += "\n" + Path(file).read_text()
|
|
172
|
+
|
|
173
|
+
requirements_in.write_bytes(
|
|
174
|
+
Template(full_requirements)
|
|
175
|
+
.substitute(
|
|
176
|
+
{
|
|
177
|
+
"working_dir": ctx.working_dir,
|
|
178
|
+
"metadata": ctx.metadata,
|
|
179
|
+
"environ": ctx.environ,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
.encode("utf-8")
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
lock_pip_requirements_rc = wait_process(
|
|
186
|
+
token,
|
|
187
|
+
[
|
|
188
|
+
str(uv_bin),
|
|
189
|
+
"pip",
|
|
190
|
+
"compile",
|
|
191
|
+
"--python",
|
|
192
|
+
str(python_bin),
|
|
193
|
+
"--emit-index-url",
|
|
194
|
+
str(requirements_in),
|
|
195
|
+
"-o",
|
|
196
|
+
str(requirements_txt),
|
|
197
|
+
],
|
|
198
|
+
callback=logger.debug,
|
|
199
|
+
)
|
|
200
|
+
if lock_pip_requirements_rc != 0:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
logger.info("Locked dependencies")
|
|
204
|
+
|
|
205
|
+
sync_pip_requirements_rc = wait_process(
|
|
206
|
+
token,
|
|
207
|
+
[
|
|
208
|
+
str(uv_bin),
|
|
209
|
+
"pip",
|
|
210
|
+
"install",
|
|
211
|
+
"--python",
|
|
212
|
+
str(python_bin),
|
|
213
|
+
]
|
|
214
|
+
+ (["--exact"] if inexact is False else [])
|
|
215
|
+
+ [
|
|
216
|
+
"-r",
|
|
217
|
+
str(requirements_txt),
|
|
218
|
+
],
|
|
219
|
+
callback=logger.debug,
|
|
220
|
+
)
|
|
221
|
+
if sync_pip_requirements_rc != 0:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
logger.info("Synced dependencies")
|
|
225
|
+
|
|
226
|
+
return python_bin
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _download_uv(
|
|
230
|
+
token: CancellationToken,
|
|
231
|
+
ui: UI,
|
|
232
|
+
directory: Path,
|
|
233
|
+
*,
|
|
234
|
+
version: str | None = None,
|
|
235
|
+
):
|
|
236
|
+
logger = logging.getLogger("bex_hooks.hooks.python")
|
|
237
|
+
|
|
238
|
+
if version is None:
|
|
239
|
+
version = _get_uv_latest_version()
|
|
240
|
+
if version is None:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
logger.info("Resolved uv version to %s", version)
|
|
244
|
+
|
|
245
|
+
exe = ".exe" if sys.platform == "win32" else ""
|
|
246
|
+
uv_bin = directory / f"uv-{version}{exe}"
|
|
247
|
+
if uv_bin.exists():
|
|
248
|
+
return uv_bin
|
|
249
|
+
|
|
250
|
+
filename, target = _get_uv_release_info()
|
|
251
|
+
if filename is None or target is None:
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
with ui.progress() as pb:
|
|
255
|
+
task_id = pb.add_task(f"Downloading uv {version}")
|
|
256
|
+
temp_filename = download_file(
|
|
257
|
+
token,
|
|
258
|
+
urljoin(_UV_DOWNLOAD_URL.format(version=version), filename),
|
|
259
|
+
report_hook=lambda curr, total: pb.update(
|
|
260
|
+
task_id, total=total, completed=curr
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
if filename.endswith(".zip"):
|
|
266
|
+
with (
|
|
267
|
+
zipfile.ZipFile(temp_filename, "r") as archive,
|
|
268
|
+
archive.open(archive.getinfo(f"uv{exe}")) as source,
|
|
269
|
+
open(uv_bin, "wb") as target_file,
|
|
270
|
+
):
|
|
271
|
+
target_file.write(source.read())
|
|
272
|
+
else:
|
|
273
|
+
with tarfile.open(temp_filename, "r:gz") as archive:
|
|
274
|
+
source = archive.extractfile(f"{target}/uv{exe}")
|
|
275
|
+
if source is None:
|
|
276
|
+
msg = "Failed to extract uv from archive"
|
|
277
|
+
raise RuntimeError(msg)
|
|
278
|
+
|
|
279
|
+
with (
|
|
280
|
+
source,
|
|
281
|
+
open(uv_bin, "wb") as target_file,
|
|
282
|
+
):
|
|
283
|
+
target_file.write(source.read())
|
|
284
|
+
|
|
285
|
+
uv_bin.chmod(uv_bin.stat().st_mode | stat.S_IXUSR)
|
|
286
|
+
return uv_bin
|
|
287
|
+
finally:
|
|
288
|
+
_path = Path(temp_filename)
|
|
289
|
+
if _path.exists():
|
|
290
|
+
_path.unlink()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _get_python_path(python_bin: Path):
|
|
294
|
+
try:
|
|
295
|
+
_output = subprocess.check_output(
|
|
296
|
+
[
|
|
297
|
+
str(python_bin),
|
|
298
|
+
"-c",
|
|
299
|
+
"import sysconfig;print(sysconfig.get_paths()['purelib'])",
|
|
300
|
+
],
|
|
301
|
+
shell=False,
|
|
302
|
+
text=True,
|
|
303
|
+
stderr=subprocess.STDOUT,
|
|
304
|
+
)
|
|
305
|
+
except subprocess.CalledProcessError:
|
|
306
|
+
return None
|
|
307
|
+
else:
|
|
308
|
+
if _output[-1:] == "\n":
|
|
309
|
+
_output = _output[:-1]
|
|
310
|
+
return _output
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _get_uv_latest_version() -> str | None:
|
|
314
|
+
response = httpx.get(_UV_RELEASES_URL).json()
|
|
315
|
+
releases = (
|
|
316
|
+
(entry["name"], dt.datetime.fromisoformat(entry["published_at"]))
|
|
317
|
+
for entry in response
|
|
318
|
+
if entry["draft"] is False and entry["prerelease"] is False
|
|
319
|
+
)
|
|
320
|
+
return next(
|
|
321
|
+
iter(sorted(releases, key=lambda entry: entry[1], reverse=True)),
|
|
322
|
+
(None, None),
|
|
323
|
+
)[0]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _get_uv_release_info():
|
|
327
|
+
system = platform.system().lower()
|
|
328
|
+
if system not in ("windows", "linux", "darwin"):
|
|
329
|
+
return None, None
|
|
330
|
+
|
|
331
|
+
arch = defaultdict(
|
|
332
|
+
lambda: None,
|
|
333
|
+
{
|
|
334
|
+
"AMD64": "x86_64",
|
|
335
|
+
"x86_64": "x86_64",
|
|
336
|
+
"arm64": "aarch64",
|
|
337
|
+
"aarch64": "aarch64",
|
|
338
|
+
},
|
|
339
|
+
)[platform.machine()]
|
|
340
|
+
if arch is None:
|
|
341
|
+
return None, None
|
|
342
|
+
|
|
343
|
+
vendor = defaultdict(lambda: "unknown", {"windows": "pc", "darwin": "apple"})[
|
|
344
|
+
system
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
abi = None
|
|
348
|
+
if system == "windows":
|
|
349
|
+
cc = sysconfig.get_config_var("CC")
|
|
350
|
+
abi = "msvc" if cc is None or cc == "cl.exe" else "gnu"
|
|
351
|
+
elif system == "linux":
|
|
352
|
+
libc, _ = platform.libc_ver()
|
|
353
|
+
abi = "gnu" if libc in ("glibc", "libc") else "musl"
|
|
354
|
+
|
|
355
|
+
if abi is not None:
|
|
356
|
+
target = f"uv-{arch}-{vendor}-{system}-{abi}"
|
|
357
|
+
else:
|
|
358
|
+
target = f"uv-{arch}-{vendor}-{system}"
|
|
359
|
+
|
|
360
|
+
if system == "windows":
|
|
361
|
+
return target + ".zip", target
|
|
362
|
+
|
|
363
|
+
return target + ".tar.gz", target
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from stdlibx.compose import flow
|
|
13
|
+
from stdlibx.option import fn as option
|
|
14
|
+
from stdlibx.option import optional_of
|
|
15
|
+
from stdlibx.result import Error, Ok, as_result
|
|
16
|
+
from stdlibx.result import fn as result
|
|
17
|
+
|
|
18
|
+
from bex_hooks.hooks.python._interface import is_token_cancelled
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
|
|
23
|
+
from bex_hooks.hooks.python._interface import CancellationToken
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def append_path(previous: str, *values: str) -> str:
|
|
27
|
+
path_sep = ";" if platform.system() == "Windows" else ":"
|
|
28
|
+
previous_path = previous.split(path_sep)
|
|
29
|
+
for value in values:
|
|
30
|
+
if value not in previous_path:
|
|
31
|
+
previous_path.append(value)
|
|
32
|
+
return path_sep.join([value for value in previous_path if len(value) > 0])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def prepend_path(previous: str, *values: str) -> str:
|
|
36
|
+
path_sep = ";" if platform.system() == "Windows" else ":"
|
|
37
|
+
previous_path = previous.split(path_sep)
|
|
38
|
+
for value in reversed(values):
|
|
39
|
+
if value not in previous_path:
|
|
40
|
+
previous_path.insert(0, value)
|
|
41
|
+
return path_sep.join([value for value in previous_path if len(value) > 0])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def download_file(
|
|
45
|
+
token: CancellationToken,
|
|
46
|
+
source: str,
|
|
47
|
+
*,
|
|
48
|
+
chunk_size: int | None = None,
|
|
49
|
+
report_hook: Callable[[int, int], Any] | None = None,
|
|
50
|
+
) -> Path:
|
|
51
|
+
with (
|
|
52
|
+
tempfile.NamedTemporaryFile(delete=False) as dest,
|
|
53
|
+
httpx.stream(
|
|
54
|
+
"GET", source, follow_redirects=True, headers={"Accept-Encoding": ""}
|
|
55
|
+
) as response,
|
|
56
|
+
):
|
|
57
|
+
_content_len = (
|
|
58
|
+
int(response.headers["Content-Length"])
|
|
59
|
+
if "Content-Length" in response.headers
|
|
60
|
+
else -1
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
chunk_iter = response.iter_bytes(chunk_size)
|
|
64
|
+
with contextlib.suppress(StopIteration):
|
|
65
|
+
while token.is_cancelled() is False:
|
|
66
|
+
dest.write(next(chunk_iter))
|
|
67
|
+
if callable(report_hook):
|
|
68
|
+
report_hook(response.num_bytes_downloaded, _content_len)
|
|
69
|
+
|
|
70
|
+
_path = Path(dest.name)
|
|
71
|
+
if is_token_cancelled(token) and _path.exists():
|
|
72
|
+
_path.unlink()
|
|
73
|
+
raise token.get_error()
|
|
74
|
+
|
|
75
|
+
return _path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def wait_process(
|
|
79
|
+
token: CancellationToken,
|
|
80
|
+
args: str | Sequence[str],
|
|
81
|
+
/,
|
|
82
|
+
*,
|
|
83
|
+
callback: Callable[[str], Any] | None = None,
|
|
84
|
+
timeout: float | None = None,
|
|
85
|
+
**kwargs,
|
|
86
|
+
) -> int:
|
|
87
|
+
class _ProcessEndedError(Exception): ...
|
|
88
|
+
|
|
89
|
+
process = subprocess.Popen(
|
|
90
|
+
args,
|
|
91
|
+
shell=False,
|
|
92
|
+
text=True,
|
|
93
|
+
stdout=subprocess.PIPE,
|
|
94
|
+
stderr=subprocess.STDOUT,
|
|
95
|
+
**kwargs,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _terminate_process(_: Exception | None):
|
|
99
|
+
if process.poll() is not None:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
process.terminate()
|
|
103
|
+
try:
|
|
104
|
+
process.wait(timeout=timeout)
|
|
105
|
+
except subprocess.TimeoutExpired:
|
|
106
|
+
process.kill()
|
|
107
|
+
process.wait()
|
|
108
|
+
|
|
109
|
+
token.register(_terminate_process)
|
|
110
|
+
|
|
111
|
+
while True:
|
|
112
|
+
_result = flow(
|
|
113
|
+
optional_of(lambda: process.stdout),
|
|
114
|
+
option.map_or_else(
|
|
115
|
+
lambda: Ok("\n") if process.poll() is None else Ok(""),
|
|
116
|
+
as_result(
|
|
117
|
+
lambda stdout: (
|
|
118
|
+
stdout.readline() or "\n" if process.poll() is None else ""
|
|
119
|
+
)
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
result.and_then(
|
|
123
|
+
lambda val: Ok(val) if len(val) > 0 else Error(_ProcessEndedError())
|
|
124
|
+
),
|
|
125
|
+
result.map_(lambda val: val.strip("\n")),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
match _result:
|
|
129
|
+
case Ok(line) if callback is not None:
|
|
130
|
+
callback(line)
|
|
131
|
+
case Error(_ProcessEndedError()):
|
|
132
|
+
token.raise_if_cancelled()
|
|
133
|
+
return process.poll() # type: ignore
|
|
134
|
+
case Error():
|
|
135
|
+
_terminate_process(None)
|
|
136
|
+
return process.poll() # type: ignore
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class EtaCalculator:
|
|
140
|
+
def __init__(self) -> None:
|
|
141
|
+
self.__start_time = None
|
|
142
|
+
|
|
143
|
+
def eta(self, value: int, total: int) -> str:
|
|
144
|
+
if value == -1:
|
|
145
|
+
return str(dt.timedelta())
|
|
146
|
+
|
|
147
|
+
_now = dt.datetime.now(tz=dt.UTC)
|
|
148
|
+
if self.__start_time is None:
|
|
149
|
+
self.__start_time = _now
|
|
150
|
+
|
|
151
|
+
_elapsed = (_now - self.__start_time).total_seconds()
|
|
152
|
+
if _elapsed == 0:
|
|
153
|
+
return str(dt.timedelta())
|
|
154
|
+
|
|
155
|
+
return str(dt.timedelta(seconds=(total - value) / (value / _elapsed)))
|