From 1eaf7a977c5a344a7cfa0bdfb1e34e65cbb42b19 Mon Sep 17 00:00:00 2001 From: huji <669898595@qq.com> Date: Fri, 29 Mar 2024 17:13:09 +0800 Subject: [PATCH] add config cache --- src/linktools/_config.py | 386 +++++++++++------- src/linktools/_environ.py | 31 +- src/linktools/_tools.py | 10 +- .../assets/containers/100-nginx/container.py | 15 +- src/linktools/cli/command.py | 6 +- src/linktools/cli/commands/common/cntr.py | 61 ++- src/linktools/container/container.py | 112 ++--- src/linktools/container/manager.py | 11 +- src/linktools/decorator.py | 12 +- src/linktools/frida/script.py | 2 +- src/linktools/rich.py | 8 +- src/linktools/template/metadata | 8 +- src/linktools/utils/_utils.py | 2 +- 13 files changed, 393 insertions(+), 271 deletions(-) diff --git a/src/linktools/_config.py b/src/linktools/_config.py index 0ab0ee62..a656d75c 100644 --- a/src/linktools/_config.py +++ b/src/linktools/_config.py @@ -40,7 +40,6 @@ Any, Tuple, IO, Mapping, Union, List, Dict, Callable from . import utils -from .decorator import cached_property from .metadata import __missing__ from .rich import prompt, confirm, choose @@ -50,14 +49,14 @@ T = TypeVar("T") EnvironType = TypeVar("EnvironType", bound=BaseEnviron) - ConfigType = Literal["path"] + ConfigType = Literal["path", "json"] -def _is_type(obj: Any) -> bool: +def is_type(obj: Any) -> bool: return isinstance(obj, type) -def _cast_bool(obj: Any) -> bool: +def cast_bool(obj: Any) -> bool: if isinstance(obj, bool): return obj if isinstance(obj, str): @@ -70,25 +69,35 @@ def _cast_bool(obj: Any) -> bool: return bool(obj) -def _cast_str(obj: Any) -> str: +def cast_str(obj: Any) -> str: if isinstance(obj, str): return obj + if isinstance(obj, (Tuple, List, Dict)): + return json.dumps(obj) if obj is None: return "" return str(obj) -def _cast_path(obj: Any) -> str: +def cast_path(obj: Any) -> str: if isinstance(obj, str): return os.path.abspath(os.path.expanduser(obj)) raise TypeError(f"{type(obj)} cannot be converted to path") -_CONFIG_ENV = "ENV" -_CONFIG_TYPES: "Dict[Union[Type[T], ConfigType], Callable[[Any], T]]" = { - bool: _cast_bool, - str: _cast_str, - "path": _cast_path, +def cast_json(obj: Any) -> Union[List, Dict]: + if isinstance(obj, str): + return json.loads(obj) + if isinstance(obj, (Tuple, List, Dict)): + return obj + raise TypeError(f"{type(obj)} cannot be converted to json") + + +CONFIG_TYPES: "Dict[Union[Type[T], ConfigType], Callable[[Any], T]]" = { + bool: cast_bool, + str: cast_str, + "path": cast_path, + "json": cast_json, } @@ -98,67 +107,90 @@ class ConfigError(Exception): class ConfigParser(configparser.ConfigParser): - def optionxform(self, optionstr): + def optionxform(self, optionstr: str): return optionstr +class ConfigCacheParser: + + def __init__(self, path: str, namespace: str): + self._parser = ConfigParser(default_section="ENV") # 兼容老版本,默认ENV作为默认节 + self._path = path + self._section = f"{namespace}.CACHE".upper() + + def load(self): + if self._path and os.path.exists(self._path): + self._parser.read(self._path) + if not self._parser.has_section(self._section): + self._parser.add_section(self._section) + + def dump(self): + with open(self._path, "wt") as fd: + self._parser.write(fd) + + def get(self, key: str, default: Any) -> Any: + if self._parser.has_option(self._section, key): + return self._parser.get(self._section, key) + return default + + def set(self, key: str, value: str) -> None: + self._parser.set(self._section, key, value) + + def remove(self, key: str) -> bool: + return self._parser.remove_option(self._section, key) + + def items(self) -> Generator[Tuple[str, Any], None, None]: + for key, value in self._parser.items(self._section): + yield key, value + + class ConfigProperty(abc.ABC): - __lock__ = threading.RLock() - def __init__(self, type: "Union[Type[T], ConfigType]" = None, cached: Union[bool, str] = False): - self._data: Union[str, object] = __missing__ + def __init__(self, type: "Union[Type[T], ConfigType]" = None, cached: bool = False, reloadable: bool = False): + self._data = __missing__ self._type = type self._cached = cached + self._reloadable = reloadable - def load(self, config: "Config", key: str, type: "Union[Type[T], ConfigType]" = None) -> Any: - if self._data is not __missing__: - return self._data - - with self.__lock__: - if self._data is not __missing__: - return self._data - type = type or self._type - - if self._cached: - # load cache from config file - config_parser = ConfigParser() - if os.path.exists(config.path): - config_parser.read(config.path) - config_section = f"{config.namespace}.CACHE".upper() - if isinstance(self._cached, str): - config_section = self._cached - config_cache = __missing__ - if config_parser.has_option(config_section, key): - config_cache = config_parser.get(config_section, key) - - # load config value - result = self._load(config, key, config_cache) - if isinstance(result, ConfigProperty): - result = result.load(config, key, type=type) - elif not _is_type(type) or not isinstance(result, type): - result = config.cast(result, type) - - # update cache to config file - if config_section == config_parser.default_section: - pass - elif not config_parser.has_section(config_section): - config_parser.add_section(config_section) - config_parser.set(config_section, key, str(result)) - with open(config.path, "wt") as fd: - config_parser.write(fd) - - self._data = result + @property + def reloadable(self): + return self._reloadable - else: - result = self._load(config, key, __missing__) - if isinstance(result, ConfigProperty): - result = result.load(config, key, type=type) - elif not _is_type(type) or not isinstance(result, type): - result = config.cast(result, type) - self._data = result + def load(self, config: "Config", key: str, type: "Union[Type[T], ConfigType]" = None, + cache: Any = __missing__) -> Any: + if self._data != __missing__: return self._data + type = type or self._type + if self._cached: + # load cache from config file + config_parser = config._get_cache_parser() + config_cache = cache + if config_cache == __missing__: + config_cache = config_parser.get(key, __missing__) + + # load config value + result = self._load(config, key, config_cache) + if isinstance(result, ConfigProperty): + result = result.load(config, key, type=type, cache=cache) + elif not is_type(type) or not isinstance(result, type): + result = config.cast(result, type) + + # update cache to config file + config_parser.set(key, config.cast(result, type=str)) + config_parser.dump() + + else: + result = self._load(config, key, __missing__) + if isinstance(result, ConfigProperty): + result = result.load(config, key, type=type, cache=cache) + elif not is_type(type) or not isinstance(result, type): + result = config.cast(result, type) + + self._data = result + return result + @abc.abstractmethod def _load(self, config: "Config", key: str, cache: Any): pass @@ -172,7 +204,7 @@ def update_from_pyfile(self, filename: str, silent: bool = False) -> bool: d.prompt = Config.Prompt d.lazy = Config.Lazy d.alias = Config.Alias - d.sample = Config.Sample + d.error = Config.Error d.confirm = Config.Confirm try: data = utils.read_file(filename, text=False) @@ -215,75 +247,59 @@ def update_from_mapping(self, mapping: Optional[Mapping[str, Any]] = None, **kwa class Config: + __lock__ = threading.RLock() - def __init__(self, environ: "BaseEnviron", default: ConfigDict, share: bool = False): - self._environ = environ - self._config = default if share else pickle.loads(pickle.dumps(default)) - self._envvar_prefix = f"{self._environ.name.upper()}_" - self._namespace = configparser.DEFAULTSECT - - @property - def envvar_prefix(self) -> str: - """ - 环境变量前缀 - """ - return self._envvar_prefix - - @envvar_prefix.setter - def envvar_prefix(self, value: str): - """ - 环境变量前缀 + def __init__( + self, + environ: "BaseEnviron", + config: ConfigDict, + namespace: str = __missing__, + prefix: str = __missing__, + share: bool = False + ): + """ + 初始化配置对象 + :param environ: 环境对象 + :param config: 配置相关数据 + :param namespace: 缓存对应的命名空间 + :param prefix: 环境变量前缀 + :param share: 是否共享配置 """ - self._envvar_prefix = value + self._environ = environ + self._config = config if share else pickle.loads(pickle.dumps(config)) + self._namespace = namespace if namespace != __missing__ else "MAIN" + self._prefix = prefix.upper() if prefix != __missing__ else "" + self._cache = dict() + self._reload = None + self._cache_path = self._environ.get_data_path(f"{self._environ.name}.cfg", create_parent=True) + self.load_cache() @property - def namespace(self) -> str: + def reload(self) -> bool: """ - 配置文件的对应的节 + 是否重新加载配置 """ - return self._namespace + if self._reload is None: + self._reload = self.get("RELOAD_CONFIG", type=bool, default=False) + return self._reload - @namespace.setter - def namespace(self, value: str): + @reload.setter + def reload(self, value: bool): """ - 配置文件的节 + 是否重新加载配置 """ - self._namespace = value - - @cached_property - def path(self) -> str: - """ - 存放配置的目录 - """ - return self._environ.get_data_path(f"{self._environ.name}.cfg", create_parent=True) - - def load_from_env(self): - """ - 从缓存中加载配置 - """ - if os.path.exists(self.path): - try: - config_parser = ConfigParser() - config_parser.read(self.path) - if not config_parser.has_section(_CONFIG_ENV): - config_parser.add_section(_CONFIG_ENV) - with open(self.path, "wt") as fd: - config_parser.write(fd) - for key, value in config_parser.items(_CONFIG_ENV): - self.set(key, value) - except Exception as e: - self._environ.logger.warning(f"Load config from {self.path} failed: {e}") + self._reload = value def cast(self, obj: Any, type: "Union[Type[T], ConfigType]", default: Any = __missing__) -> "T": """ 类型转换 """ if type not in (None, __missing__): - cast = _CONFIG_TYPES.get(type, type) + cast = CONFIG_TYPES.get(type, type) try: return cast(obj) except Exception as e: - if default is not __missing__: + if default != __missing__: return default raise e return obj @@ -294,27 +310,42 @@ def get(self, key: str, type: "Union[Type[T], ConfigType]" = None, default: Any """ last_error = __missing__ try: - env_key = f"{self.envvar_prefix}{key}" + env_key = f"{self._prefix}{key}" if env_key in os.environ: value = os.environ.get(env_key) return self.cast(value, type=type) + if key in self._cache: + value = self._cache.get(key) + prop = self._config.get(key, None) + if isinstance(prop, ConfigProperty) and prop.reloadable and self.reload: + with self.__lock__: + value = self._cache[key] = prop.load(self, key, type=type, cache=value) + else: + value = self.cast(value, type=type) + return value + if key in self._config: value = self._config.get(key) if isinstance(value, ConfigProperty): - return value.load(self, key, type=type) - return self.cast(value, type=type) + with self.__lock__: + value = self._cache[key] = value.load(self, key, type=type) + else: + value = self.cast(value, type=type) + return value except Exception as e: last_error = e - if default is __missing__: - if last_error is not __missing__: + if default == __missing__: + if last_error != __missing__: raise last_error - raise ConfigError(f"Not found environment variable \"{self.envvar_prefix}{key}\" or config \"{key}\"") + raise ConfigError(f"Not found environment variable \"{self._prefix}{key}\" or config \"{key}\"") if isinstance(default, ConfigProperty): - return default.load(self, key, type=type) + with self.__lock__: + value = self._cache[key] = default.load(self, key, type=type) + return value return default @@ -323,9 +354,10 @@ def keys(self) -> Generator[str, None, None]: 遍历配置名,默认不遍历内置配置 """ keys = set(self._config.keys()) + keys.update(self._cache.keys()) for key in os.environ.keys(): - if key.startswith(self._envvar_prefix): - keys.add(key[len(self._envvar_prefix):]) + if key.startswith(self._prefix): + keys.add(key[len(self._prefix):]) for key in sorted(keys): yield key @@ -397,12 +429,55 @@ def update_from_dir(self, path: str, recursion: bool = False) -> bool: self.update_from_file(os.path.join(root, name)) return True + def load_cache(self) -> None: + """ + 从缓存中加载配置 + """ + parser = self._get_cache_parser() + with self.__lock__: + self._cache.clear() + self._cache.update(parser.items()) + + def save_cache(self, **kwargs: Any) -> None: + """ + 保存配置到缓存 + :param kwargs: 需要保存的配置 + """ + parser = self._get_cache_parser() + with self.__lock__: + for key, value in kwargs.items(): + self._cache[key] = value + parser.set(key, self.cast(value, type=str)) + parser.dump() + + def remove_cache(self, *keys: str) -> None: + """ + 删除缓存 + :param keys: 需要删除的缓存键 + """ + parser = self._get_cache_parser() + with self.__lock__: + for key in keys: + self._cache.pop(key, None) + parser.remove(key) + parser.dump() + + def __contains__(self, key) -> bool: + return f"{self._prefix}{key}" in os.environ or \ + key in self._config or \ + key in self._cache + def __getitem__(self, key: str) -> Any: return self.get(key) def __setitem__(self, key: str, value: Any): self.set(key, value) + def _get_cache_parser(self) -> ConfigCacheParser: + parser = ConfigCacheParser(self._cache_path, self._namespace) + parser.load() + return parser + class Prompt(ConfigProperty): def __init__( @@ -412,11 +487,11 @@ def __init__( choices: Optional[List[str]] = None, type: "Union[Type[Union[str, int, float]], ConfigType]" = str, default: Any = __missing__, - cached: Union[bool, str] = False, + cached: bool = False, always_ask: bool = False, allow_empty: bool = False, ): - super().__init__(type=type, cached=cached) + super().__init__(type=type, cached=cached, reloadable=True) self.type = type self.prompt = prompt @@ -429,16 +504,16 @@ def __init__( def _load(self, config: "Config", key: str, cache: Any): default = cache - if default is not __missing__ and not self.always_ask: - if not config.get("RELOAD_CONFIG", type=bool, default=False): + if default != __missing__ and not self.always_ask: + if not config.reload: return default - if default is __missing__: + if default == __missing__: default = self.default if isinstance(default, ConfigProperty): - default = default.load(config, key) + default = default.load(config, key, cache=cache) - if default is not __missing__: + if default != __missing__: default = config.cast(default, self.type) if self.choices: @@ -468,10 +543,10 @@ def __init__( self, prompt: str = None, default: Any = __missing__, - cached: Union[bool, str] = False, + cached: bool = False, always_ask: bool = False, ): - super().__init__(type=bool, cached=cached) + super().__init__(type=bool, cached=cached, reloadable=True) self.prompt = prompt self.default = default @@ -480,16 +555,16 @@ def __init__( def _load(self, config: "Config", key: str, cache: Any): default = cache - if default is not __missing__ and not self.always_ask: - if not config.get("RELOAD_CONFIG", type=bool, default=False): + if default != __missing__ and not self.always_ask: + if not config.reload: return default - if default is __missing__: + if default == __missing__: default = self.default if isinstance(default, ConfigProperty): - default = default.load(config, key) + default = default.load(config, key, cache=cache) - if default is not __missing__: + if default != __missing__: default = config.cast(default, bool) return confirm( @@ -507,17 +582,17 @@ def __init__( *keys: str, type: "Union[Type[T], ConfigType]" = str, default: Any = __missing__, - cached: Union[bool, str] = False + cached: bool = False ): super().__init__(type=type, cached=cached) self.keys = keys self.default = default def _load(self, config: "Config", key: str, cache: Any): - if cache is not __missing__: + if cache != __missing__: return cache - if self.default is __missing__: + if self.default == __missing__: last_error = None for key in self.keys: try: @@ -544,38 +619,33 @@ def __init__(self, func: "Callable[[Config], T]"): def _load(self, config: "Config", key: str, cache: Any): return self.func(config) - class Sample(ConfigProperty): + class Error(ConfigProperty): - def __init__(self, data: Union[str, Dict[str, str]] = None): + def __init__(self, message: str = None): super().__init__() - self.data = data + self.message = message def _load(self, config: "Config", key: str, cache: Any): - message = \ + message = self.message or \ f"Cannot find config \"{key}\". {os.linesep}" \ f"You can use any of the following methods to fix it: {os.linesep}" \ - f"1. set \"{config.envvar_prefix}{key}\" as an environment variable,{os.linesep}" \ - f"2. set \"{key}\" in [{_CONFIG_ENV}] section of {config.path}, such as: {os.linesep}" \ - f" [{_CONFIG_ENV}] {os.linesep}" \ - f" KEY1 = value1 {os.linesep}" \ - f" KEY2 = value2 {os.linesep}" - if self.data: - if isinstance(self.data, Dict): - for key, value in self.data.items(): - message += f"=> {key} = {value} {os.linesep}" - else: - message += f"=> {self.data} {os.linesep}" - else: - message += f"=> {key} = <= add this line {os.linesep}" - - raise ConfigError(message.rstrip()) + f"1. set \"{config._prefix}{key}\" as an environment variable, {os.linesep}" \ + f"2. call config.save_cache method to save the value to file. {os.linesep}" + raise ConfigError(message) class ConfigWrapper(Config): - def __init__(self, config: "Config"): + def __init__( + self, + config: "Config", + namespace: str = __missing__, + prefix: str = __missing__, + ): super().__init__( config._environ, config._config, + namespace=namespace if namespace != __missing__ else config._namespace, + prefix=prefix if prefix != __missing__ else config._prefix, share=True ) diff --git a/src/linktools/_environ.py b/src/linktools/_environ.py index 6cf71977..85d3df54 100644 --- a/src/linktools/_environ.py +++ b/src/linktools/_environ.py @@ -243,34 +243,46 @@ def _default_config(self) -> "ConfigDict": def _create_config(self) -> "Config": from ._config import Config - return Config(self, self._default_config) + return Config( + self, + self._default_config, + namespace="MAIN", + prefix=f"{self.name.upper()}_", + ) @cached_property def config(self) -> "Config": """ 环境相关配置 """ - config = self._create_config() - config.load_from_env() - return config + return self._create_config() - def wrap_config(self) -> "Config": + def wrap_config(self, namespace: str = metadata.__missing__, prefix: str = metadata.__missing__) -> "Config": """ - 环境相关配置,与environ.config共享数据 + 环境相关配置,与environ.config共享配置数据,但不共享缓存数据和环境变量信息 + :param namespace: 缓存对应的命名空间 + :param prefix: 环境变量使用前缀 + :return: 配置对象 """ from ._config import ConfigWrapper - return ConfigWrapper(self.config) + return ConfigWrapper(self.config, namespace=namespace, prefix=prefix) def get_config(self, key: str, type: "Type[T]" = None, default: Any = metadata.__missing__) -> "T": """ 获取指定配置,优先会从环境变量中获取 + :param key: 配置键 + :param type: 配置类型 + :param default: 默认值 + :return: 配置值 """ return self.config.get(key=key, type=type, default=default) def set_config(self, key: str, value: Any) -> None: """ 更新配置 + :param key: 配置键 + :param value: 配置值 """ self.config.set(key, value) @@ -306,6 +318,9 @@ def tools(self) -> "Tools": def get_tool(self, name: str, **kwargs) -> "Tool": """ 获取指定工具 + :param name: 工具名 + :param kwargs: 工具其他参数 + :return: 工具对象 """ tool = self.tools[name] if len(kwargs) != 0: @@ -315,6 +330,8 @@ def get_tool(self, name: str, **kwargs) -> "Tool": def get_url_file(self, url: str) -> "UrlFile": """ 获取指定url + :param url: url地址 + :return: UrlFile对象 """ from ._url import UrlFile diff --git a/src/linktools/_tools.py b/src/linktools/_tools.py index e7b3d22d..a3bce0b4 100644 --- a/src/linktools/_tools.py +++ b/src/linktools/_tools.py @@ -235,7 +235,7 @@ def config(self) -> dict: # download url download_url = utils.get_item(cfg, "download_url") or "" - if download_url is __missing__: + if download_url == __missing__: download_url = "" assert isinstance(download_url, str), \ f"Tool<{cfg['name']}>.download_url type error, " \ @@ -243,21 +243,21 @@ def config(self) -> dict: cfg["download_url"] = download_url.format(tools=self._container, **cfg) unpack_path = utils.get_item(cfg, "unpack_path") or "" - if unpack_path is __missing__: + if unpack_path == __missing__: unpack_path = "" assert isinstance(unpack_path, str), \ f"Tool<{cfg['name']}>.unpack_path type error, " \ f"str was expects, got {type(unpack_path)}" target_path = utils.get_item(cfg, "target_path") or "" - if target_path is __missing__: + if target_path == __missing__: target_path = "" assert isinstance(target_path, str), \ f"Tool<{cfg['name']}>.target_path type error, " \ f"str was expects, got {type(target_path)}" absolute_path = utils.get_item(cfg, "absolute_path") or "" - if absolute_path is __missing__: + if absolute_path == __missing__: absolute_path = "" assert isinstance(absolute_path, str), \ f"Tool<{cfg['name']}>.absolute_path type error, " \ @@ -286,7 +286,7 @@ def config(self) -> dict: # set executable cmdline cmdline = utils.get_item(cfg, "cmdline") or "" - if cmdline is __missing__: + if cmdline == __missing__: cmdline = cfg["name"] assert isinstance(cmdline, str), \ f"Tool<{cfg['name']}>.cmdline type error, " \ diff --git a/src/linktools/assets/containers/100-nginx/container.py b/src/linktools/assets/containers/100-nginx/container.py index a419b3fa..5178a332 100644 --- a/src/linktools/assets/containers/100-nginx/container.py +++ b/src/linktools/assets/containers/100-nginx/container.py @@ -29,6 +29,7 @@ import os import re import shutil +import textwrap from linktools import Config, utils from linktools.container import BaseContainer @@ -52,11 +53,15 @@ def configs(self): WILDCARD_DOMAIN=Config.Confirm(default=False, cached=True), HTTP_PORT=Config.Prompt(default=80, type=int, cached=True), HTTPS_PORT=Config.Prompt(default=443, type=int, cached=True), - ACME_DNS_API=Config.Sample({ - "ACME_DNS_API": "dns_ali <= parameter --dns, find from https://github.com/acmesh-official/acme.sh/wiki/dnsapi", - "Ali_Key ": " <= environment variable with dns_ali", - "Ali_Secret ": " <= environment variable with dns_ali", - }) + ACME_DNS_API=Config.Error(textwrap.dedent( + """ + Ensure ACME_DNS_API config matches --dns parameter in acme command is set. + · Also, set corresponding environment variables. + · For details, see: https://github.com/acmesh-official/acme.sh/wiki/dnsapi. + · Example command: + $ ct-cntr config set ACME_DNS_API=dns_ali Ali_Key=xxx Ali_Secret=yyy + """ + )) ) @cached_property diff --git a/src/linktools/cli/command.py b/src/linktools/cli/command.py index 8dec5e8c..547e9aff 100644 --- a/src/linktools/cli/command.py +++ b/src/linktools/cli/command.py @@ -116,7 +116,7 @@ def __call__(self, parser, namespace, values, option_string=None): def _filter_kwargs(kwargs): - return {k: v for k, v in kwargs.items() if v is not __missing__} + return {k: v for k, v in kwargs.items() if v != __missing__} _subcommand_index: int = 0 @@ -270,7 +270,7 @@ def decorator(func): class _SubCommandInfo: - def __init__(self, subcommand: Union["SubCommand", "_SubCommandInfo"]): + def __init__(self, subcommand: "Union[SubCommand, _SubCommandInfo]"): self.node: SubCommand = subcommand.node if isinstance(subcommand, _SubCommandInfo) else subcommand self.children: List[_SubCommandInfo] = [] @@ -388,7 +388,7 @@ def create_parser(self, type: Callable[..., ArgumentParser]) -> ArgumentParser: if dest not in signature.parameters: raise SubCommandError( f"Check subcommand argument error, " - f"{self.info} has no `{argument.action.dest}` argument") + f"{self.info} has no `{dest}` argument") # 根据方法参数的注解,设置一些默认值 parameter = signature.parameters[dest] diff --git a/src/linktools/cli/commands/common/cntr.py b/src/linktools/cli/commands/common/cntr.py index 7e03abd6..d6dd609c 100644 --- a/src/linktools/cli/commands/common/cntr.py +++ b/src/linktools/cli/commands/common/cntr.py @@ -28,12 +28,14 @@ """ from argparse import Namespace, ArgumentParser from subprocess import SubprocessError -from typing import Optional, List, Type +from typing import Optional, List, Type, Dict, Tuple import yaml +from git import GitCommandError from linktools import environ, ConfigError, utils from linktools.cli import BaseCommand, subcommand, SubCommandWrapper, subcommand_argument, SubCommandGroup +from linktools.cli.argparse import KeyValueAction from linktools.container import ContainerManager, ContainerError from linktools.rich import confirm, choose @@ -120,25 +122,36 @@ def run(self, args: Namespace) -> Optional[int]: privilege=False, ).check_call() + @subcommand("set", help="set container configs") + @subcommand_argument("configs", action=KeyValueAction, nargs="+", help="container config key=value") + def on_command_set(self, configs: Dict[str, str]): + manager.config.save_cache(**configs) + for key in sorted(configs.keys()): + value = manager.config.get(key) + self.logger.info(f"{key}: {value}") + + @subcommand("remove", help="remove container configs") + @subcommand_argument("keys", metavar="KEY", nargs="+", help="container config keys") + def on_command_remove(self, keys: List[str]): + manager.config.remove_cache(*keys) + self.logger.info(f"Remove {', '.join(keys)} success") + @subcommand("list", help="list container configs") def on_command_list(self): keys = set() - containers = manager.prepare_installed_containers() - for container in containers: + for container in manager.prepare_installed_containers(): keys.update(container.configs.keys()) + if hasattr(container, "keys") and isinstance(container.keys, (Tuple, List)): + keys.update([key for key in container.keys if key in manager.config]) for key in sorted(keys): value = manager.config.get(key) self.logger.info(f"{key}: {value}") @subcommand("reload", help="reload container configs") def on_command_reload(self): - manager.config.set("RELOAD_CONFIG", True) + manager.config.reload = True manager.prepare_installed_containers() - @subcommand("path", help="show config path") - def on_command_path(self): - print(manager.config.path, end="") - class ExecCommand(BaseCommand): """exec container command""" @@ -147,14 +160,15 @@ class ExecCommand(BaseCommand): def name(self): return "exec" - def iter_installed_container_names(self): + @classmethod + def _iter_installed_container_names(cls): containers = manager.get_installed_containers() containers = manager.resolve_depend_containers(containers) return [container.name for container in containers] def init_arguments(self, parser: ArgumentParser) -> None: parser.add_argument("exec_name", nargs="?", metavar="CONTAINER", help="container name", - choices=utils.lazy_load(self.iter_installed_container_names)) + choices=utils.lazy_load(self._iter_installed_container_names)) parser.add_argument("exec_args", nargs="...", metavar="ARGS", help="container exec args") def run(self, args: Namespace) -> Optional[int]: @@ -189,7 +203,7 @@ class Command(BaseCommand): @property def known_errors(self) -> List[Type[BaseException]]: known_errors = super().known_errors - known_errors.extend([ContainerError, ConfigError, SubprocessError]) + known_errors.extend([ContainerError, ConfigError, SubprocessError, GitCommandError, OSError]) return known_errors def init_arguments(self, parser: ArgumentParser) -> None: @@ -265,38 +279,53 @@ def on_command_info(self, names: List[str]): @subcommand("up", help="deploy installed containers") def on_command_up(self): containers = manager.prepare_installed_containers() + for container in containers: container.on_starting() - manager.create_docker_compose_process( containers, "up", "-d", "--build", "--remove-orphans" ).check_call() - for container in reversed(containers): container.on_started() @subcommand("restart", help="restart installed containers") def on_command_restart(self): containers = manager.prepare_installed_containers() - for container in containers: - container.on_starting() + for container in reversed(containers): + container.on_stopping() manager.create_docker_compose_process( containers, - "restart" + "stop" ).check_call() + for container in containers: + container.on_stopped() + for container in containers: + container.on_starting() + manager.create_docker_compose_process( + containers, + "up", "-d", "--build", "--remove-orphans" + ).check_call() for container in reversed(containers): container.on_started() @subcommand("down", help="stop installed containers") def on_command_down(self): containers = manager.prepare_installed_containers() + + for container in reversed(containers): + container.on_stopping() manager.create_docker_compose_process( containers, "down", ).check_call() + for container in containers: + container.on_stopped() + + for container in containers: + container.on_removed() command = Command() diff --git a/src/linktools/container/container.py b/src/linktools/container/container.py index 44e7d67f..3c70c824 100644 --- a/src/linktools/container/container.py +++ b/src/linktools/container/container.py @@ -223,55 +223,23 @@ def services(self) -> Dict[str, Dict[str, Any]]: return {} return services - @cached_property - def main_service(self) -> Optional[Dict[str, Any]]: - for service in self.services.values(): - if isinstance(service, dict): - return service - return None + def on_init(self): + pass - def choose_service(self) -> Optional[Dict[str, Any]]: - services = list(self.services.values()) - if len(services) == 0: - return None - if len(services) == 1: - return services[0] - index = choose( - "Please choose service", - choices=[service.get("container_name") for service in services], - default=0 - ) - return services[index] + def on_starting(self): + pass - def get_docker_compose_file(self) -> Optional[str]: - destination = None - if self.docker_compose: - destination = utils.get_path( - self.manager.temp_path, - "compose", - f"{self.name}.yml", - create_parent=True, - ) - utils.write_file( - destination, - yaml.dump(self.docker_compose) - ) - return destination + def on_started(self): + pass - def get_docker_file_path(self) -> Optional[str]: - destination = None - if self.docker_file: - destination = utils.get_path( - self.manager.temp_path, - "dockerfile", - f"{self.name}.Dockerfile", - create_parent=True, - ) - utils.write_file( - destination, - self.docker_file - ) - return destination + def on_stopping(self): + pass + + def on_stopped(self): + pass + + def on_removed(self): + pass @subcommand("shell", help="exec into container using command sh", prefix_chars=chr(0)) @subcommand_argument("args", nargs="...") @@ -333,15 +301,6 @@ def on_exec_logs(self, follow: bool = True, tail: str = None, timestamps: bool = "logs", *options, service.get("container_name") ).call() - def on_init(self): - pass - - def on_starting(self): - pass - - def on_started(self): - pass - def get_path(self, *paths: str): return utils.get_path( self.root_path, @@ -392,6 +351,49 @@ def get_temp_path(self, *paths: str, create: bool = False, create_parent: bool = create_parent=create_parent ) + def choose_service(self) -> Optional[Dict[str, Any]]: + services = list(self.services.values()) + if len(services) == 0: + return None + if len(services) == 1: + return services[0] + index = choose( + "Please choose service", + choices=[service.get("container_name") for service in services], + default=0 + ) + return services[index] + + def get_docker_compose_file(self) -> Optional[str]: + destination = None + if self.docker_compose: + destination = utils.get_path( + self.manager.temp_path, + "compose", + f"{self.name}.yml", + create_parent=True, + ) + utils.write_file( + destination, + yaml.dump(self.docker_compose) + ) + return destination + + def get_docker_file_path(self) -> Optional[str]: + destination = None + if self.docker_file: + destination = utils.get_path( + self.manager.temp_path, + "dockerfile", + f"{self.name}.Dockerfile", + create_parent=True, + ) + utils.write_file( + destination, + self.docker_file + ) + return destination + def is_depend_on(self, name: str): next_items = set(self.dependencies) exclude_items = set() diff --git a/src/linktools/container/manager.py b/src/linktools/container/manager.py index 0b2f1bf4..5e54e49d 100644 --- a/src/linktools/container/manager.py +++ b/src/linktools/container/manager.py @@ -59,9 +59,7 @@ def __init__(self, environ: "BaseEnviron", name: str = "aio"): # all_in_one self.environ = environ self.logger = environ.get_logger("container") - self.config = self.environ.wrap_config() - self.config.envvar_prefix = "" - self.config.namespace = "CONTAINER" + self.config = self.environ.wrap_config(namespace="container", prefix="") self.config.update_defaults( COMPOSE_PROJECT_NAME=name or self.environ.name, DOCKER_USER=Config.Prompt(default=os.environ.get("SUDO_USER", self.user), cached=True), @@ -70,12 +68,7 @@ def __init__(self, environ: "BaseEnviron", name: str = "aio"): # all_in_one ) self.docker_container_name = "container.py" - self.docker_compose_names = ( - "compose.yaml", - "compose.yml", - "docker-compose.yaml", - "docker-compose.yml" - ) + self.docker_compose_names = ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml") @property def debug(self) -> bool: diff --git a/src/linktools/decorator.py b/src/linktools/decorator.py index 6b859e26..99768c9a 100644 --- a/src/linktools/decorator.py +++ b/src/linktools/decorator.py @@ -46,9 +46,9 @@ def singleton(cls: "Type[T]") -> "Callable[P, T]": @functools.wraps(cls) def wrapper(*args, **kwargs): nonlocal instance - if instance is __missing__: + if instance == __missing__: with lock: - if instance is __missing__: + if instance == __missing__: instance = cls(*args, **kwargs) return instance @@ -119,11 +119,11 @@ def __get__(self, instance, owner=None): ) raise TypeError(msg) from None val = cache.get(self.attrname, __missing__) - if val is __missing__: + if val == __missing__: with self.lock: # check if another thread filled cache while we awaited lock val = cache.get(self.attrname, __missing__) - if val is __missing__: + if val == __missing__: val = self.func(instance) try: cache[self.attrname] = val @@ -159,10 +159,10 @@ def __init__(self, func): self.val = __missing__ def __get__(self, instance, owner=None): - if self.val is __missing__: + if self.val == __missing__: with self.lock: # check if another thread filled cache while we awaited lock - if self.val is __missing__: + if self.val == __missing__: self.val = self.func(owner) return self.val diff --git a/src/linktools/frida/script.py b/src/linktools/frida/script.py index d4d5ce57..2694294f 100644 --- a/src/linktools/frida/script.py +++ b/src/linktools/frida/script.py @@ -51,7 +51,7 @@ def source(self) -> Optional[str]: def load(self) -> Optional[str]: with self._lock: - if self._source is __missing__: + if self._source == __missing__: self._source = self._load() return self._source diff --git a/src/linktools/rich.py b/src/linktools/rich.py index fe496029..96b4113a 100644 --- a/src/linktools/rich.py +++ b/src/linktools/rich.py @@ -323,7 +323,7 @@ def prompt( prompt, password=password, choices=choices, - default=default if default is not __missing__ else ..., + default=default if default != __missing__ else ..., show_default=show_default, show_choices=show_choices ) @@ -344,7 +344,7 @@ def choose( if default in choices \ else __missing__ index = default \ - if default is not __missing__ and 0 <= default < len(choices) \ + if default != __missing__ and 0 <= default < len(choices) \ else 0 begin = 1 @@ -363,7 +363,7 @@ def choose( return _create_prompt_class(int, allow_empty=False).ask( text, choices=[str(i) for i in range(begin, len(choices) + begin, 1)], - default=default + begin if default is not __missing__ else ..., + default=default + begin if default != __missing__ else ..., show_default=show_default, show_choices=False, ) - begin @@ -376,6 +376,6 @@ def confirm( ) -> bool: return _create_prompt_class(bool, allow_empty=False).ask( prompt, - default=default if default is not __missing__ else ..., + default=default if default != __missing__ else ..., show_default=show_default, ) diff --git a/src/linktools/template/metadata b/src/linktools/template/metadata index 0c9f1e6c..affbe617 100644 --- a/src/linktools/template/metadata +++ b/src/linktools/template/metadata @@ -27,10 +27,16 @@ /_==__==========__==_ooo__ooo=_/' /___________," """ + +class __MissingType: + __eq__ = lambda l, r: \ + l is r or type(l) is type(r) + + __name__ = "linktools" __release__ = {{ release }} __version__ = "{{ version }}" -__missing__ = Ellipsis +__missing__ = __MissingType() __description__ = f"""\ ___ __ __ __ / (_)___ / /__/ /_____ ____ / /____ diff --git a/src/linktools/utils/_utils.py b/src/linktools/utils/_utils.py index 527b62c3..0c2489d7 100755 --- a/src/linktools/utils/_utils.py +++ b/src/linktools/utils/_utils.py @@ -188,7 +188,7 @@ def cast(type: "Type[T]", obj: Any, default: Any = __missing__) -> "Optional[T]" :param default: 默认值 :return: 转换后的值 """ - if default is __missing__: + if default == __missing__: return type(obj) try: return type(obj)