diff options
Diffstat (limited to 'tools/cru-py/cru/service/_config.py')
-rw-r--r-- | tools/cru-py/cru/service/_config.py | 446 |
1 files changed, 446 insertions, 0 deletions
diff --git a/tools/cru-py/cru/service/_config.py b/tools/cru-py/cru/service/_config.py new file mode 100644 index 0000000..b51e21c --- /dev/null +++ b/tools/cru-py/cru/service/_config.py @@ -0,0 +1,446 @@ +from collections.abc import Iterable +from typing import Any, Literal, overload + +from cru import CruException +from cru.config import Configuration, ConfigItem +from cru.value import ( + INTEGER_VALUE_TYPE, + TEXT_VALUE_TYPE, + CruValueTypeError, + RandomStringValueGenerator, + UuidValueGenerator, +) +from cru.parsing import ParseError, SimpleLineConfigParser + +from ._base import AppFeaturePath, AppCommandFeatureProvider + + +class AppConfigError(CruException): + def __init__( + self, message: str, configuration: Configuration, *args, **kwargs + ) -> None: + super().__init__(message, *args, **kwargs) + self._configuration = configuration + + @property + def configuration(self) -> Configuration: + return self._configuration + + +class AppConfigFileError(AppConfigError): + def __init__( + self, + message: str, + configuration: Configuration, + *args, + **kwargs, + ) -> None: + super().__init__(message, configuration, *args, **kwargs) + + +class AppConfigFileNotFoundError(AppConfigFileError): + def __init__( + self, + message: str, + configuration: Configuration, + file_path: str, + *args, + **kwargs, + ) -> None: + super().__init__(message, configuration, *args, **kwargs) + self._file_path = file_path + + @property + def file_path(self) -> str: + return self._file_path + + +class AppConfigFileParseError(AppConfigFileError): + def __init__( + self, + message: str, + configuration: Configuration, + file_content: str, + *args, + **kwargs, + ) -> None: + super().__init__(message, configuration, *args, **kwargs) + self._file_content = file_content + self.__cause__: ParseError + + @property + def file_content(self) -> str: + return self._file_content + + def get_user_message(self) -> str: + return f"Error while parsing config file at line {self.__cause__.line_number}." + + +class AppConfigFileEntryError(AppConfigFileError): + def __init__( + self, + message: str, + configuration: Configuration, + entries: Iterable[SimpleLineConfigParser.Entry], + *args, + **kwargs, + ) -> None: + super().__init__(message, configuration, *args, **kwargs) + self._entries = list(entries) + + @property + def error_entries(self) -> list[SimpleLineConfigParser.Entry]: + return self._entries + + @staticmethod + def entries_to_friendly_message( + entries: Iterable[SimpleLineConfigParser.Entry], + ) -> str: + return "\n".join( + f"line {entry.line_number}: {entry.key}={entry.value}" for entry in entries + ) + + @property + def friendly_message_head(self) -> str: + return "Error entries found in config file" + + def get_user_message(self) -> str: + return ( + f"{self.friendly_message_head}:\n" + f"{self.entries_to_friendly_message(self.error_entries)}" + ) + + +class AppConfigDuplicateEntryError(AppConfigFileEntryError): + @property + def friendly_message_head(self) -> str: + return "Duplicate entries found in config file" + + +class AppConfigEntryValueFormatError(AppConfigFileEntryError): + @property + def friendly_message_head(self) -> str: + return "Invalid value format for entries" + + +class AppConfigItemNotSetError(AppConfigError): + def __init__( + self, + message: str, + configuration: Configuration, + items: list[ConfigItem], + *args, + **kwargs, + ) -> None: + super().__init__(message, configuration, *args, **kwargs) + self._items = items + + +class ConfigManager(AppCommandFeatureProvider): + def __init__(self) -> None: + super().__init__("config-manager") + configuration = Configuration() + self._configuration = configuration + self._loaded: bool = False + self._init_app_defined_items() + + def _init_app_defined_items(self) -> None: + prefix = self.config_name_prefix + + def _add_text(name: str, description: str) -> ConfigItem: + item = ConfigItem(f"{prefix}_{name}", description, TEXT_VALUE_TYPE) + self.configuration.add(item) + return item + + def _add_uuid(name: str, description: str) -> ConfigItem: + item = ConfigItem( + f"{prefix}_{name}", + description, + TEXT_VALUE_TYPE, + default=UuidValueGenerator(), + ) + self.configuration.add(item) + return item + + def _add_random_string( + name: str, description: str, length: int = 32, secure: bool = True + ) -> ConfigItem: + item = ConfigItem( + f"{prefix}_{name}", + description, + TEXT_VALUE_TYPE, + default=RandomStringValueGenerator(length, secure), + ) + self.configuration.add(item) + return item + + def _add_int(name: str, description: str) -> ConfigItem: + item = ConfigItem(f"{prefix}_{name}", description, INTEGER_VALUE_TYPE) + self.configuration.add(item) + return item + + self._domain = _add_text("DOMAIN", "domain name") + self._email = _add_text("EMAIL", "admin email address") + _add_text( + "AUTO_BACKUP_COS_SECRET_ID", + "access key id for Tencent COS, used for auto backup", + ) + _add_text( + "AUTO_BACKUP_COS_SECRET_KEY", + "access key secret for Tencent COS, used for auto backup", + ) + _add_text( + "AUTO_BACKUP_COS_REGION", "region for Tencent COS, used for auto backup" + ) + _add_text( + "AUTO_BACKUP_BUCKET_NAME", + "bucket name for Tencent COS, used for auto backup", + ) + _add_text("GITHUB_USERNAME", "github username for fetching todos") + _add_int("GITHUB_PROJECT_NUMBER", "github project number for fetching todos") + _add_text("GITHUB_TOKEN", "github token for fetching todos") + _add_text("GITHUB_TODO_COUNT", "github todo count") + _add_uuid("V2RAY_TOKEN", "v2ray user id") + _add_uuid("V2RAY_PATH", "v2ray path, which will be prefixed by _") + _add_text("FORGEJO_MAILER_USER", "Forgejo SMTP user") + _add_text("FORGEJO_MAILER_PASSWD", "Forgejo SMTP password") + _add_random_string("2FAUTH_APP_KEY", "2FAuth App Key") + _add_text("2FAUTH_MAIL_USERNAME", "2FAuth SMTP user") + _add_text("2FAUTH_MAIL_PASSWORD", "2FAuth SMTP password") + + def setup(self) -> None: + self._config_file_path = self.app.data_dir.add_subpath( + "config", False, description="Configuration file path." + ) + + @property + def config_name_prefix(self) -> str: + return self.app.app_id.upper() + + @property + def configuration(self) -> Configuration: + return self._configuration + + @property + def config_file_path(self) -> AppFeaturePath: + return self._config_file_path + + @property + def all_set(self) -> bool: + return self.configuration.all_set + + def get_item(self, name: str) -> ConfigItem[Any]: + if not name.startswith(self.config_name_prefix + "_"): + name = f"{self.config_name_prefix}_{name}" + + item = self.configuration.get_or(name, None) + if item is None: + raise AppConfigError(f"Config item '{name}' not found.", self.configuration) + return item + + @overload + def get_item_value_str(self, name: str) -> str: ... + + @overload + def get_item_value_str(self, name: str, ensure_set: Literal[True]) -> str: ... + + @overload + def get_item_value_str(self, name: str, ensure_set: bool = True) -> str | None: ... + + def get_item_value_str(self, name: str, ensure_set: bool = True) -> str | None: + self.load_config_file() + item = self.get_item(name) + if not item.is_set: + if ensure_set: + raise AppConfigItemNotSetError( + f"Config item '{name}' is not set.", self.configuration, [item] + ) + return None + return item.value_str + + def get_str_dict(self, ensure_all_set: bool = True) -> dict[str, str]: + self.load_config_file() + if ensure_all_set and not self.configuration.all_set: + raise AppConfigItemNotSetError( + "Some config items are not set.", + self.configuration, + self.configuration.get_unset_items(), + ) + return self.configuration.to_str_dict() + + @property + def domain_item_name(self) -> str: + return self._domain.name + + def get_domain_value_str(self) -> str: + return self.get_item_value_str(self._domain.name) + + def get_email_value_str_optional(self) -> str | None: + return self.get_item_value_str(self._email.name, ensure_set=False) + + def _set_with_default(self) -> None: + if not self.configuration.all_not_set: + raise AppConfigError( + "Config is not clean. " + "Some config items are already set. " + "Can't set again with default value.", + self.configuration, + ) + for item in self.configuration: + if item.can_generate_default: + item.set_value(item.generate_default_value()) + + def _to_config_file_content(self) -> str: + content = "".join( + [ + f"{item.name}={item.value_str if item.is_set else ''}\n" + for item in self.configuration + ] + ) + return content + + def _create_init_config_file(self) -> None: + if self.config_file_path.check_self(): + raise AppConfigError( + "Config file already exists.", + self.configuration, + user_message=f"The config file at " + f"{self.config_file_path.full_path_str} already exists.", + ) + self._set_with_default() + self.config_file_path.ensure() + with open( + self.config_file_path.full_path, "w", encoding="utf-8", newline="\n" + ) as file: + file.write(self._to_config_file_content()) + + def _parse_config_file(self) -> SimpleLineConfigParser.Result: + if not self.config_file_path.check_self(): + raise AppConfigFileNotFoundError( + "Config file not found.", + self.configuration, + self.config_file_path.full_path_str, + user_message=f"The config file at " + f"{self.config_file_path.full_path_str} does not exist. " + f"You can create an initial one with 'init' command.", + ) + + text = self.config_file_path.full_path.read_text() + try: + parser = SimpleLineConfigParser() + return parser.parse(text) + except ParseError as e: + raise AppConfigFileParseError( + "Failed to parse config file.", self.configuration, text + ) from e + + def _parse_and_print_config_file(self) -> None: + parse_result = self._parse_config_file() + for entry in parse_result: + print(f"{entry.key}={entry.value}") + + def _check_duplicate( + self, + parse_result: dict[str, list[SimpleLineConfigParser.Entry]], + ) -> dict[str, SimpleLineConfigParser.Entry]: + entry_dict: dict[str, SimpleLineConfigParser.Entry] = {} + duplicate_entries: list[SimpleLineConfigParser.Entry] = [] + for key, entries in parse_result.items(): + entry_dict[key] = entries[0] + if len(entries) > 1: + duplicate_entries.extend(entries) + if len(duplicate_entries) > 0: + raise AppConfigDuplicateEntryError( + "Duplicate entries found.", self.configuration, duplicate_entries + ) + + return entry_dict + + def _check_type( + self, entry_dict: dict[str, SimpleLineConfigParser.Entry] + ) -> dict[str, Any]: + value_dict: dict[str, Any] = {} + error_entries: list[SimpleLineConfigParser.Entry] = [] + errors: list[CruValueTypeError] = [] + for key, entry in entry_dict.items(): + config_item = self.configuration.get(key) + try: + if entry.value == "": + value_dict[key] = None + else: + value_dict[key] = config_item.value_type.convert_str_to_value( + entry.value + ) + except CruValueTypeError as e: + error_entries.append(entry) + errors.append(e) + if len(error_entries) > 0: + raise AppConfigEntryValueFormatError( + "Entry value format is not correct.", + self.configuration, + error_entries, + ) from ExceptionGroup("Multiple format errors occurred.", errors) + return value_dict + + def _read_config_file(self) -> dict[str, Any]: + parsed = self._parse_config_file() + entry_groups = parsed.cru_iter().group_by(lambda e: e.key) + entry_dict = self._check_duplicate(entry_groups) + value_dict = self._check_type(entry_dict) + return value_dict + + def _real_load_config_file(self) -> None: + self.configuration.reset_all() + value_dict = self._read_config_file() + for key, value in value_dict.items(): + if value is None: + continue + self.configuration.set_config_item(key, value) + + def load_config_file(self, force=False) -> None: + if force or not self._loaded: + self._real_load_config_file() + self._loaded = True + + def _print_app_config_info(self): + for item in self.configuration: + print(item.description_str) + + def get_command_info(self): + return "config", "Manage configuration." + + def setup_arg_parser(self, arg_parser) -> None: + subparsers = arg_parser.add_subparsers( + dest="config_command", required=True, metavar="CONFIG_COMMAND" + ) + _init_parser = subparsers.add_parser( + "init", help="create an initial config file" + ) + _print_app_parser = subparsers.add_parser( + "print-app", + help="print information of the config items defined by app", + ) + _print_parser = subparsers.add_parser("print", help="print current config") + _check_config_parser = subparsers.add_parser( + "check", + help="check the validity of the config file", + ) + _check_config_parser.add_argument( + "-f", + "--format-only", + action="store_true", + help="only check content format, not app config item requirements.", + ) + + def run_command(self, args) -> None: + if args.config_command == "init": + self._create_init_config_file() + elif args.config_command == "print-app": + self._print_app_config_info() + elif args.config_command == "print": + self._parse_and_print_config_file() + elif args.config_command == "check": + if args.format_only: + self._parse_config_file() + else: + self._read_config_file() |