diff --git a/.python-version b/.python-version deleted file mode 100644 index d70c8f8d..00000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.6 diff --git a/Makefile b/Makefile index d45507b8..8511f143 100644 --- a/Makefile +++ b/Makefile @@ -136,11 +136,11 @@ lint: isort black flake8 pylint mypy .PHONY: isort isort: - isort --check --diff . + isort --check --diff src tests .PHONY: black black: - black --check --diff . + black --check --diff src tests .PHONY: flake8 flake8: @@ -157,8 +157,8 @@ mypy: .PHONY: format format: - isort . - black . + isort src tests + black src tests .PHONY: help diff --git a/src/ch_tools/chadmin/chadmin_cli.py b/src/ch_tools/chadmin/chadmin_cli.py index 2aff068e..27fb7edf 100755 --- a/src/ch_tools/chadmin/chadmin_cli.py +++ b/src/ch_tools/chadmin/chadmin_cli.py @@ -10,6 +10,7 @@ import cloup +from ch_tools import __version__ from ch_tools.chadmin.cli.chs3_backup_group import chs3_backup_group from ch_tools.chadmin.cli.config_command import config_command from ch_tools.chadmin.cli.crash_log_group import crash_log_group @@ -42,6 +43,7 @@ from ch_tools.chadmin.cli.wait_started_command import wait_started_command from ch_tools.chadmin.cli.zookeeper_group import zookeeper_group from ch_tools.common.cli.context_settings import CONTEXT_SETTINGS +from ch_tools.common.cli.locale_resolver import LocaleResolver from ch_tools.common.cli.parameters import TimeSpanParamType LOG_FILE = "/var/log/chadmin/chadmin.log" @@ -71,6 +73,7 @@ @cloup.option("--timeout", type=TimeSpanParamType(), help="Timeout for SQL queries.") @cloup.option("--port", type=int, help="Port to connect.") @cloup.option("-d", "--debug", is_flag=True, help="Enable debug output.") +@cloup.version_option(__version__) @cloup.pass_context def cli(ctx, format_, settings, timeout, port, debug): """ClickHouse administration tool.""" @@ -147,4 +150,9 @@ def main(): """ Program entry point. """ + LocaleResolver.resolve() cli.main() + + +if __name__ == "__main__": + main() diff --git a/src/ch_tools/common/cli/locale_resolver.py b/src/ch_tools/common/cli/locale_resolver.py new file mode 100644 index 00000000..f63b2b4d --- /dev/null +++ b/src/ch_tools/common/cli/locale_resolver.py @@ -0,0 +1,81 @@ +import locale +import os +import subprocess +from typing import List, Tuple + +__all__ = [ + "LocaleResolver", +] + + +class LocaleResolver: + """ + Sets the locale for Click. Otherwise, it may fail with an error like + + ``` + RuntimeError: Click discovered that you exported a UTF-8 locale + but the locale system could not pick up from it because it does not exist. + The exported locale is 'en_US.UTF-8' but it is not supported. + ``` + """ + + @staticmethod + def resolve(): + lang, _ = locale.getlocale() + locales, has_c, has_en_us = LocaleResolver._get_utf8_locales() + + langs = map(lambda loc: str.lower(loc[0]), locales) + if lang is None or lang.lower() not in langs: + if has_c: + lang = "C" + elif has_en_us: + lang = "en_US" + else: + raise RuntimeError( + f'Locale "{lang}" is not supported. ' + 'We tried to use "C" and "en_US" but they\'re absent on your machine.', + ) + + for locale_ in locales: + if lang != locale_[0]: + continue + + os.environ["LC_ALL"] = f"{lang}.{locale_[1]}" + os.environ["LANG"] = f"{lang}.{locale_[1]}" + + @staticmethod + def _get_utf8_locales() -> Tuple[List[Tuple[str, str]], bool, bool]: + try: + with subprocess.Popen( + ["locale", "-a"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="ascii", + errors="replace", + ) as proc: + stdout, _ = proc.communicate() + except OSError: + stdout = "" + + langs = [] + encodings = [] + + has_c = False + has_en_us = False + + for line in stdout.splitlines(): + locale_ = line.strip() + if not locale_.lower().endswith(("utf-8", "utf8")): + continue + + lang, encoding = locale_.split(".") + + langs.append(lang) + encodings.append(encoding) + + has_c |= lang.lower() == "c" + has_en_us |= lang.lower() == "en_us" + + res = list(zip(langs, encodings)) + + return res, has_c, has_en_us diff --git a/src/ch_tools/monrun_checks/main.py b/src/ch_tools/monrun_checks/main.py index 19d78a88..10ce1c4d 100644 --- a/src/ch_tools/monrun_checks/main.py +++ b/src/ch_tools/monrun_checks/main.py @@ -5,13 +5,18 @@ import sys import warnings from functools import wraps +from typing import Optional warnings.filterwarnings(action="ignore", message="Python 3.6 is no longer supported") # pylint: disable=wrong-import-position import click +import cloup +from ch_tools import __version__ +from ch_tools.common.cli.context_settings import CONTEXT_SETTINGS +from ch_tools.common.cli.locale_resolver import LocaleResolver from ch_tools.common.result import Status from ch_tools.monrun_checks.ch_backup import backup_command from ch_tools.monrun_checks.ch_core_dumps import core_dumps_command @@ -33,13 +38,30 @@ LOG_FILE = "/var/log/clickhouse-monitoring/clickhouse-monitoring.log" DEFAULT_USER = "monitor" +# pylint: disable=too-many-ancestors + + +class MonrunChecks(cloup.Group): + def add_command( + self, + cmd: click.Command, + name: Optional[str] = None, + section: Optional[cloup.Section] = None, + fallback_to_default_section: bool = True, + ) -> None: + if cmd.callback is None: + super().add_command( + cmd, + name=name, + section=section, + fallback_to_default_section=fallback_to_default_section, + ) + return -class MonrunChecks(click.Group): - def add_command(self, cmd, name=None): cmd_callback = cmd.callback @wraps(cmd_callback) - @click.pass_context + @cloup.pass_context def callback_wrapper(ctx, *args, **kwargs): os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) logging.basicConfig( @@ -75,23 +97,26 @@ def callback_wrapper(ctx, *args, **kwargs): status.report() cmd.callback = callback_wrapper - super().add_command(cmd, name=name) + super().add_command( + cmd, + name=name, + section=section, + fallback_to_default_section=fallback_to_default_section, + ) -@click.group( +@cloup.group( cls=MonrunChecks, - context_settings={ - "help_option_names": ["-h", "--help"], - "terminal_width": 120, - }, + context_settings=CONTEXT_SETTINGS, ) -@click.option( +@cloup.option( "--no-user-check", "no_user_check", is_flag=True, default=False, help="Do not check current user.", ) +@cloup.version_option(__version__) def cli(no_user_check): if not no_user_check: check_current_user() @@ -124,8 +149,8 @@ def main(): """ Program entry point. """ - # pylint: disable=no-value-for-parameter - cli() + LocaleResolver.resolve() + cli.main() def check_current_user(): @@ -148,3 +173,7 @@ def check_current_user(): except Exception as exc: print(repr(exc), file=sys.stderr) sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/ch_tools/monrun_checks_keeper/main.py b/src/ch_tools/monrun_checks_keeper/main.py index 32bfbc10..81a1a7a3 100644 --- a/src/ch_tools/monrun_checks_keeper/main.py +++ b/src/ch_tools/monrun_checks_keeper/main.py @@ -1,10 +1,14 @@ import logging import os from functools import wraps +from typing import Optional import click -from click import group, option, pass_context +import cloup +from ch_tools import __version__ +from ch_tools.common.cli.context_settings import CONTEXT_SETTINGS +from ch_tools.common.cli.locale_resolver import LocaleResolver from ch_tools.common.result import Status from ch_tools.monrun_checks_keeper.keeper_commands import ( alive_command, @@ -21,13 +25,30 @@ LOG_FILE = "/var/log/keeper-monitoring/keeper-monitoring.log" +# pylint: disable=too-many-ancestors + + +class KeeperChecks(cloup.Group): + def add_command( + self, + cmd: click.Command, + name: Optional[str] = None, + section: Optional[cloup.Section] = None, + fallback_to_default_section: bool = True, + ) -> None: + if cmd.callback is None: + super().add_command( + cmd, + name=name, + section=section, + fallback_to_default_section=fallback_to_default_section, + ) + return -class KeeperChecks(click.Group): - def add_command(self, cmd, name=None): cmd_callback = cmd.callback @wraps(cmd_callback) - @click.pass_context + @cloup.pass_context def wrapper(ctx, *a, **kw): os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) logging.basicConfig( @@ -58,12 +79,22 @@ def wrapper(ctx, *a, **kw): status.report() cmd.callback = wrapper - super().add_command(cmd, name=name) + super().add_command( + cmd, + name=name, + section=section, + fallback_to_default_section=fallback_to_default_section, + ) -@group(cls=KeeperChecks, context_settings={"help_option_names": ["-h", "--help"]}) -@option("-r", "--retries", "retries", type=int, default=3, help="Number of retries") -@option( +@cloup.group( + cls=KeeperChecks, + context_settings=CONTEXT_SETTINGS, +) +@cloup.option( + "-r", "--retries", "retries", type=int, default=3, help="Number of retries" +) +@cloup.option( "-t", "--timeout", "timeout", @@ -71,7 +102,7 @@ def wrapper(ctx, *a, **kw): default=0.5, help="Connection timeout (in seconds)", ) -@option( +@cloup.option( "-n", "--no-verify-ssl-certs", "no_verify_ssl_certs", @@ -79,7 +110,8 @@ def wrapper(ctx, *a, **kw): default=False, help="Allow unverified SSL certificates, e.g. self-signed ones", ) -@pass_context +@cloup.version_option(__version__) +@cloup.pass_context def cli(ctx, retries, timeout, no_verify_ssl_certs): ctx.obj = dict( retries=retries, timeout=timeout, no_verify_ssl_certs=no_verify_ssl_certs @@ -108,5 +140,9 @@ def main(): """ Program entry point. """ - # pylint: disable=no-value-for-parameter - cli() + LocaleResolver.resolve() + cli.main() + + +if __name__ == "__main__": + main()