bex-hooks-python 0.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,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)))
@@ -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,8 @@
1
+ bex_hooks/hooks/python/__init__.py,sha256=Ca_ZaarYqxUtd2zylrBHSZDCAiNBmzVJp0nRl_b-Ql0,352
2
+ bex_hooks/hooks/python/_interface.py,sha256=hZle0vH_Buyo0vkh1aMiWg3msVHcyvyE9gpwW12wq-4,2700
3
+ bex_hooks/hooks/python/setup.py,sha256=ZsQu2tul83w1-HCMI0UPAtIYxD0Gyl0luRBux3Q_oX4,10302
4
+ bex_hooks/hooks/python/utils.py,sha256=fFec03RC90d_2m7XoB0gvKlAhFNFAz2d6GwsESjgOUM,4587
5
+ bex_hooks_python-0.1.0.dist-info/licenses/LICENSE,sha256=EpsoiD9x04vG_Im6LSZpj5ev1iFTq5TYKVbR55N8k5w,1066
6
+ bex_hooks_python-0.1.0.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
7
+ bex_hooks_python-0.1.0.dist-info/METADATA,sha256=lmK4YzGxtS81vzJ1QTi8dup_4rHPiUA5_lsxd4oZ4cs,2925
8
+ bex_hooks_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.