|
4 | 4 | from __future__ import annotations
|
5 | 5 |
|
6 | 6 | import argparse
|
| 7 | +import hashlib |
7 | 8 | import importlib
|
8 | 9 | import inspect
|
9 | 10 | import logging
|
|
15 | 16 | from contextlib import AbstractContextManager
|
16 | 17 | from contextlib import contextmanager
|
17 | 18 | from contextlib import nullcontext
|
| 19 | +from functools import cached_property |
18 | 20 | from functools import partial
|
19 | 21 | from subprocess import CompletedProcess
|
20 | 22 | from types import FunctionType
|
|
26 | 28 | from typing import TypeVar
|
27 | 29 | from typing import cast
|
28 | 30 |
|
| 31 | +import attr |
29 | 32 | import requests
|
30 | 33 | import rich
|
31 | 34 | from rich.console import Console
|
|
35 | 38 | from ptscripts import process
|
36 | 39 | from ptscripts.virtualenv import VirtualEnv
|
37 | 40 | from ptscripts.virtualenv import VirtualEnvConfig
|
| 41 | +from ptscripts.virtualenv import _cast_to_pathlib_path |
38 | 42 |
|
39 | 43 | if sys.version_info < (3, 10):
|
40 | 44 | from typing_extensions import Concatenate
|
@@ -99,6 +103,82 @@ class FullArgumentOptions(ArgumentOptions):
|
99 | 103 | type: type[Any]
|
100 | 104 |
|
101 | 105 |
|
| 106 | +@attr.s(frozen=True) |
| 107 | +class DefaultRequirementsConfig: |
| 108 | + """ |
| 109 | + Default tools requirements configuration typing. |
| 110 | + """ |
| 111 | + |
| 112 | + requirements: list[str] = attr.ib(factory=list) |
| 113 | + requirements_files: list[pathlib.Path] = attr.ib(factory=list) |
| 114 | + pip_args: list[str] = attr.ib(factory=list) |
| 115 | + |
| 116 | + @cached_property |
| 117 | + def requirements_hash(self) -> str: |
| 118 | + """ |
| 119 | + Returns a sha256 hash of the requirements. |
| 120 | + """ |
| 121 | + requirements_hash = hashlib.sha256() |
| 122 | + hash_seed = os.environ.get("TOOLS_VIRTUALENV_CACHE_SEED", "") |
| 123 | + requirements_hash.update(hash_seed.encode()) |
| 124 | + if self.pip_args: |
| 125 | + for argument in self.pip_args: |
| 126 | + requirements_hash.update(argument.encode()) |
| 127 | + if self.requirements: |
| 128 | + for requirement in sorted(self.requirements): |
| 129 | + requirements_hash.update(requirement.encode()) |
| 130 | + if self.requirements_files: |
| 131 | + for fpath in sorted(self.requirements_files): |
| 132 | + with _cast_to_pathlib_path(fpath).open("rb") as rfh: |
| 133 | + try: |
| 134 | + digest = hashlib.file_digest(rfh, "sha256") # type: ignore[attr-defined] |
| 135 | + except AttributeError: |
| 136 | + # Python < 3.11 |
| 137 | + buf = bytearray(2**18) # Reusable buffer to reduce allocations. |
| 138 | + view = memoryview(buf) |
| 139 | + digest = hashlib.sha256() |
| 140 | + while True: |
| 141 | + size = rfh.readinto(buf) |
| 142 | + if size == 0: |
| 143 | + break # EOF |
| 144 | + digest.update(view[:size]) |
| 145 | + requirements_hash.update(digest.digest()) |
| 146 | + return requirements_hash.hexdigest() |
| 147 | + |
| 148 | + def install(self, ctx: Context) -> None: |
| 149 | + """ |
| 150 | + Install default requirements. |
| 151 | + """ |
| 152 | + from ptscripts.__main__ import TOOLS_VENVS_PATH |
| 153 | + |
| 154 | + requirements_hash_file = TOOLS_VENVS_PATH / ".default-requirements.hash" |
| 155 | + if ( |
| 156 | + requirements_hash_file.exists() |
| 157 | + and requirements_hash_file.read_text() == self.requirements_hash |
| 158 | + ): |
| 159 | + # Requirements are up to date |
| 160 | + ctx.debug("Base tools requirements haven't changed.") |
| 161 | + return |
| 162 | + requirements = [] |
| 163 | + if self.requirements_files: |
| 164 | + for fpath in self.requirements_files: |
| 165 | + requirements.extend(["-r", str(fpath)]) |
| 166 | + if self.requirements: |
| 167 | + requirements.extend(self.requirements) |
| 168 | + if requirements: |
| 169 | + ctx.info("Installing base tools requirements ...") |
| 170 | + ctx.run( |
| 171 | + sys.executable, |
| 172 | + "-m", |
| 173 | + "pip", |
| 174 | + "install", |
| 175 | + *self.pip_args, |
| 176 | + *requirements, |
| 177 | + ) |
| 178 | + requirements_hash_file.parent.mkdir(parents=True, exist_ok=True) |
| 179 | + requirements_hash_file.write_text(self.requirements_hash) |
| 180 | + |
| 181 | + |
102 | 182 | class Context:
|
103 | 183 | """
|
104 | 184 | Context class passed to every command group function as the first argument.
|
@@ -308,6 +388,35 @@ def set_default_virtualenv_config(cls, venv_config: VirtualEnvConfig) -> None:
|
308 | 388 | instance.venv_config = venv_config
|
309 | 389 |
|
310 | 390 |
|
| 391 | +class DefaultToolsPythonRequirements: |
| 392 | + """ |
| 393 | + Simple class to hold registered imports. |
| 394 | + """ |
| 395 | + |
| 396 | + _instance: DefaultToolsPythonRequirements | None = None |
| 397 | + reqs_config: DefaultRequirementsConfig | None |
| 398 | + |
| 399 | + def __new__(cls) -> DefaultToolsPythonRequirements: |
| 400 | + """ |
| 401 | + Method that instantiates a singleton class and returns it. |
| 402 | + """ |
| 403 | + if cls._instance is None: |
| 404 | + instance = super().__new__(cls) |
| 405 | + instance.reqs_config = None |
| 406 | + cls._instance = instance |
| 407 | + return cls._instance |
| 408 | + |
| 409 | + @classmethod |
| 410 | + def set_default_requirements_config(cls, reqs_config: DefaultRequirementsConfig) -> None: |
| 411 | + """ |
| 412 | + Set the default tools requirements configuration. |
| 413 | + """ |
| 414 | + instance = cls._instance |
| 415 | + if instance is None: |
| 416 | + instance = cls() |
| 417 | + instance.reqs_config = reqs_config |
| 418 | + |
| 419 | + |
311 | 420 | class RegisteredImports:
|
312 | 421 | """
|
313 | 422 | Simple class to hold registered imports.
|
@@ -444,6 +553,10 @@ def __new__(cls) -> Parser:
|
444 | 553 | return cls._instance
|
445 | 554 |
|
446 | 555 | def _process_registered_tool_modules(self) -> None:
|
| 556 | + default_reqs_config = DefaultToolsPythonRequirements().reqs_config |
| 557 | + if default_reqs_config: |
| 558 | + default_reqs_config.install(self.context) |
| 559 | + |
447 | 560 | default_venv: VirtualEnv | AbstractContextManager[None]
|
448 | 561 | default_venv_config = DefaultVirtualEnv().venv_config
|
449 | 562 | if default_venv_config:
|
|
0 commit comments