diff --git a/README-frontend.en.md b/.github/README-frontend.en.md similarity index 100% rename from README-frontend.en.md rename to .github/README-frontend.en.md diff --git a/README.en.md b/.github/README.md similarity index 82% rename from README.en.md rename to .github/README.md index 44c3b081b6d4daed0754641e690e6326d0c4e876..59547f361d873c29d385b7441f212e416c5848ee 100644 --- a/README.en.md +++ b/.github/README.md @@ -1,6 +1,8 @@ # clitheme - Command line customization utility -[中文](./README.md) | **English** +[中文](../README.md) | **English** + +**Disclaimer:** Please do not use this tool to create harmful or illegal content. The author of this software does not take any responsibility for content and definition files created by others. --- @@ -19,9 +21,9 @@ test.c:4:3: warning: incompatible pointer types assigning to 'char *' from 'int ``` ```plaintext $ clitheme apply-theme clang-theme.clithemedef.txt -==> Generating data... -Successfully generated data -==> Applying theme...Success +==> Processing files... +Successfully processed files +==> Applying theme... Theme applied successfully ``` ```plaintext @@ -52,7 +54,7 @@ Other characteristics: For more information, please see the project's Wiki documentation page. It can be accessed through the following links: -- https://gitee.com/swiftycode/clitheme/wikis/pages +- https://gitee.com/swiftycode/clitheme/wikis - https://gitee.com/swiftycode/clitheme-wiki-repo - https://github.com/swiftycode256/clitheme-wiki-repo @@ -64,10 +66,10 @@ Get the command line output, including any terminal control characters: ```plaintext # --debug: Add a marker at the beginning of each line; contains information on whether the output is stdout/stderr ("o>" or "e>") -# --debug-showchars: Show terminal control characters in the output -# --debug-nosubst: Even if a theme is set, do not apply substitution rules (get original output content) +# --showchars: Show terminal control characters in the output +# --nosubst: Even if a theme is set, do not apply substitution rules (get original output content) -$ clitheme-exec --debug --debug-showchars --debug-nosubst clang test.c +$ clitheme-exec --debug --showchars --nosubst clang test.c e> {{ESC}}[1mtest.c:1:1: {{ESC}}[0m{{ESC}}[0;1;31merror: {{ESC}}[0m{{ESC}}[1munknown type name 'bool'{{ESC}}[0m\r\n e> bool *func(int *a) {\r\n e> {{ESC}}[0;1;32m^\r\n @@ -82,7 +84,7 @@ Write theme definition file and substitution rules based on the output: ```plaintext # Define basic information for this theme in header_section; required {header_section} - # It is recommended to include name and description at the minimum + # `name` is a required entry in header_section name clang example theme [description] An example theme for clang (for demonstration purposes) @@ -99,14 +101,14 @@ Write theme definition file and substitution rules based on the output: gcc g++ [/filter_commands] - [substitute_regex] (?P^({{ESC}}.*?m)*(.+:\d+:\d+:) ({{ESC}}.*?m)*)warning: (?P({{ESC}}.*?m)*)incompatible pointer types assigning to '(?P.+)' from '(?P.+)' + [subst_regex] (?P^({{ESC}}.*?m)*(.+:\d+:\d+:) ({{ESC}}.*?m)*)warning: (?P({{ESC}}.*?m)*)incompatible pointer types assigning to '(?P.+)' from '(?P.+)' # Use "locale:en_US" if you only want the substitution rule to applied when the system locale setting is English (en_US) # Use "locale:default" to not apply any locale filters locale:default \gnote: \gincompatible pointer types '\g' and '\g', they're so……so incompatible!~ - [/substitute_regex] - [substitute_regex] (?P^({{ESC}}.*?m)*(.+:\d+:\d+:) ({{ESC}}.*?m)*)error: (?P({{ESC}}.*?m)*)unknown type name '(?P.+)' + [/subst_regex] + [subst_regex] (?P^({{ESC}}.*?m)*(.+:\d+:\d+:) ({{ESC}}.*?m)*)error: (?P({{ESC}}.*?m)*)unknown type name '(?P.+)' locale:default \gError! : \gunknown type name '\g', you forgot to d……define it!~ಥ_ಥ - [/substitute_regex] + [/subst_regex] {/substrules_section} ``` @@ -169,11 +171,21 @@ Please see [this article](./README-frontend.en.md) `clitheme` can be installed through pip package, Debian package, and Arch Linux package. -### Install using pip package +### Install using Python/pip package + +First, ensure that Python 3 is installed on the system. `clitheme` requires Python 3.8 or higher. + +- On Linux distributions, you can use relevant package manager to install +- On macOS, you can install Python through Xcode command line developer tools (use `xcode-select --install` command), or through Python website ( https://www.python.org/downloads ) +- On Windows, you can install Python through Microsoft Store ([Python 3.13 link](https://apps.microsoft.com/detail/9pnrbtzxmb4z)), or through Python website ( https://www.python.org/downloads ) + +Then, ensure that `pip` is installed within Python. The following command will perform an offline install of `pip` if it's not detected. + + $ python3 -m ensurepip Download the `.whl` file from latest distribution page and install it using `pip`: - $ pip install ./clitheme--py3-none-any.whl + $ python3 -m pip install ./clitheme--py3-none-any.whl ### Install using Arch Linux package @@ -195,7 +207,7 @@ You can build the package from the repository source code, which includes any la First, install `setuptools`, `build`, and `wheel` packages. You can use the packages provided by your Linux distribution, or install using `pip`: - $ pip install --upgrade setuptools build wheel + $ python3 -m pip install --upgrade setuptools build wheel Then, switch to project directory and use the following command to build the package: diff --git a/PKGBUILD b/PKGBUILD index 570264f64b39d3e306991ba276eec17474bc067a..dad4485e58624bb33a09245e57d38bb5f449d661 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: swiftycode <3291929745@qq.com> pkgname='clitheme' -pkgver=2.0_beta2 +pkgver=2.0_beta3 pkgrel=1 pkgdesc="A text theming library for command line applications" arch=('any') diff --git a/README.md b/README.md index dd6ddda4a20c973869507bb6bfed146bf967051c..09be97df25c53a115b32ad44b5945451d14cfacb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # clitheme - 命令行自定义工具 -**中文** | [English](./README.en.md) +**中文** | [English](.github/README.md) + +**免责声明:** 请不要利用该软件传播有害或违法内容。本软件作者对其他人制作的内容和定义文件不负任何责任。 --- @@ -19,9 +21,9 @@ test.c:4:3: warning: incompatible pointer types assigning to 'char *' from 'int ``` ```plaintext $ clitheme apply-theme clang-theme.clithemedef.txt -==> Generating data... -Successfully generated data -==> Applying theme...Success +==> Processing files... +Successfully processed files +==> Applying theme... Theme applied successfully ``` ```plaintext @@ -51,7 +53,7 @@ test.c:4:3: 提示: 'char *'从不兼容的指针类型赋值为'int *',两者 - 无需应用程序API也可以访问当前主题中的字符串定义(易懂的数据结构) 更多信息请见本项目的Wiki文档页面。你可以通过以下位置访问这些文档: -- https://gitee.com/swiftycode/clitheme/wikis/pages +- https://gitee.com/swiftycode/clitheme/wikis - https://gitee.com/swiftycode/clitheme-wiki-repo - https://github.com/swiftycode256/clitheme-wiki-repo @@ -63,10 +65,10 @@ test.c:4:3: 提示: 'char *'从不兼容的指针类型赋值为'int *',两者 ```plaintext # --debug:在每一行的输出前添加标记;包含输出是否为stdout或stderr的信息("o>"或"e>") -# --debug-showchars:显示输出中的终端控制符号 -# --debug-nosubst:即使设定了主题,不对输出应用替换规则(获取原始输出) +# --showchars:显示输出中的终端控制符号 +# --nosubst:即使设定了主题,不对输出应用替换规则(获取原始输出) -$ clitheme-exec --debug --debug-showchars --debug-nosubst clang test.c +$ clitheme-exec --debug --showchars --nosubst clang test.c e> {{ESC}}[1mtest.c:1:1: {{ESC}}[0m{{ESC}}[0;1;31merror: {{ESC}}[0m{{ESC}}[1munknown type name 'bool'{{ESC}}[0m\r\n e> bool *func(int *a) {\r\n e> {{ESC}}[0;1;32m^\r\n @@ -81,7 +83,7 @@ e> {{ESC}}[0m2 errors generated.\r\n ```plaintext # 在header_section中定义一些关于该主题定义的基本信息;必须包括 {header_section} - # 这里建议至少包括name和description信息 + # 在header_section中必须定义`name`条目 name clang样例主题 [description] 一个为clang打造的的样例主题,为了演示作用 @@ -98,14 +100,14 @@ e> {{ESC}}[0m2 errors generated.\r\n gcc g++ [/filter_commands] - [substitute_regex] (?P^({{ESC}}.*?m)*(.+:\d+:\d+:) ({{ESC}}.*?m)*)warning: (?P({{ESC}}.*?m)*)incompatible pointer types assigning to '(?P.+)' from '(?P.+)' + [subst_regex] (?P^({{ESC}}.*?m)*(.+:\d+:\d+:) ({{ESC}}.*?m)*)warning: (?P({{ESC}}.*?m)*)incompatible pointer types assigning to '(?P.+)' from '(?P.+)' # 如果你想仅在系统语言设定为中文(zh_CN)时应用这个替换规则,你可以使用"locale:zh_CN" # 使用"locale:default"时不会添加系统语言限制 locale:default \g提示: \g'\g'从不兼容的指针类型赋值为'\g',两者怎么都……都说不过去!^^; - [/substitute_regex] - [substitute_regex] (?P^({{ESC}}.*?m)*(.+:\d+:\d+:) ({{ESC}}.*?m)*)error: (?P({{ESC}}.*?m)*)unknown type name '(?P.+)' + [/subst_regex] + [subst_regex] (?P^({{ESC}}.*?m)*(.+:\d+:\d+:) ({{ESC}}.*?m)*)error: (?P({{ESC}}.*?m)*)unknown type name '(?P.+)' locale:default \g错误!: \g未知的类型名'\g',忘记定义了~ಥ_ಥ - [/substitute_regex] + [/subst_regex] {/substrules_section} ``` @@ -168,11 +170,21 @@ $ clitheme-man ls 安装`clitheme`非常简单,您可以通过pip软件包,Arch Linux软件包,或者Debian软件包安装。 -### 通过pip软件包安装 +### 通过Python/pip软件包安装 + +首先,确保Python 3已安装在系统中。`clitheme`需要Python 3.8或更高版本。 + +- 在Linux发行版上,你可以通过对应的软件包管理器安装Python +- 在macOS上,你可以通过Xcode命令行开发者工具安装Python(使用`xcode-select --install`命令),或者通过Python官网( https://www.python.org/downloads )下载 +- 在Windows上,你可以通过Microsoft Store安装Python([Python 3.13链接](https://apps.microsoft.com/detail/9pnrbtzxmb4z)),或者通过Python官网( https://www.python.org/downloads )下载 + +然后,确保`pip`软件包管理器已安装在Python中。以下命令将会通过本地安装`pip`,如果检测到没有安装。 + + $ python3 -m ensurepip 从最新发行版页面下载`.whl`文件,使用`pip`直接安装即可: - $ pip install ./clitheme--py3-none-any.whl + $ python3 -m pip install ./clitheme--py3-none-any.whl ### 通过Arch Linux软件包安装 @@ -194,7 +206,7 @@ $ clitheme-man ls 首先,安装`setuptools`、`build`、和`wheel`软件包。你可以通过你使用的Linux发行版提供的软件包,或者使用以下命令通过`pip`安装: - $ pip install --upgrade setuptools build wheel + $ python3 -m pip install --upgrade setuptools build wheel 然后,切换到项目目录,使用以下命令构建软件包: diff --git a/cspell.json b/cspell.json index f1a7d2a40dc6c6b28ac785e9765f6be6ec886c5a..3c0e3f733f85810085e7c8a84006f2d9037fa198 100644 --- a/cspell.json +++ b/cspell.json @@ -15,11 +15,11 @@ "exactcmdmatch", "smartcmdmatch", "endmatchhere", "leadtabindents", "leadspaces", "strictcmdmatch", "normalcmdmatch", "exactcmdmatch", "smartcmdmatch", - "subststdoutonly", "subststderronly", "substall", "foregroundonly", + "subststdoutonly", "subststderronly", "substallstreams", "foregroundonly", "substvar", "substesc", "PKGBUILD", "MANPATH", "tcgetattr", "tcsetattr", "getpid", "setsid", "setitimer", "tcgetpgrp", "tcsetpgrp", - "TIOCGWINSZ", "TIOCSWINSZ", "TCSADRAIN", "SIGWINCH", "SIGALRM", "ITIMER", "SIGTSTP", "SIGSTOP", "SIGCONT", + "TIOCGWINSZ", "TIOCSWINSZ", "TCSADRAIN", "SIGWINCH", "SIGALRM", "ITIMER", "SIGTSTP", "SIGSTOP", "SIGCONT", "SIGUSR1", "SIGUSR2", "showchars", "keepends", "appname", "domainapp", "sanitycheck", "debugmode", "splitarray", "disablelang" diff --git a/debian/changelog b/debian/changelog index 90b611f89a6d440e41fcc6f3767e98113b6bef1a..00f5e8ad84cf21a7e6acc2bcfc7d65d72cd4cee6 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,9 +1,45 @@ -clitheme (2.0-beta2-1) unstable; urgency=low +clitheme (2.0-beta3-1) unstable; urgency=medium New features - * Support specifying multiple `[file_content]` phrases and filenames in `{manpage_section}`: - * New `[include_file]` syntax in `{manpage_section}`, supporting specifying multiple target file paths at once: + * Support using content variables in option definitions + * Show prompt when reading files and message when reading from standard input in CLI interface + * CLI interface: `get-current-theme-info` supports only displaying theme name and source file path for a more concise output + * Rename certain options in `clitheme-exec` + * A warning is now displayed when trying to reference a content variable without enabling the `substvar` option + * Add new functions for modifying global default settings only for current file in `frontend` module, keeping separate settings for different files + * Add `set_local_themedefs` function in `frontend` module, supporting specifying multiple files at once + * Add `!require_version` definition in theme definition file, requiring minimum `clitheme` version + * Support specifying content variables in `locale:` syntax + * Support using more concise `[subst_regex]` and `[subst_string]` syntax + * Support `--name` and `--file-path` options in `clitheme get-current-theme-info` command + * Support using `--yes` option to skip confirmation prompt in `clitheme apply-theme` and `clitheme update-theme` commands + + Bug fixes and improvements + + * clitheme-exec: Specifying `--debug-newlines` option requires `--debug` option to be specified at the same time + * Add more restricted characters to string entry pathnames + * Change how the `foregroundonly` option work: it now applies to individual substitution rules + - If specified in a command filter rule (specified after `[/filter_commands]`), it will reset to previous global setting after exiting current rule + * Fix an issue where read segments repeatedly occur at non-newline positions + * `clitheme-exec`: Ignore backspace characters with identical user input from output substitution processing + * Rename "Generating data" prompt to "Processing files" + * Fix an issue where outputs of `clitheme get-current-theme-info` and `clitheme update-theme` command have incorrect file ordering + * `clitheme-exec`: Properly handle piped standard input + * `{header_section}` now requires defining `name` entry + * `clitheme-exec`: Fix high idle CPU usage + * `clitheme-exec`: Database fetches are now cached in memory, significantly improving performance + * Fix an issue when in multi-line block content and `substesc` option is enabled, `{{ESC}}` in content variables might not be processed + * Important change: `endmatchhere` option only applies to substitution rules in the current file; other files are not affected + + -- swiftycode <3291929745@qq.com> Wed, 01 Jan 2025 11:54:00 +0800 + +clitheme (2.0-beta2-1) unstable; urgency=medium + + New features + + * Support specifying multiple `[file_content]` phrases and filenames in `{manpage_section}` + * New `[include_file]` syntax in `{manpage_section}`, supporting specifying multiple target file paths at once * New `foregroundonly` option for `filter_command` and `[filter_commands]` in `{substrules_section}`; only apply substitution rules if process is in foreground state * Applicable for shell and other command interpreter applications that changes foreground state when executing processes * `clitheme-exec`: new `--debug-foreground` option; outputs message when foreground state changes (shown as `! Foreground: False` and `! Foreground: True`) diff --git a/debian/rules b/debian/rules index fa827b553514669c418717c1d960b62596b3b5e0..dba9095b74c6abf9f35efba169046ec54206267b 100755 --- a/debian/rules +++ b/debian/rules @@ -10,3 +10,4 @@ override_dh_auto_install: dh_auto_install # remove __pycache__ -rm -r debian/$(PYBUILD_NAME)/usr/lib/python3/dist-packages/$(PYBUILD_NAME)/__pycache__ + -rm -r debian/$(PYBUILD_NAME)/usr/lib/python3/dist-packages/$(PYBUILD_NAME)/*/__pycache__ diff --git a/docs/clitheme-exec.1 b/docs/clitheme-exec.1 index 8016e8c646497afbaad59a14b8cf1af783133b46..047e489cd5ca6f15f541801da608cbeffbe94fc5 100644 --- a/docs/clitheme-exec.1 +++ b/docs/clitheme-exec.1 @@ -2,7 +2,7 @@ .SH NAME clitheme\-exec \- match and substitute output of a command .SH SYNOPSIS -.B clitheme-exec [--debug] [--debug-color] [--debug-newlines] [--debug-showchars] \fIcommand\fR +.B clitheme-exec [--debug] [--debug-color] [--debug-newlines] [--showchars] [--foreground-stat] [--nosubst] \fIcommand\fR .SH DESCRIPTION \fIclitheme-exec\fR substitutes the output of the specified command with substitution rules defined through a theme definition file. The current theme definition on the system is controlled through \fIclitheme(1)\fR. .SH OPTIONS @@ -24,7 +24,7 @@ For stdout, yellow color is applied. For stderr, red color is applied. .B --debug-newlines For output that does not end on a newline, display the output ending with newlines. .TP -.B --debug-showchars +.B --showchars Display various control characters in plain text. The following characters will be displayed as its code name: .P .RS 14 @@ -39,7 +39,7 @@ Display various control characters in plain text. The following characters will - Bell character (\\x07) .RE .TP -.B --debug-foreground +.B --foreground-stat When the foreground status of the main process changes (determined using value of \fItcgetpgrp(3)\fR system call), output a message showing this change. Such change happens when running a shell in \fIclitheme-exec\fR and running another command in that shell. @@ -50,10 +50,10 @@ Such change happens when running a shell in \fIclitheme-exec\fR and running anot - "! Foreground: True ()": Process enters (re-enters) foreground state .RE .TP -.B --debug-nosubst +.B --nosubst Even if a theme is set, do not perform any output substitution operations. -This is useful if you are trying to get the original output of the command with control characters displayed on-screen using \fI--debug-showchars\fR. +This is useful if you are trying to get the original output of the command with control characters displayed on-screen using \fI--showchars\fR. .SH SEE ALSO \fIclitheme(1)\fR \ No newline at end of file diff --git a/docs/clitheme.1 b/docs/clitheme.1 index c64e7577969a8d96b98ca574bafdb3b92676f0d9..14ad49ac4fdff6963400eaf70a54fb8743279745 100644 --- a/docs/clitheme.1 +++ b/docs/clitheme.1 @@ -1,4 +1,4 @@ -.TH clitheme 1 2024-06-17 +.TH clitheme 1 2024-12-19 .SH NAME clitheme \- frontend to customize output of applications .SH SYNOPSIS @@ -7,25 +7,38 @@ clitheme \- frontend to customize output of applications \fIclitheme\fR is a framework for applications that allows users to customize its output and messages through theme definition files. This CLI interface allows the user to control their current settings and theme definition on the system. .SH OPTIONS .TP -.B apply-theme [themedef-file(s)] [--overlay] [--preserve-temp] +.B apply-theme [themedef-file] [--overlay] [--preserve-temp] [--yes] Applies the given theme definition file(s) into the current system. Supported applications will immediately start using the defined values after performing this operation. -Specify \fB--overlay\fR to append value definitions in the file(s) onto the current data. +Specify \fB--overlay\fR to to add file(s) onto the current data. + +Specify \fB--preserve-temp\fR to preserve the temporary directory after the operation. (Debug purposes only) + +Specify \fB--yes\fR to skip the confirmation prompt. -Specify \fB--preserve-temp\fR to prevent the temporary directory from removed after the operation. .TP -.B get-current-theme-info +.B get-current-theme-info [--name] [--file-path] Outputs detailed information about the currently applied theme. If multiple themes are applied using the \fB--overlay\fR option, outputs detailed information for each applied theme. + +Specify \fB--name\fR to only display the name of each theme. + +Specify \fB--file-path\fR to only display the source file path of each theme. + +(Both will be displayed when both specified) + .TP .B unset-current-theme Removes the current theme data from the system. Supported applications will immediately stop using the defined values after this operation. .TP -.B update-theme +.B update-theme [--yes] Re-applies the theme definition files specified in the previous "apply-theme" command (previous commands if \fB--overlay\fR is used) Calling this command is equivalent to calling "clitheme apply-theme" with previously-specified files. + +Specify \fB--yes\fR to skip the confirmation prompt. + .TP -.B generate-data [themedef-file(s)] [--overlay] +.B generate-data [themedef-file] [--overlay] Generates the data hierarchy for the given theme definition file(s). This operation generates the same data as \fBapply-theme\fR, but does not apply it onto the system. This command is for debug purposes only. diff --git a/frontend_demo.py b/frontend_demo.py index 713a1c757e690580b4eb15379e63e98371dd0892..48635cf1ef2412cfe08d12daea2b34d1112239b2 100755 --- a/frontend_demo.py +++ b/frontend_demo.py @@ -50,8 +50,8 @@ com.example example-app helpmessage unknown-command 错误:未知命令"{}" """ -frontend.global_domain="com.example" -frontend.global_appname="example-app" +frontend.set_domain("com.example") +frontend.set_appname("example-app") f=frontend.FetchDescriptor() if len(sys.argv)>1 and sys.argv[1]=="install-files": diff --git a/frontend_fallback.py b/frontend_fallback.py index f07bb0af8cf36cf5900d2415893a9e23535d5ea4..0b4f1fbfe9b9398a6d4b465607df5616ff693814 100644 --- a/frontend_fallback.py +++ b/frontend_fallback.py @@ -1,7 +1,7 @@ """ clitheme fallback frontend for version 2.0 (returns fallback values for all functions) """ -from typing import Optional +from typing import Optional, List data_path="" @@ -12,6 +12,13 @@ global_debugmode=False global_lang="" global_disablelang=False +def set_domain(value: Optional[str]): pass +def set_appname(value: Optional[str]): pass +def set_subsections(value: Optional[str]): pass +def set_debugmode(value: Optional[bool]): pass +def set_lang(value: Optional[str]): pass +def set_disablelang(value: Optional[bool]): pass + alt_path=None alt_path_dirname=None alt_path_hash=None @@ -19,6 +26,9 @@ alt_path_hash=None def set_local_themedef(file_content: str, overlay: bool=False) -> bool: """Fallback set_local_themedef function (always returns False)""" return False +def set_local_themedefs(file_contents: List[str], overlay: bool=False): + """Fallback set_local_themedefs function (always returns False)""" + return False def unset_local_themedef(): """Fallback unset_local_themedef function""" return diff --git a/pyproject.toml b/pyproject.toml index d39afd05239b0700d99fd45d587cbcddfbdd2629..a51adba9df1e0e14947473788ab030b411b9b016 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ authors = [ { name="swiftycode", email="3291929745@qq.com" }, ] description = "A text theming library for command line applications" -readme = "README.en.md" +readme = ".github/README.md" license = {text = "GNU General Public License v3 (GPLv3)"} requires-python = ">=3.8" classifiers = [ diff --git a/src/clitheme-testblock_testprogram.py b/src/clitheme-testblock_testprogram.py index af3b642216ab9dd081d2d3a8b19bda444faa87d3..223e3f8ac240e0aa086ef6a7183bdca8842932ed 100644 --- a/src/clitheme-testblock_testprogram.py +++ b/src/clitheme-testblock_testprogram.py @@ -1,4 +1,8 @@ -#!/usr/bin/python3 +# Copyright © 2023-2024 swiftycode + +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . # Program for testing multi-line (block) processing of _generator from clitheme import _generator, frontend @@ -53,7 +57,7 @@ begin_main end_main """ -frontend.global_debugmode=True +frontend.set_debugmode(True) if frontend.set_local_themedef(file_data)==False: print("Error: set_local_themedef failed") exit(1) diff --git a/src/clitheme/__init__.py b/src/clitheme/__init__.py index 6b724b11af4d9fc4ca1f1dc5dc68f4746d07652b..1573821467ab1ab48b60515f9eb2c9a6ff6e7a7e 100644 --- a/src/clitheme/__init__.py +++ b/src/clitheme/__init__.py @@ -1 +1,37 @@ -__all__=["frontend", "cli", "man", "exec"] \ No newline at end of file +""" +Command line customization toolkit +""" +# Copyright © 2023-2024 swiftycode + +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . + +__all__=["frontend"] + +# Prevent RuntimeWarning from displaying when running a submodule (e.g. "python3 -m clitheme.exec") +import warnings +warnings.simplefilter("ignore", category=RuntimeWarning) +del warnings + +# Enable processing of escape characters in Windows Command Prompt +import os +if os.name=="nt": + import ctypes + + try: + handle=ctypes.windll.kernel32.GetStdHandle(-11) # standard output handle + console_mode=ctypes.c_long() + if ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(console_mode))==0: + raise Exception("GetConsoleMode failed: "+str(ctypes.windll.kernel32.GetLastError())) + console_mode.value|=0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING + if ctypes.windll.kernel32.SetConsoleMode(handle, console_mode.value)==0: + raise Exception("SetConsoleMode failed: "+str(ctypes.windll.kernel32.GetLastError())) + except: + pass +del os + +# Expose these modules when "clitheme" is imported +from . import _globalvar, frontend +_globalvar.handle_set_themedef(frontend, "global") # type: ignore +del _globalvar # Don't expose this module \ No newline at end of file diff --git a/src/clitheme/_generator/__init__.py b/src/clitheme/_generator/__init__.py index 618b624315c5dee8e9344002407b2e90df729d61..71a873053c8911fb924b74c0a6d7126c14d5b13e 100644 --- a/src/clitheme/_generator/__init__.py +++ b/src/clitheme/_generator/__init__.py @@ -11,6 +11,9 @@ import os import string import random from typing import Optional +from .. import _globalvar +from . import _parser_handlers +from . import _header_parser, _entries_parser, _substrules_parser, _manpage_parser # spell-checker:ignore infofile splitarray datapath lineindex banphrases cmdmatch minspaces blockinput optline matchoption endphrase filecontent @@ -32,28 +35,33 @@ def generate_data_hierarchy(file_content: str, custom_path_gen=True, custom_info if custom_path_gen: generate_custom_path() global path - obj=_dataclass.GeneratorObject(file_content=file_content, custom_infofile_name=custom_infofile_name, filename=filename, path=path, silence_warn=silence_warn) + obj=_parser_handlers.GeneratorObject(file_content=file_content, custom_infofile_name=custom_infofile_name, filename=filename, path=path, silence_warn=silence_warn) - ## Main code + before_content_lines=True while obj.goto_next_line(): - first_phrase=obj.lines_data[obj.lineindex].split()[0] - # process header and main sections here - if first_phrase=="set_options": - obj.check_enough_args(obj.lines_data[obj.lineindex].split(), 2) - obj.handle_set_global_options(obj.subst_variable_content(_globalvar.splitarray_to_string(obj.lines_data[obj.lineindex].split()[1:])).split(), really_really_global=True) - elif first_phrase.startswith("setvar:"): - obj.check_enough_args(obj.lines_data[obj.lineindex].split(), 2) - obj.handle_set_variable(obj.lines_data[obj.lineindex], really_really_global=True) - elif first_phrase=="begin_header" or first_phrase==r"{header_section}": + phrases=obj.lines_data[obj.lineindex].split() + first_phrase=phrases[0] + is_content=True + if first_phrase in ("begin_header", r"{header_section}"): _header_parser.handle_header_section(obj, first_phrase) - elif first_phrase=="begin_main" or first_phrase==r"{entries_section}": + elif first_phrase in ("begin_main", r"{entries_section}"): _entries_parser.handle_entries_section(obj, first_phrase) elif first_phrase==r"{substrules_section}": _substrules_parser.handle_substrules_section(obj, first_phrase) elif first_phrase==r"{manpage_section}": _manpage_parser.handle_manpage_section(obj, first_phrase) + elif obj.handle_setters(really_really_global=True): pass + elif first_phrase=="!require_version": + is_content=False + obj.check_enough_args(phrases, 2) + obj.check_extra_args(phrases, 2, use_exact_count=True) + if not before_content_lines: + obj.handle_error(obj.fd.feof("phrase-precedence-err", "Line {num}: header macro \"{phrase}\" must be specified before other lines", num=str(obj.lineindex+1), phrase=first_phrase)) + obj.check_version(phrases[1]) else: obj.handle_invalid_phrase(first_phrase) + if is_content: before_content_lines=False + def is_content_parsed() -> bool: content_sections=["entries", "substrules", "manpage"] for section in content_sections: @@ -70,9 +78,3 @@ def generate_data_hierarchy(file_content: str, custom_path_gen=True, custom_info theme_index.write(obj.custom_infofile_name+"\n") path=obj.path return obj.path - -# prevent circular import error by placing these statements at the end -from .. import _globalvar -from . import _dataclass -from . import _header_parser, _entries_parser, _substrules_parser, _manpage_parser -_globalvar.handle_set_themedef(_dataclass.GeneratorObject.frontend, "generator") \ No newline at end of file diff --git a/src/clitheme/_generator/_handlers.py b/src/clitheme/_generator/_data_handlers.py similarity index 91% rename from src/clitheme/_generator/_handlers.py rename to src/clitheme/_generator/_data_handlers.py index 3412fd4b8222da4d78909c858f36be71d2ede5cb..7df973067da671a710da660ad34e7cdb6115e5c9 100644 --- a/src/clitheme/_generator/_handlers.py +++ b/src/clitheme/_generator/_data_handlers.py @@ -5,12 +5,12 @@ # You should have received a copy of the GNU General Public License along with this program. If not, see . """ -Functions for data processing and others (internal module) +Functions for data processing and error handling (internal module) """ import os import gzip import re -from typing import Optional +from typing import Optional, List from .. import _globalvar, frontend # spell-checker:ignore datapath @@ -26,8 +26,8 @@ class DataHandlers: if not os.path.exists(self.datapath): os.mkdir(self.datapath) self.fd=self.frontend.FetchDescriptor(domain_name="swiftycode", app_name="clitheme", subsections="generator") self.fmt=_globalvar.make_printable # alias for the make_printable function - def handle_error(self, message: str): - output=self.fd.feof("error-str", "Syntax error: {msg}", msg=message) + def handle_error(self, message: str, not_syntax_error: bool=False): + output=message if not_syntax_error else self.fd.feof("error-str", "Syntax error: {msg}", msg=message) raise SyntaxError(output) def handle_warning(self, message: str): output=self.fd.feof("warning-str", "Warning: {msg}", msg=message) @@ -65,7 +65,7 @@ class DataHandlers: num=str(line_number_debug), name=self.fmt(header_name_debug))) f=open(target_path,'w', encoding="utf-8") f.write(content+'\n') - def write_infofile_newlines(self, path: str, filename: str, content_phrases: list, line_number_debug: int, header_name_debug: str): + def write_infofile_newlines(self, path: str, filename: str, content_phrases: List[str], line_number_debug: int, header_name_debug: str): if not os.path.isdir(path): os.makedirs(path) target_path=path+"/"+filename @@ -75,13 +75,13 @@ class DataHandlers: f=open(target_path,'w', encoding="utf-8") for line in content_phrases: f.write(line+"\n") - def write_manpage_file(self, file_path: list, content: str, line_number_debug: int, custom_parent_path: Optional[str]=None): + def write_manpage_file(self, file_path: List[str], content: str, line_number_debug: int, custom_parent_path: Optional[str]=None): parent_path=custom_parent_path if custom_parent_path!=None else self.path+"/"+_globalvar.generator_manpage_pathname parent_path+='/'+os.path.dirname(_globalvar.splitarray_to_string(file_path).replace(" ","/")) # create the parent directory try: os.makedirs(parent_path, exist_ok=True) except (FileExistsError, NotADirectoryError): - self.handle_error(self.fd.feof("manpage-subdir-file-conflict-err", "Line {num}: conflicting files and subdirectories; please check previous definitions", num=str(line_number_debug))) + self.handle_error(self.fd.feof("manpage-subdir-file-conflict-err", "Line {num}: conflicting files and subdirectories; please check previous definitions", num=str(line_number_debug)), not_syntax_error=True) full_path=parent_path+"/"+file_path[-1] if os.path.isfile(full_path): if line_number_debug!=-1: self.handle_warning(self.fd.feof("repeated-manpage-warn","Line {num}: repeated manpage file, overwriting", num=str(line_number_debug))) @@ -90,4 +90,4 @@ class DataHandlers: open(full_path, "w", encoding="utf-8").write(content) open(full_path+".gz", "wb").write(gzip.compress(bytes(content, "utf-8"))) except IsADirectoryError: - self.handle_error(self.fd.feof("manpage-subdir-file-conflict-err", "Line {num}: conflicting files and subdirectories; please check previous definitions", num=str(line_number_debug))) \ No newline at end of file + self.handle_error(self.fd.feof("manpage-subdir-file-conflict-err", "Line {num}: conflicting files and subdirectories; please check previous definitions", num=str(line_number_debug)), not_syntax_error=True) \ No newline at end of file diff --git a/src/clitheme/_generator/_entries_parser.py b/src/clitheme/_generator/_entries_parser.py index 1faae2a59310fbc6dc950bbb38043b7f4f932a06..da7e15a21f29709c7b2d803238c7b670086feef3 100644 --- a/src/clitheme/_generator/_entries_parser.py +++ b/src/clitheme/_generator/_entries_parser.py @@ -9,11 +9,11 @@ entries_section parser function (internal module) """ from typing import Optional from .. import _globalvar -from . import _dataclass +from . import _parser_handlers # spell-checker:ignore infofile splitarray datapath lineindex banphrases cmdmatch minspaces blockinput optline matchoption endphrase filecontent -def handle_entries_section(obj: _dataclass.GeneratorObject, first_phrase: str): +def handle_entries_section(obj: _parser_handlers.GeneratorObject, first_phrase: str): obj.handle_begin_section("entries") end_phrase="end_main" if first_phrase=="begin_main" else r"{/entries_section}" if first_phrase=="begin_main": @@ -23,7 +23,7 @@ def handle_entries_section(obj: _dataclass.GeneratorObject, first_phrase: str): while obj.goto_next_line(): phrases=obj.lines_data[obj.lineindex].split() if phrases[0]=="in_domainapp": - this_phrases=obj.subst_variable_content(obj.lines_data[obj.lineindex].strip()).split() + this_phrases=obj.parse_content(obj.lines_data[obj.lineindex].strip(), pure_name=True).split() obj.check_enough_args(this_phrases, 3) obj.check_extra_args(this_phrases, 3, use_exact_count=False) obj.in_domainapp=this_phrases[1]+" "+this_phrases[2] @@ -33,7 +33,7 @@ def handle_entries_section(obj: _dataclass.GeneratorObject, first_phrase: str): elif phrases[0]=="in_subsection": obj.check_enough_args(phrases, 2) obj.in_subsection=_globalvar.splitarray_to_string(phrases[1:]) - obj.in_subsection=obj.subst_variable_content(obj.in_subsection) + obj.in_subsection=obj.parse_content(obj.in_subsection, pure_name=True) if _globalvar.sanity_check(obj.in_subsection)==False: obj.handle_error(obj.fd.feof("sanity-check-subsection-err", "Line {num}: subsection names {sanitycheck_msg}", num=str(obj.lineindex+1), sanitycheck_msg=_globalvar.sanity_check_error_message)) elif phrases[0]=="unset_domainapp": @@ -42,16 +42,11 @@ def handle_entries_section(obj: _dataclass.GeneratorObject, first_phrase: str): elif phrases[0]=="unset_subsection": obj.check_extra_args(phrases, 1, use_exact_count=True) obj.in_subsection="" - elif phrases[0]=="entry" or phrases[0]=="[entry]": + elif phrases[0] in ("entry", "[entry]"): obj.check_enough_args(phrases, 2) entry_name=_globalvar.extract_content(obj.lines_data[obj.lineindex]) obj.handle_entry(entry_name, start_phrase=phrases[0], end_phrase="[/entry]" if phrases[0]=="[entry]" else "end_entry") - elif phrases[0]=="set_options": - obj.check_enough_args(phrases, 2) - obj.handle_set_global_options(obj.subst_variable_content(_globalvar.splitarray_to_string(phrases[1:])).split()) - elif phrases[0].startswith("setvar:"): - obj.check_enough_args(phrases, 2) - obj.handle_set_variable(obj.lines_data[obj.lineindex]) + elif obj.handle_setters(): pass elif phrases[0]==end_phrase: obj.check_extra_args(phrases, 1, use_exact_count=True) obj.handle_end_section("entries") diff --git a/src/clitheme/_generator/_entry_block_handler.py b/src/clitheme/_generator/_entry_block_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..08b01c75c3345b0616d2d3ace0104cc00d4a39b1 --- /dev/null +++ b/src/clitheme/_generator/_entry_block_handler.py @@ -0,0 +1,155 @@ +# Copyright © 2023-2024 swiftycode + +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . + +import sys +import re +import copy +import uuid +from typing import Optional, Union, List, Dict, Any +from .. import _globalvar + + +def handle_entry(obj, entry_name: str, start_phrase: str, end_phrase: str, is_substrules: bool=False, substrules_options: Dict[str, Any]={}): + # Workaround to circular import issue + from . import _parser_handlers + self: _parser_handlers.GeneratorObject=obj + # substrules_options: {effective_commands: list, is_regex: bool, strictness: int} + + entry_name_substesc=False; entry_name_substvar=False + names_processed=False # Set to True when no more entry names are being specified + + # For supporting specifying multiple entries at once (0: name, 1: uuid, 2: debug_linenumber) + entryNames: List[tuple]=[(entry_name, uuid.uuid4(), self.lineindex+1)] + # For substrules_section: (0: match_content, 1: substitute_content, 2: locale, 3: entry_name_uuid, 4: content_linenumber_str, 5: match_content_linenumber) + # For entries_section: (0: target_entry, 1: content, 2: debug_linenumber, 3: entry_name_uuid, 4: entry_name_linenumber) + entries: List[tuple]=[] + + substrules_endmatchhere=False + substrules_stdout_stderr_option=0 + substrules_foregroundonly=False + + def check_valid_pattern(pattern: str, debug_linenumber: Union[str, int]=self.lineindex+1): + # check if patterns are valid + try: re.compile(pattern) + except: self.handle_error(self.fd.feof("bad-match-pattern-err", "Bad match pattern at line {num} ({error_msg})", num=str(debug_linenumber), error_msg=sys.exc_info()[1])) + while self.goto_next_line(): + phrases=self.lines_data[self.lineindex].split() + line_content=self.lines_data[self.lineindex] + # Support specifying multiple match pattern/entry names in one definition block + if phrases[0]!=start_phrase and not names_processed: + names_processed=True # Prevent specifying it after other definition syntax + # --Process entry names-- + for x in range(len(entryNames)): + each_entry=entryNames[x] + name=each_entry[0] + if not is_substrules: + if self.in_subsection!="": name=self.in_subsection+" "+name + if self.in_domainapp!="": name=self.in_domainapp+" "+name + entryNames[x]=(name, each_entry[1], each_entry[2]) + + if phrases[0]==start_phrase and not names_processed: + self.check_enough_args(phrases, 2) + pattern=_globalvar.extract_content(line_content) + entryNames.append((pattern, uuid.uuid4(), self.lineindex+1)) + elif phrases[0]=="locale" or phrases[0].startswith("locale:"): + content: str + locale: str + if phrases[0].startswith("locale:"): + self.check_enough_args(phrases, 2) + results=re.search(r"locale:(?P.+)", phrases[0]) + if results==None: + self.handle_error(self.fd.feof("not-enough-args-err", "Not enough arguments for \"{phrase}\" at line {num}", phrase="locale:", num=str(self.lineindex+1))) + else: + locale=results.groupdict()['locale'] + content=_globalvar.extract_content(line_content) + else: + self.check_enough_args(phrases, 3) + content=_globalvar.extract_content(line_content, begin_phrase_count=2) + locale=phrases[1] + locales=self.parse_content(locale, pure_name=True).split() + content=self.parse_content(content) + for this_locale in locales: + for each_name in entryNames: + if is_substrules: + entries.append((each_name[0], content, None if this_locale=="default" else this_locale, each_name[1], str(self.lineindex+1), each_name[2])) + else: + target_entry=copy.copy(each_name[0]) + if this_locale!="default": + target_entry+="__"+this_locale + entries.append((target_entry, content, self.lineindex+1, each_name[1], each_name[2])) + elif phrases[0] in ("locale_block", "[locale]"): + self.check_enough_args(phrases, 2) + locales=self.parse_content(_globalvar.splitarray_to_string(phrases[1:]), pure_name=True).split() + begin_line_number=self.lineindex+1+1 + content=self.handle_block_input(preserve_indents=True, preserve_empty_lines=True, end_phrase="[/locale]" if phrases[0]=="[locale]" else "end_block") + for this_locale in locales: + for each_name in entryNames: + if is_substrules: + entries.append((each_name[0], content, None if this_locale=="default" else this_locale, each_name[1], self.handle_linenumber_range(begin_line_number, self.lineindex+1-1), each_name[2])) + else: + target_entry=copy.copy(each_name[0]) + if this_locale!="default": + target_entry+="__"+this_locale + entries.append((target_entry, content, begin_line_number, each_name[1], each_name[2])) + elif phrases[0]==end_phrase: + got_options=self.parse_options(phrases[1:] if len(phrases)>1 else [], merge_global_options=True, \ + allowed_options=\ + (self.subst_limiting_options if is_substrules else []) + +(self.content_subst_options if is_substrules else ["substvar"]) # don't allow substesc in `[entry]` + +(['foregroundonly'] if is_substrules else []) + ) + for option in got_options: + if option=="endmatchhere" and got_options['endmatchhere']==True: + substrules_endmatchhere=True + elif option=="subststdoutonly" and got_options['subststdoutonly']==True: + substrules_stdout_stderr_option=1 + elif option=="subststderronly" and got_options['subststderronly']==True: + substrules_stdout_stderr_option=2 + elif option=="substesc" and got_options['substesc']==True: + entry_name_substesc=True + elif option=="substvar" and got_options['substvar']==True: + entry_name_substvar=True + elif option=="foregroundonly" and got_options['foregroundonly']==True: + substrules_foregroundonly=True + break + else: self.handle_invalid_phrase(phrases[0]) + # For silence_warning in subst_variable_content + encountered_ids=set() + for x in range(len(entries)): + entry=entries[x] + match_pattern=entry[0] + # substvar MUST come before substesc or "{{ESC}}" in variable content will not be processed + debug_linenumber=entry[5] if is_substrules else entry[4] + match_pattern=self.subst_variable_content(match_pattern, custom_condition=entry_name_substvar, \ + line_number_debug=debug_linenumber, \ + # Don't show warnings for the same match_pattern + silence_warnings=entry[3] in encountered_ids) + match_pattern=self.handle_substesc(match_pattern, condition=entry_name_substesc==True, line_number_debug=debug_linenumber) + + if is_substrules: check_valid_pattern(match_pattern, entry[5]) + else: + # Prevent leading . & prevent /,\ in entry name + if _globalvar.sanity_check(match_pattern)==False: + self.handle_error(self.fd.feof("sanity-check-entry-err", "Line {num}: entry subsections/names {sanitycheck_msg}", num=str(entry[4]), sanitycheck_msg=_globalvar.sanity_check_error_message)) + encountered_ids.add(entry[3]) + if is_substrules: + try: + self.db_interface.add_subst_entry( + match_pattern=match_pattern, \ + substitute_pattern=entry[1], \ + effective_commands=substrules_options['effective_commands'], \ + effective_locale=entry[2], \ + is_regex=substrules_options['is_regex'], \ + command_match_strictness=substrules_options['strictness'], \ + end_match_here=substrules_endmatchhere, \ + stdout_stderr_matchoption=substrules_stdout_stderr_option, \ + foreground_only=substrules_foregroundonly, \ + line_number_debug=entry[4], \ + file_id=self.file_id, \ + unique_id=entry[3]) + except self.db_interface.bad_pattern: self.handle_error(self.fd.feof("bad-subst-pattern-err", "Bad substitute pattern at line {num} ({error_msg})", num=entry[4], error_msg=sys.exc_info()[1])) + else: + self.add_entry(self.datapath, match_pattern, entry[1], entry[2]) \ No newline at end of file diff --git a/src/clitheme/_generator/_header_parser.py b/src/clitheme/_generator/_header_parser.py index 94dc8e2ea09d88d2ac2960df5390507e3068fe86..d40f189a1a2e1b6b9b30bf2e5dd33dde615e548a 100644 --- a/src/clitheme/_generator/_header_parser.py +++ b/src/clitheme/_generator/_header_parser.py @@ -10,38 +10,38 @@ header_section parser function (internal module) import re from typing import Optional from .. import _globalvar -from . import _dataclass +from . import _parser_handlers # spell-checker:ignore infofile splitarray datapath lineindex banphrases cmdmatch minspaces blockinput optline matchoption endphrase filecontent -def handle_header_section(obj: _dataclass.GeneratorObject, first_phrase: str): +def handle_header_section(obj: _parser_handlers.GeneratorObject, first_phrase: str): obj.handle_begin_section("header") end_phrase="end_header" if first_phrase=="begin_header" else r"{/header_section}" + specified_info=[] while obj.goto_next_line(): phrases=obj.lines_data[obj.lineindex].split() - if phrases[0]=="name" or phrases[0]=="version" or phrases[0]=="description": + if phrases[0] in ("name", "version", "description"): obj.check_enough_args(phrases, 2) content=_globalvar.extract_content(obj.lines_data[obj.lineindex]) - if phrases[0]=="description": content=obj.handle_singleline_content(content) - else: content=obj.subst_variable_content(content) + content=obj.parse_content(content, pure_name=phrases[0]!="description") obj.write_infofile( \ obj.path+"/"+_globalvar.generator_info_pathname+"/"+obj.custom_infofile_name, \ _globalvar.generator_info_filename.format(info=phrases[0]),\ content,obj.lineindex+1,phrases[0]) # e.g. [...]/theme-info/1/clithemeinfo_name - elif phrases[0]=="locales" or phrases[0]=="supported_apps": + elif phrases[0] in ("locales", "supported_apps"): obj.check_enough_args(phrases, 2) - content=obj.subst_variable_content(_globalvar.splitarray_to_string(phrases[1:])).split() + content=obj.parse_content(_globalvar.splitarray_to_string(phrases[1:]), pure_name=True).split() obj.write_infofile_newlines( \ obj.path+"/"+_globalvar.generator_info_pathname+"/"+obj.custom_infofile_name, \ _globalvar.generator_info_v2filename.format(info=phrases[0]),\ content,obj.lineindex+1,phrases[0]) # e.g. [...]/theme-info/1/clithemeinfo_description_v2 - elif phrases[0]=="locales_block" or phrases[0]=="supported_apps_block" or phrases[0]=="description_block" or phrases[0]=="[locales]" or phrases[0]=="[supported_apps]" or phrases[0]=="[description]": + elif phrases[0] in ("locales_block", "supported_apps_block", "description_block", "[locales]", "[supported_apps]", "[description]"): obj.check_extra_args(phrases, 1, use_exact_count=True) # handle block input content=""; file_name="" endphrase="end_block" if not phrases[0].endswith("_block"): endphrase=phrases[0].replace("[", "[/") - if phrases[0]=="description_block" or phrases[0]=="[description]": + if phrases[0] in ("description_block", "[description]"): content=obj.handle_block_input(preserve_indents=True, preserve_empty_lines=True, end_phrase=endphrase) file_name=_globalvar.generator_info_filename.format(info=re.sub(r'_block$', '', phrases[0]).replace('[','').replace(']','')) else: @@ -51,14 +51,12 @@ def handle_header_section(obj: _dataclass.GeneratorObject, first_phrase: str): obj.path+"/"+_globalvar.generator_info_pathname+"/"+obj.custom_infofile_name, \ file_name,\ content,obj.lineindex+1,re.sub(r'_block$','',phrases[0])) # e.g. [...]/theme-info/1/clithemeinfo_description_v2 - elif phrases[0]=="set_options": - obj.check_enough_args(phrases, 2) - obj.handle_set_global_options(obj.subst_variable_content(_globalvar.splitarray_to_string(phrases[1:])).split()) - elif phrases[0].startswith("setvar:"): - obj.check_enough_args(phrases, 2) - obj.handle_set_variable(obj.lines_data[obj.lineindex]) + elif obj.handle_setters(): pass elif phrases[0]==end_phrase: obj.check_extra_args(phrases, 1, use_exact_count=True) + if not "name" in specified_info: + obj.handle_error(obj.fd.feof("missing-info-err", "{sect_name} section missing required entries: {entries}", sect_name="header", entries="name")) obj.handle_end_section("header") break - else: obj.handle_invalid_phrase(phrases[0]) \ No newline at end of file + else: obj.handle_invalid_phrase(phrases[0]) + specified_info.append(phrases[0]) \ No newline at end of file diff --git a/src/clitheme/_generator/_manpage_parser.py b/src/clitheme/_generator/_manpage_parser.py index 85e802ca55d4a599c81f5221fe1727fe94c232d9..aca50ce18ae2b5b80dec5414d529769833f4f5fa 100644 --- a/src/clitheme/_generator/_manpage_parser.py +++ b/src/clitheme/_generator/_manpage_parser.py @@ -9,18 +9,18 @@ substrules_section parser function (internal module) """ import os import sys -from typing import Optional +from typing import Optional, List from .. import _globalvar -from . import _dataclass +from . import _parser_handlers # spell-checker:ignore infofile splitarray datapath lineindex banphrases cmdmatch minspaces blockinput optline matchoption endphrase filecontent -def handle_manpage_section(obj: _dataclass.GeneratorObject, first_phrase: str): +def handle_manpage_section(obj: _parser_handlers.GeneratorObject, first_phrase: str): obj.handle_begin_section("manpage") end_phrase="{/manpage_section}" while obj.goto_next_line(): phrases=obj.lines_data[obj.lineindex].split() - def get_file_content(filepath: list) -> str: + def get_file_content(filepath: List[str]) -> str: # determine file path parent_dir="" # if no filename provided, use current working directory as parent path; else, use the directory the file is in as the parent path @@ -28,16 +28,21 @@ def handle_manpage_section(obj: _dataclass.GeneratorObject, first_phrase: str): parent_dir+=os.path.dirname(obj.filename) file_dir=parent_dir+("/" if parent_dir!="" else "")+_globalvar.splitarray_to_string(filepath).replace(" ","/") # get file content + orig_stdout=sys.stdout + sys.stdout=sys.__stdout__ + is_stdin=_globalvar.handle_stdin_prompt(file_dir) filecontent: str try: filecontent=open(file_dir, 'r', encoding="utf-8").read() - except: obj.handle_error(obj.fd.feof("include-file-read-error", "Line {num}: unable to read file \"{filepath}\":\n{error_msg}", num=str(obj.lineindex+1), filepath=obj.fmt(file_dir), error_msg=sys.exc_info()[1])) + except: obj.handle_error(obj.fd.feof("include-file-read-err", "Line {num}: unable to read file \"{filepath}\":\n{error_msg}", num=str(obj.lineindex+1), filepath=obj.fmt(file_dir), error_msg=sys.exc_info()[1]), not_syntax_error=True) + if is_stdin: print() + sys.stdout=orig_stdout # write manpage files in theme-info for db migration feature to work successfully obj.write_manpage_file(filepath, filecontent, -1, custom_parent_path=obj.path+"/"+_globalvar.generator_info_pathname+"/"+obj.custom_infofile_name+"/manpage_data") return filecontent if phrases[0]=="[file_content]": - def handle(p: list) -> list: + def handle(p: List[str]) -> List[str]: obj.check_enough_args(p, 2) - filepath=obj.subst_variable_content(_globalvar.splitarray_to_string(p[1:])).split() + filepath=obj.parse_content(_globalvar.splitarray_to_string(p[1:]), pure_name=True).split() # sanity check the file path if _globalvar.sanity_check(_globalvar.splitarray_to_string(filepath))==False: obj.handle_error(obj.fd.feof("sanity-check-manpage-err", "Line {num}: manpage paths {sanitycheck_msg}; use spaces to denote subdirectories", num=str(obj.lineindex+1), sanitycheck_msg=_globalvar.sanity_check_error_message)) @@ -58,14 +63,14 @@ def handle_manpage_section(obj: _dataclass.GeneratorObject, first_phrase: str): obj.write_manpage_file(filepath, content, obj.lineindex+1) elif phrases[0]=="include_file": obj.check_enough_args(phrases, 2) - filepath=obj.subst_variable_content(_globalvar.splitarray_to_string(phrases[1:])).split() + filepath=obj.parse_content(_globalvar.splitarray_to_string(phrases[1:]), pure_name=True).split() if _globalvar.sanity_check(_globalvar.splitarray_to_string(filepath))==False: obj.handle_error(obj.fd.feof("sanity-check-manpage-err", "Line {num}: manpage paths {sanitycheck_msg}; use spaces to denote subdirectories", num=str(obj.lineindex+1), sanitycheck_msg=_globalvar.sanity_check_error_message)) filecontent=get_file_content(filepath) # expect "as" clause on next line if obj.goto_next_line() and len(obj.lines_data[obj.lineindex].split())>0 and obj.lines_data[obj.lineindex].split()[0]=="as": - target_file=obj.subst_variable_content(_globalvar.splitarray_to_string(obj.lines_data[obj.lineindex].split()[1:])).split() + target_file=obj.parse_content(_globalvar.splitarray_to_string(obj.lines_data[obj.lineindex].split()[1:]), pure_name=True).split() if _globalvar.sanity_check(_globalvar.splitarray_to_string(target_file))==False: obj.handle_error(obj.fd.feof("sanity-check-manpage-err", "Line {num}: manpage paths {sanitycheck_msg}; use spaces to denote subdirectories", num=str(obj.lineindex+1), sanitycheck_msg=_globalvar.sanity_check_error_message)) obj.write_manpage_file(target_file, filecontent, obj.lineindex+1) @@ -73,7 +78,7 @@ def handle_manpage_section(obj: _dataclass.GeneratorObject, first_phrase: str): obj.handle_error(obj.fd.feof("include-file-missing-phrase-err", "Missing \"as \" phrase on next line of line {num}", num=str(obj.lineindex+1-1))) elif phrases[0]=="[include_file]": obj.check_enough_args(phrases, 2) - filepath=obj.subst_variable_content(_globalvar.splitarray_to_string(phrases[1:])).split() + filepath=obj.parse_content(_globalvar.splitarray_to_string(phrases[1:]), pure_name=True).split() if _globalvar.sanity_check(_globalvar.splitarray_to_string(filepath))==False: obj.handle_error(obj.fd.feof("sanity-check-manpage-err", "Line {num}: manpage paths {sanitycheck_msg}; use spaces to denote subdirectories", num=str(obj.lineindex+1), sanitycheck_msg=_globalvar.sanity_check_error_message)) filecontent=get_file_content(filepath) @@ -81,7 +86,7 @@ def handle_manpage_section(obj: _dataclass.GeneratorObject, first_phrase: str): p=obj.lines_data[obj.lineindex].split() if p[0]=="as": obj.check_enough_args(p, 2) - target_file=obj.subst_variable_content(_globalvar.splitarray_to_string(obj.lines_data[obj.lineindex].split()[1:])).split() + target_file=obj.parse_content(_globalvar.splitarray_to_string(obj.lines_data[obj.lineindex].split()[1:]), pure_name=True).split() if _globalvar.sanity_check(_globalvar.splitarray_to_string(target_file))==False: obj.handle_error(obj.fd.feof("sanity-check-manpage-err", "Line {num}: manpage paths {sanitycheck_msg}; use spaces to denote subdirectories", num=str(obj.lineindex+1), sanitycheck_msg=_globalvar.sanity_check_error_message)) obj.write_manpage_file(target_file, filecontent, obj.lineindex+1) @@ -89,12 +94,7 @@ def handle_manpage_section(obj: _dataclass.GeneratorObject, first_phrase: str): obj.check_extra_args(p, 1, use_exact_count=True) break else: obj.handle_invalid_phrase(phrases[0]) - elif phrases[0]=="set_options": - obj.check_enough_args(phrases, 2) - obj.handle_set_global_options(obj.subst_variable_content(_globalvar.splitarray_to_string(phrases[1:])).split()) - elif phrases[0].startswith("setvar:"): - obj.check_enough_args(phrases, 2) - obj.handle_set_variable(obj.lines_data[obj.lineindex]) + elif obj.handle_setters(): pass elif phrases[0]==end_phrase: obj.check_extra_args(phrases, 1, use_exact_count=True) obj.handle_end_section("manpage") diff --git a/src/clitheme/_generator/_dataclass.py b/src/clitheme/_generator/_parser_handlers.py similarity index 48% rename from src/clitheme/_generator/_dataclass.py rename to src/clitheme/_generator/_parser_handlers.py index 7c939d1f9f1b39fecef2b62b8e7cdac837471491..420e8cae63d028e99a0c96468916a22621f33997 100644 --- a/src/clitheme/_generator/_dataclass.py +++ b/src/clitheme/_generator/_parser_handlers.py @@ -5,7 +5,7 @@ # You should have received a copy of the GNU General Public License along with this program. If not, see . """ -Class object for sharing data between section parsers (internal module) +Functions used by various parsers (internal module) """ import sys @@ -13,18 +13,18 @@ import re import math import copy import uuid -from typing import Optional, Union -from .. import _globalvar -from . import _handlers -# spell-checker:ignore lineindex banphrases cmdmatch minspaces blockinput optline datapath matchoption +from typing import Optional, Union, List, Dict +from .. import _globalvar, _version +from . import _data_handlers, _entry_block_handler +# spell-checker:ignore lineindex banphrases minspaces blockinput optline datapath matchoption -class GeneratorObject(_handlers.DataHandlers): +class GeneratorObject(_data_handlers.DataHandlers): ## Defined option groups lead_indent_options=["leadtabindents", "leadspaces"] content_subst_options=["substesc","substvar"] command_filter_options=["strictcmdmatch", "exactcmdmatch", "smartcmdmatch", "normalcmdmatch"]+["foregroundonly"] - subst_limiting_options=["subststdoutonly", "subststderronly", "substall"]+["endmatchhere"] + subst_limiting_options=["subststdoutonly", "subststderronly", "substallstreams"]+["endmatchhere"] # options used in handle_block_input block_input_options=lead_indent_options+content_subst_options @@ -37,9 +37,11 @@ class GeneratorObject(_handlers.DataHandlers): switch_options=[command_filter_options[:4]] # Disable these options for now (BETA) # switch_options+=[subst_limiting_options[:3]] + substvar_banphrases=['{', '}', '[', ']', '(', ')'] def __init__(self, file_content: str, custom_infofile_name: str, filename: str, path: str, silence_warn: bool): # data to keep track of + self.warnings: Dict[str, bool]={} self.section_parsing=False self.parsed_sections=[] self.lines_data=file_content.splitlines() @@ -55,7 +57,8 @@ class GeneratorObject(_handlers.DataHandlers): self.custom_infofile_name=custom_infofile_name self.filename=filename self.file_content=file_content - _handlers.DataHandlers.__init__(self, path, silence_warn) + self.file_id=uuid.uuid4() + _data_handlers.DataHandlers.__init__(self, path, silence_warn) from . import db_interface self.db_interface=db_interface def is_ignore_line(self) -> bool: @@ -66,22 +69,45 @@ class GeneratorObject(_handlers.DataHandlers): # stop at non-empty or non-comment line if not self.is_ignore_line(): return True else: return False # End of file - def check_enough_args(self, phrases: list, count: int): + def check_enough_args(self, phrases: List[str], count: int): if len(phrases)count if not_pass: self.handle_error(self.fd.feof("extra-arguments-err", "Extra arguments after \"{phrase}\" on line {num}", num=str(self.lineindex+1), phrase=self.fmt(phrases[0]))) + def check_version(self, version_str: str): + # allow_bugfix is disabled to allow interoperability with other release variants + allow_bugfix: bool=False # Whether to allow specifying bugfix releases in version info + match_result=re.match(rf"^(?P\d+)\.(?P\d+)(\.(?P\d+)){{,{int(allow_bugfix)}}}(-beta(?P\d+))?$", version_str) + def invalid_version(): self.handle_error(self.fd.feof("invalid-version-err", "Invalid version information \"{ver}\" on line {num}", ver=self.fmt(version_str), num=str(self.lineindex+1))) + if match_result==None: invalid_version() + elif int(match_result.groupdict()['major'])<2: invalid_version() + else: + version_ok= int(match_result.groupdict()['major'])<=_version.major \ + and int(match_result.groupdict()['minor'])<=_version.minor \ + and (int(match_result.groupdict()['bugfix']) if match_result.groupdict().get("bugfix")!=None else -1)<=_version.release + if match_result.groupdict().get("beta_release")!=None and _version.beta_release!=None: + version_ok=version_ok and int(match_result.groupdict()['beta_release'])<=_version.beta_release + + if not version_ok: + self.handle_error(self.fd.feof("unsupported-version-err", "Current version of clitheme ({cur_ver}) does not support this file (requires {req_ver} or higher)", + cur_ver=_version.__version__+ \ + # For "dev" versions: output corresponding beta milestone + (f" [beta{_version.beta_release}]" if _version.beta_release!=None and not "beta" in _version.__version__ else ""), + req_ver=self.fmt(version_str)), not_syntax_error=True) def handle_invalid_phrase(self, name: str): self.handle_error(self.fd.feof("invalid-phrase-err", "Unexpected \"{phrase}\" on line {num}", phrase=self.fmt(name), num=str(self.lineindex+1))) - def parse_options(self, options_data: list, merge_global_options: int, allowed_options: Optional[list]=None) -> dict: + def parse_options(self, options_data: List[str], merge_global_options: int, allowed_options: Optional[List[str]]=None, ban_options: Optional[List[str]]=None) -> Dict[str, Union[int,bool]]: # merge_global_options: 0 - Don't merge; 1 - Merge self.global_options; 2 - Merge self.really_really_global_options + assert not (allowed_options!=None and ban_options!=None), "Cannot specify allowed and banned options at the same time" + final_options={} if merge_global_options!=0: final_options=copy.copy(self.global_options if merge_global_options==1 else self.really_really_global_options) if len(options_data)==0: return final_options # return either empty data or pre-existing global options + options_data=self.parse_content(_globalvar.splitarray_to_string(options_data), pure_name=True).split() for each_option in options_data: option_name=re.sub(r"^(no)?(?P.+?)(:.+)?$", r"\g", each_option) option_name_preserve_no=re.sub(r"^(?P.+?)(:.+)?$", r"\g", each_option) @@ -112,26 +138,48 @@ class GeneratorObject(_handlers.DataHandlers): break else: # executed when no break occurs self.handle_error(self.fd.feof("unknown-option-err", "Unknown option \"{phrase}\" on line {num}", num=str(self.lineindex+1), phrase=self.fmt(option_name_preserve_no))) - if allowed_options!=None and option_name not in allowed_options: + if (allowed_options!=None and option_name not in allowed_options) or\ + (ban_options!=None and option_name in ban_options): self.handle_error(self.fd.feof("option-not-allowed-err", "Option \"{phrase}\" not allowed here at line {num}", num=str(self.lineindex+1), phrase=self.fmt(option_name))) return final_options - def handle_set_global_options(self, options_data: list, really_really_global: bool=False): + def handle_set_global_options(self, options_data: List[str], really_really_global: bool=False): # set options globally if really_really_global: self.really_really_global_options=self.parse_options(options_data, merge_global_options=2) - else: - self.global_options=self.parse_options(options_data, merge_global_options=1) + self.global_options=self.parse_options(options_data, merge_global_options=1) + specified_options=self.parse_options(options_data, merge_global_options=False) + # if manually disabled, show substvar warning again next time + for option in ("substvar", "substesc"): + if self.global_options.get(option)!=True \ + and option in specified_options: + self.warnings[option]=True def handle_setup_global_options(self): + prev_options=copy.copy(self.global_options) # reset global_options to contents of really_really_global_options self.global_options=copy.copy(self.really_really_global_options) + # if manually disabled, show warnings again next time + for option in ("substvar", "substesc"): + if self.global_options.get(option)!=True and prev_options.get(option)==True: + self.warnings[option]=True self.global_variables=copy.copy(self.really_really_global_variables) - def subst_variable_content(self, content: str, override_check: bool=False, line_number_debug: Optional[str]=None, silence_warnings: bool=False) -> str: - if not override_check and (not "substvar" in self.global_options or self.global_options["substvar"]==False): return content + def subst_variable_content(self, content: str, custom_condition: Optional[bool]=None, line_number_debug: Optional[str]=None, silence_warnings: bool=False) -> str: + pattern=r"{{([^\s]+?)??}}" + # Check the condition here instead of respective if statements to better handle the warning + condition=self.global_options.get("substvar")==True if custom_condition==None else custom_condition + if condition==False: + # Handle substvar warning + if self.warnings.get('substvar') in (True,None): + for match in re.finditer(pattern, content): + if self.global_variables.get(match.group(1))!=None: + self.handle_warning(self.fd.feof("set-substvar-warn", "Line {num}: attempted to reference a defined variable, but \"substvar\" option is not enabled", num=line_number_debug if line_number_debug!=None else str(self.lineindex+1))) + self.warnings['substvar']=False + break + return content # get all variables used in content new_content=copy.copy(content) encountered_variables=set() offset=0 - for match in re.finditer(r"{{([^\s]+?)??}}", content): + for match in re.finditer(pattern, content): var_name=match.group(1) if var_name==None or var_name.strip()=='': continue if var_name=="ESC": continue # skip {{ESC}}; leave it for substesc @@ -158,18 +206,15 @@ class GeneratorObject(_handlers.DataHandlers): # sanity check var_name def bad_var(): self.handle_error(self.fd.feof("bad-var-name-err", "Line {num}: \"{name}\" is not a valid variable name", name=self.fmt(var_name), num=str(self.lineindex+1))) if var_name=='ESC': bad_var() - banphrases=['{', '}', '[', ']', '(', ')'] - for char in banphrases: + for char in self.substvar_banphrases: if char in var_name: bad_var() var_content=_globalvar.extract_content(line_content) - # subst variable references - check_list=self.really_really_global_options if really_really_global else self.global_options - if "substvar" in check_list and check_list["substvar"]==True: - var_content=self.subst_variable_content(var_content, override_check=True) + # Parse content without substesc (subst variable content) + var_content=self.parse_content(var_content, pure_name=True) # set variable if really_really_global: self.really_really_global_variables[var_name]=var_content - else: self.global_variables[var_name]=var_content + self.global_variables[var_name]=var_content def handle_begin_section(self, section_name: str): if section_name in self.parsed_sections: self.handle_error(self.fd.feof("repeated-section-err", "Repeated {section} section at line {num}", num=str(self.lineindex+1), section=section_name)) @@ -178,21 +223,40 @@ class GeneratorObject(_handlers.DataHandlers): def handle_end_section(self, section_name: str): self.parsed_sections.append(section_name) self.section_parsing=False - def handle_substesc(self, content: str) -> str: - return content.replace("{{ESC}}", "\x1b") + self.handle_setup_global_options() + def handle_substesc(self, content: str, condition: bool, line_number_debug: Optional[str]=None) -> str: + if condition==True: + return content.replace("{{ESC}}", "\x1b") + else: + # Handle substesc warning + if self.warnings.get("substesc") in (True,None) and "{{ESC}}" in content: + self.handle_warning(self.fd.feof("set-substesc-warn", "Line {num}: attempted to use \"{{{{ESC}}}}\", but \"substesc\" option is not enabled", num=line_number_debug if line_number_debug!=None else str(self.lineindex+1))) + self.warnings['substesc']=False + return content def handle_linenumber_range(self, begin: int, end: int) -> str: if begin==end: return str(end) else: return f"{begin}-{end}" - def handle_singleline_content(self, content: str) -> str: + def parse_content(self, content: str, pure_name: bool=False) -> str: target_content=copy.copy(content) target_content=self.subst_variable_content(target_content) - if "substesc" in self.global_options.keys() and self.global_options['substesc']==True: - target_content=self.handle_substesc(target_content) + if pure_name==False: + target_content=self.handle_substesc(target_content, condition=pure_name==False and self.global_options.get("substesc")==True) return target_content + def handle_setters(self, really_really_global: bool=False) -> bool: + # Handle set_options and setvar + phrases=self.lines_data[self.lineindex].split() + if phrases[0]=="set_options": + self.check_enough_args(phrases, 2) + self.handle_set_global_options(_globalvar.splitarray_to_string(phrases[1:]).split(), really_really_global) + elif phrases[0].startswith("setvar:"): + self.check_enough_args(phrases, 2) + self.handle_set_variable(self.lines_data[self.lineindex], really_really_global) + else: return False + return True ## sub-block processing functions - def handle_block_input(self, preserve_indents: bool, preserve_empty_lines: bool, end_phrase: str="end_block", disallow_cmdmatch_options: bool=True, disable_substesc: bool=False) -> str: + def handle_block_input(self, preserve_indents: bool, preserve_empty_lines: bool, end_phrase: str, disallow_other_options: bool=True, disable_substesc: bool=False) -> str: minspaces=math.inf blockinput_data="" begin_line_number=self.lineindex+1+1 @@ -227,161 +291,31 @@ class GeneratorObject(_handlers.DataHandlers): if preserve_indents: pattern=r"(?P\n|^)[ ]{"+str(minspaces)+"}" blockinput_data=re.sub(pattern,r"\g", blockinput_data, flags=re.MULTILINE) - # parse leadtabindents leadspaces, and substesc options + # parse leadtabindents, leadspaces, substesc, and substvar options here got_options=copy.copy(self.global_options) - specified_options={} if len(self.lines_data[self.lineindex].split())>1: - got_options=self.parse_options(self.lines_data[self.lineindex].split()[1:], merge_global_options=True) - specified_options=self.parse_options(self.lines_data[self.lineindex].split()[1:], merge_global_options=False) - for option in got_options.keys(): - def is_specified_in_block() -> bool: return option in specified_options.keys() - def check_whether_explicitly_specified(pass_condition: bool): - if not pass_condition and is_specified_in_block(): self.handle_error(self.fd.feof("option-not-allowed-err", "Option \"{phrase}\" not allowed here at line {num}", num=str(self.lineindex+1), phrase=self.fmt(option))) - if option=="leadtabindents": - check_whether_explicitly_specified(pass_condition=preserve_indents) - # insert tabs at start of each line - if preserve_indents: blockinput_data=re.sub(r"^", r"\t"*int(got_options['leadtabindents']), blockinput_data, flags=re.MULTILINE) - elif option=="leadspaces": - check_whether_explicitly_specified(pass_condition=preserve_indents) - # insert spaces at start of each line - if preserve_indents: blockinput_data=re.sub(r"^", " "*int(got_options['leadspaces']), blockinput_data, flags=re.MULTILINE) - elif option=="substesc": - check_whether_explicitly_specified(pass_condition=not disable_substesc) - # substitute {{ESC}} with escape literal - if got_options['substesc']==True and not disable_substesc: blockinput_data=self.handle_substesc(blockinput_data) - elif option=="substvar": - if got_options['substvar']==True: blockinput_data=self.subst_variable_content(blockinput_data, True, line_number_debug=self.handle_linenumber_range(begin_line_number, self.lineindex+1-1)) - elif disallow_cmdmatch_options: - if is_specified_in_block(): self.handle_error(self.fd.feof("option-not-allowed-err", "Option \"{phrase}\" not allowed here at line {num}", num=str(self.lineindex+1), phrase=self.fmt(option))) - return blockinput_data - def handle_entry(self, entry_name: str, start_phrase: str, end_phrase: str, is_substrules: bool=False, substrules_options: dict={}): - # substrules_options: {effective_commands: list, is_regex: bool, strictness: int, foreground_only: bool} - - entry_name_substesc=False; entry_name_substvar=False - names_processed=False # Set to True when no more entry names are being specified - - # For supporting specifying multiple entries at once (0: name, 1: uuid, 2: debug_linenumber) - entryNames: list=[(entry_name, uuid.uuid4(), self.lineindex+1)] - # For substrules_section: (0: match_content, 1: substitute_content, 2: locale, 3: entry_name_uuid, 4: content_linenumber_str, 5: match_content_linenumber) - # For entries_section: (0: target_entry, 1: content, 2: debug_linenumber, 3: entry_name_uuid, 4: entry_name_linenumber) - entries: list=[] - - substrules_endmatchhere=False - substrules_stdout_stderr_option=0 - - def check_valid_pattern(pattern: str, debug_linenumber: Union[str, int]=self.lineindex+1): - # check if patterns are valid - try: re.compile(pattern) - except: self.handle_error(self.fd.feof("bad-match-pattern-err", "Bad match pattern at line {num} ({error_msg})", num=str(debug_linenumber), error_msg=sys.exc_info()[1])) - while self.goto_next_line(): - phrases=self.lines_data[self.lineindex].split() - line_content=self.lines_data[self.lineindex] - # Support specifying multiple match pattern/entry names in one definition block - if phrases[0]!=start_phrase and not names_processed: - names_processed=True # Prevent specifying it after other definition syntax - # --Process entry names-- - for x in range(len(entryNames)): - each_entry=entryNames[x] - name=each_entry[0] - if not is_substrules: - if self.in_subsection!="": name=self.in_subsection+" "+name - if self.in_domainapp!="": name=self.in_domainapp+" "+name - entryNames[x]=(name, each_entry[1], each_entry[2]) - - if phrases[0]==start_phrase and not names_processed: - self.check_enough_args(phrases, 2) - pattern=_globalvar.extract_content(line_content) - entryNames.append((pattern, uuid.uuid4(), self.lineindex+1)) - elif phrases[0]=="locale" or phrases[0].startswith("locale:"): - content: str - locale: str - if phrases[0].startswith("locale:"): - self.check_enough_args(phrases, 2) - results=re.search(r"locale:(?P.+)", phrases[0]) - if results==None: - self.handle_error(self.fd.feof("not-enough-args-err", "Not enough arguments for \"{phrase}\" at line {num}", phrase="locale:", num=str(self.lineindex+1))) - else: - locale=results.groupdict()['locale'] - content=_globalvar.extract_content(line_content) - else: - self.check_enough_args(phrases, 3) - content=_globalvar.extract_content(line_content, begin_phrase_count=2) - locale=phrases[1] - content=self.handle_singleline_content(content) - for each_name in entryNames: - if is_substrules: - entries.append((each_name[0], content, None if locale=="default" else locale, each_name[1], str(self.lineindex+1), each_name[2])) - else: - target_entry=copy.copy(each_name[0]) - if locale!="default": - target_entry+="__"+locale - entries.append((target_entry, content, self.lineindex+1, each_name[1], each_name[2])) - elif phrases[0]=="locale_block" or phrases[0]=="[locale]": - self.check_enough_args(phrases, 2) - locales=self.subst_variable_content(_globalvar.splitarray_to_string(phrases[1:])).split() - begin_line_number=self.lineindex+1+1 - content=self.handle_block_input(preserve_indents=True, preserve_empty_lines=True, end_phrase="[/locale]" if phrases[0]=="[locale]" else "end_block") - for this_locale in locales: - for each_name in entryNames: - if is_substrules: - entries.append((each_name[0], content, None if this_locale=="default" else this_locale, each_name[1], self.handle_linenumber_range(begin_line_number, self.lineindex+1-1), each_name[2])) - else: - target_entry=copy.copy(each_name[0]) - if this_locale!="default": - target_entry+="__"+this_locale - entries.append((target_entry, content, begin_line_number, each_name[1], each_name[2])) - elif phrases[0]==end_phrase: - got_options=self.parse_options(phrases[1:] if len(phrases)>1 else [], merge_global_options=True, \ - allowed_options=\ - (self.subst_limiting_options if is_substrules else []) \ - +(self.content_subst_options if is_substrules else ["substvar"]) # don't allow substesc in `[entry]` - ) - for option in got_options: - if option=="endmatchhere" and got_options['endmatchhere']==True: - substrules_endmatchhere=True - elif option=="subststdoutonly" and got_options['subststdoutonly']==True: - substrules_stdout_stderr_option=1 - elif option=="subststderronly" and got_options['subststderronly']==True: - substrules_stdout_stderr_option=2 - elif option=="substesc" and got_options['substesc']==True: - entry_name_substesc=True - elif option=="substvar" and got_options['substvar']==True: - entry_name_substvar=True - break - else: self.handle_invalid_phrase(phrases[0]) - # For silence_warning in subst_variable_content - encountered_ids=set() - for x in range(len(entries)): - entry=entries[x] - match_pattern=entry[0] - # substvar MUST come before substesc or "{{ESC}}" in variable content will not be processed - if entry_name_substvar: - match_pattern=self.subst_variable_content(match_pattern, override_check=True, \ - line_number_debug=entry[5] if is_substrules else entry[4], \ - # Don't show warnings for the same match_pattern - silence_warnings=entry[3] in encountered_ids) - if entry_name_substesc: match_pattern=self.handle_substesc(match_pattern) - - if is_substrules: check_valid_pattern(match_pattern, entry[5]) + # Process allowed/banned options + ban_options=None; allowed_options=None + if not disallow_other_options: + ban_options=[] + if not preserve_indents: ban_options+=self.lead_indent_options + if disable_substesc: ban_options+=["substesc"] else: - # Prevent leading . & prevent /,\ in entry name - if _globalvar.sanity_check(match_pattern)==False: - self.handle_error(self.fd.feof("sanity-check-entry-err", "Line {num}: entry subsections/names {sanitycheck_msg}", num=str(entry[5]), sanitycheck_msg=_globalvar.sanity_check_error_message)) - encountered_ids.add(entry[3]) - if is_substrules: - try: - self.db_interface.add_subst_entry( - match_pattern=match_pattern, \ - substitute_pattern=entry[1], \ - effective_commands=substrules_options['effective_commands'], \ - effective_locale=entry[2], \ - is_regex=substrules_options['is_regex'], \ - command_match_strictness=substrules_options['strictness'], \ - end_match_here=substrules_endmatchhere, \ - stdout_stderr_matchoption=substrules_stdout_stderr_option, \ - foreground_only=substrules_options['foreground_only'], \ - line_number_debug=entry[4], \ - unique_id=entry[3]) - except self.db_interface.bad_pattern: self.handle_error(self.fd.feof("bad-subst-pattern-err", "Bad substitute pattern at line {num} ({error_msg})", num=entry[4], error_msg=sys.exc_info()[1])) - else: - self.add_entry(self.datapath, match_pattern, entry[1], entry[2]) \ No newline at end of file + allowed_options=[] + if preserve_indents: allowed_options+=self.lead_indent_options + if not disable_substesc: allowed_options+=["substesc"] + allowed_options+=["substvar"] + got_options=self.parse_options(self.lines_data[self.lineindex].split()[1:], + merge_global_options=True, + allowed_options=allowed_options, ban_options=ban_options) + debug_linenumber=self.handle_linenumber_range(begin_line_number, self.lineindex+1-1) + if preserve_indents and got_options.get("leadtabindents")!=None: + blockinput_data=re.sub(r"^", r"\t"*int(got_options['leadtabindents']), blockinput_data, flags=re.MULTILINE) + if preserve_indents and got_options.get("leadspaces")!=None: + blockinput_data=re.sub(r"^", " "*int(got_options['leadspaces']), blockinput_data, flags=re.MULTILINE) + # Process substvar + blockinput_data=self.subst_variable_content(blockinput_data, custom_condition=got_options.get("substvar")==True, line_number_debug=debug_linenumber) + if not disable_substesc: # Must come after substvar + blockinput_data=self.handle_substesc(blockinput_data, condition=got_options.get("substesc")==True, line_number_debug=debug_linenumber) + return blockinput_data + handle_entry=_entry_block_handler.handle_entry \ No newline at end of file diff --git a/src/clitheme/_generator/_substrules_parser.py b/src/clitheme/_generator/_substrules_parser.py index c5ce821c45b1734b0ed28c61892ae03149e3f85d..b6fd8c1734ce8994f26d64428685db86784e306e 100644 --- a/src/clitheme/_generator/_substrules_parser.py +++ b/src/clitheme/_generator/_substrules_parser.py @@ -11,22 +11,31 @@ import os import copy from typing import Optional from .. import _globalvar -from . import _dataclass +from . import _parser_handlers # spell-checker:ignore infofile splitarray datapath lineindex banphrases cmdmatch minspaces blockinput optline matchoption endphrase filecontent -def handle_substrules_section(obj: _dataclass.GeneratorObject, first_phrase: str): +def handle_substrules_section(obj: _parser_handlers.GeneratorObject, first_phrase: str): obj.handle_begin_section("substrules") end_phrase=r"{/substrules_section}" command_filters: Optional[list]=None command_filter_strictness=0 - command_filter_foreground_only=False + # If True, reset foregroundonly option to beforehand during next command filter + outline_foregroundonly=None + def reset_outline_foregroundonly(): + """ + Set foregroundonly option to false if foregroundonly option is "inline" and not enabled previously + """ + nonlocal outline_foregroundonly + if outline_foregroundonly!=None: + obj.global_options['foregroundonly']=outline_foregroundonly + outline_foregroundonly=None # initialize the database if os.path.exists(obj.path+"/"+_globalvar.db_filename): try: obj.db_interface.connect_db(path=obj.path+"/"+_globalvar.db_filename) except obj.db_interface.need_db_regenerate: from ..exec import _check_regenerate_db - if not _check_regenerate_db(obj.path): raise RuntimeError(obj.fd.reof("db-regenerate-fail-err", "Failed to migrate existing substrules database; try performing the operation without using \"--overlay\"")) + if not _check_regenerate_db(obj.path): obj.handle_error(obj.fd.reof("db-regenerate-fail-err", "Failed to migrate existing substrules database; try performing the operation without using \"--overlay\""), not_syntax_error=True) obj.db_interface.connect_db(path=obj.path+"/"+_globalvar.db_filename) else: obj.db_interface.init_db(obj.path+"/"+_globalvar.db_filename) obj.db_interface.debug_mode=not obj.silence_warn @@ -34,16 +43,18 @@ def handle_substrules_section(obj: _dataclass.GeneratorObject, first_phrase: str phrases=obj.lines_data[obj.lineindex].split() if phrases[0]=="[filter_commands]": obj.check_extra_args(phrases, 1, use_exact_count=True) - content=obj.handle_block_input(preserve_indents=False, preserve_empty_lines=False, end_phrase=r"[/filter_commands]", disallow_cmdmatch_options=False, disable_substesc=True) + reset_outline_foregroundonly() + content=obj.handle_block_input(preserve_indents=False, preserve_empty_lines=False, end_phrase=r"[/filter_commands]", disallow_other_options=False, disable_substesc=True) # read commands command_strings=content.splitlines() strictness=0 - foreground_only=False # parse strictcmdmatch, exactcmdmatch, and other cmdmatch options here got_options=copy.copy(obj.global_options) + inline_options={} if len(obj.lines_data[obj.lineindex].split())>1: got_options=obj.parse_options(obj.lines_data[obj.lineindex].split()[1:], merge_global_options=True, allowed_options=obj.block_input_options+obj.command_filter_options) + inline_options=obj.parse_options(obj.lines_data[obj.lineindex].split()[1:], merge_global_options=False, allowed_options=obj.block_input_options+obj.command_filter_options) for this_option in got_options: if this_option=="strictcmdmatch" and got_options['strictcmdmatch']==True: strictness=1 @@ -51,19 +62,19 @@ def handle_substrules_section(obj: _dataclass.GeneratorObject, first_phrase: str strictness=2 elif this_option=="smartcmdmatch" and got_options['smartcmdmatch']==True: strictness=-1 - elif this_option=="foregroundonly" and got_options['foregroundonly']==True: - foreground_only=True + elif this_option=="foregroundonly" and "foregroundonly" in inline_options.keys(): + outline_foregroundonly=obj.global_options.get('foregroundonly')==True + obj.global_options['foregroundonly']=inline_options['foregroundonly'] command_filters=[] for cmd in command_strings: command_filters.append(cmd.strip()) command_filter_strictness=strictness - command_filter_foreground_only=foreground_only elif phrases[0]=="filter_command": obj.check_enough_args(phrases, 2) + reset_outline_foregroundonly() content=_globalvar.splitarray_to_string(phrases[1:]) - content=obj.subst_variable_content(content) + content=obj.parse_content(content, pure_name=True) strictness=0 - foreground_only=False for this_option in obj.global_options: if this_option=="strictcmdmatch" and obj.global_options['strictcmdmatch']==True: strictness=1 @@ -71,25 +82,20 @@ def handle_substrules_section(obj: _dataclass.GeneratorObject, first_phrase: str strictness=2 elif this_option=="smartcmdmatch" and obj.global_options['smartcmdmatch']==True: strictness=-1 - elif this_option=="foregroundonly" and obj.global_options['foregroundonly']==True: - foreground_only=True command_filters=[content] command_filter_strictness=strictness - command_filter_foreground_only=foreground_only elif phrases[0]=="unset_filter_command": obj.check_extra_args(phrases, 1, use_exact_count=True) + reset_outline_foregroundonly() command_filters=None - elif phrases[0]=="[substitute_string]" or phrases[0]=="[substitute_regex]": + elif phrases[0] in ("[subst_string]", "[substitute_string]", "[subst_regex]", "[substitute_regex]"): obj.check_enough_args(phrases, 2) - options={"effective_commands": copy.copy(command_filters), "is_regex": phrases[0]=="[substitute_regex]", "strictness": command_filter_strictness, "foreground_only": command_filter_foreground_only} + options={"effective_commands": copy.copy(command_filters), + "is_regex": phrases[0] in ("[subst_regex]", "[substitute_regex]"), + "strictness": command_filter_strictness} match_pattern=_globalvar.extract_content(obj.lines_data[obj.lineindex]) - obj.handle_entry(match_pattern, start_phrase=phrases[0], end_phrase="[/substitute_string]" if phrases[0]=="[substitute_string]" else "[/substitute_regex]", is_substrules=True, substrules_options=options) - elif phrases[0]=="set_options": - obj.check_enough_args(phrases, 2) - obj.handle_set_global_options(obj.subst_variable_content(_globalvar.splitarray_to_string(phrases[1:])).split()) - elif phrases[0].startswith("setvar:"): - obj.check_enough_args(phrases, 2) - obj.handle_set_variable(obj.lines_data[obj.lineindex]) + obj.handle_entry(match_pattern, start_phrase=phrases[0], end_phrase=phrases[0].replace('[', '[/'), is_substrules=True, substrules_options=options) + elif obj.handle_setters(): pass elif phrases[0]==end_phrase: obj.check_extra_args(phrases, 1, use_exact_count=True) obj.handle_end_section("substrules") diff --git a/src/clitheme/_generator/db_interface.py b/src/clitheme/_generator/db_interface.py index 630626207fa2d9f182a25bc920df97916bcdd216..157196cfeaf5c76a8a8af1159669071afccf5665 100644 --- a/src/clitheme/_generator/db_interface.py +++ b/src/clitheme/_generator/db_interface.py @@ -14,7 +14,8 @@ import sqlite3 import re import copy import uuid -from typing import Optional +import gc +from typing import Optional, List, Tuple, Dict from .. import _globalvar, frontend # spell-checker:ignore matchoption cmdlist exactmatch rowid pids tcpgrp @@ -22,13 +23,11 @@ from .. import _globalvar, frontend connection=sqlite3.connect(":memory:") # placeholder db_path="" debug_mode=False -_globalvar.handle_set_themedef(frontend, "db_interface") fd=frontend.FetchDescriptor(domain_name="swiftycode", app_name="clitheme", subsections="generator") -class need_db_regenerate(Exception): - pass -class bad_pattern(Exception): - pass +class need_db_regenerate(Exception): pass +class bad_pattern(Exception): pass +class db_not_found(Exception): pass def _handle_warning(message: str): if debug_mode: print(fd.feof("warning-str", "Warning: {msg}", msg=message)) @@ -44,6 +43,7 @@ def init_db(file_path: str): substitute_pattern TEXT NOT NULL, \ is_regex INTEGER NOT NULL, \ unique_id TEXT NOT NULL, \ + file_id TEXT NOT NULL, \ effective_command TEXT, \ effective_locale TEXT, \ command_match_strictness INTEGER NOT NULL, \ @@ -66,14 +66,14 @@ def connect_db(path: str=f"{_globalvar.clitheme_root_data_path}/{_globalvar.db_f if version!=_globalvar.db_version: raise need_db_regenerate -def add_subst_entry(match_pattern: str, substitute_pattern: str, effective_commands: Optional[list], effective_locale: Optional[str]=None, is_regex: bool=True, command_match_strictness: int=0, end_match_here: bool=False, stdout_stderr_matchoption: int=0, foreground_only: bool=False, unique_id: uuid.UUID=uuid.UUID(int=0), line_number_debug: str="-1"): +def add_subst_entry(match_pattern: str, substitute_pattern: str, effective_commands: Optional[list], effective_locale: Optional[str]=None, is_regex: bool=True, command_match_strictness: int=0, end_match_here: bool=False, stdout_stderr_matchoption: int=0, foreground_only: bool=False, unique_id: uuid.UUID=uuid.UUID(int=0), file_id: uuid.UUID=uuid.UUID(int=0), line_number_debug: str="-1"): if unique_id==uuid.UUID(int=0): unique_id=uuid.uuid4() - cmdlist: list=[] + cmdlist: List[str]=[] try: re.sub(match_pattern, substitute_pattern, "") # test if patterns are valid except: raise bad_pattern(str(sys.exc_info()[1])) # handle condition where no effective_locale is specified ("default") locale_condition="AND effective_locale=?" if effective_locale!=None else "AND typeof(effective_locale)=typeof(?)" - insert_values=["match_pattern", "substitute_pattern", "effective_command", "is_regex", "command_match_strictness", "end_match_here", "effective_locale", "stdout_stderr_only", "unique_id", "foreground_only"] + insert_values=["match_pattern", "substitute_pattern", "effective_command", "is_regex", "command_match_strictness", "end_match_here", "effective_locale", "stdout_stderr_only", "unique_id", "foreground_only", "file_id"] if effective_commands!=None and len(effective_commands)>0: for cmd in effective_commands: # remove extra spaces in the command @@ -86,7 +86,7 @@ def add_subst_entry(match_pattern: str, substitute_pattern: str, effective_comma _handle_warning(fd.feof("repeated-substrules-warn", "Repeated substrules entry at line {num}, overwriting", num=line_number_debug)) connection.execute(f"DELETE FROM {_globalvar.db_data_tablename} WHERE {match_condition};", match_params) # insert the entry into the main table - connection.execute(f"INSERT INTO {_globalvar.db_data_tablename} ({','.join(insert_values)}) VALUES ({','.join('?'*len(insert_values))});", (match_pattern, substitute_pattern, None, is_regex, command_match_strictness, end_match_here, effective_locale, stdout_stderr_matchoption, str(unique_id), foreground_only)) + connection.execute(f"INSERT INTO {_globalvar.db_data_tablename} ({','.join(insert_values)}) VALUES ({','.join('?'*len(insert_values))});", (match_pattern, substitute_pattern, None, is_regex, command_match_strictness, end_match_here, effective_locale, stdout_stderr_matchoption, str(unique_id), foreground_only, str(file_id))) for cmd in cmdlist: # remove any existing values with the same match_pattern and effective_command strictness_condition="" @@ -97,47 +97,41 @@ def add_subst_entry(match_pattern: str, substitute_pattern: str, effective_comma _handle_warning(fd.feof("repeated-substrules-warn", "Repeated substrules entry at line {num}, overwriting", num=line_number_debug)) connection.execute(f"DELETE FROM {_globalvar.db_data_tablename} WHERE {match_condition};", match_params) # insert the entry into the main table - connection.execute(f"INSERT INTO {_globalvar.db_data_tablename} ({','.join(insert_values)}) VALUES ({','.join('?'*len(insert_values))});", (match_pattern, substitute_pattern, cmd, is_regex, command_match_strictness, end_match_here, effective_locale, stdout_stderr_matchoption, str(unique_id), foreground_only)) + connection.execute(f"INSERT INTO {_globalvar.db_data_tablename} ({','.join(insert_values)}) VALUES ({','.join('?'*len(insert_values))});", (match_pattern, substitute_pattern, cmd, is_regex, command_match_strictness, end_match_here, effective_locale, stdout_stderr_matchoption, str(unique_id), foreground_only, str(file_id))) connection.commit() -def _check_strictness(match_cmd: str, strictness: int, target_command: str): - def process_smartcmdmatch_phrases(match_cmd: str) -> list: - match_cmd_phrases=[] - for p in range(len(match_cmd.split())): - ph=match_cmd.split()[p] - results=re.search(r"^-([^-]+)$",ph) - if p>0 and results!=None: - for character in results.groups()[0]: match_cmd_phrases.append("-"+character) - else: match_cmd_phrases.append(ph) - return match_cmd_phrases - success=True - if strictness==1: # must start with pattern in terms of space-separated phrases - condition=len(match_cmd.split())<=len(target_command.split()) and target_command.split()[:len(match_cmd.split())]==match_cmd.split() - if not condition==True: success=False - elif strictness==2: # must equal to pattern - if not re.sub(r" {2,}", " ", target_command).strip()==match_cmd: success=False - elif strictness==-1: # smartcmdmatch: split phrases starting with one '-' and split them. Then, perform strictness==0 operation - # process both phrases - match_cmd_phrases=process_smartcmdmatch_phrases(match_cmd) - command_phrases=process_smartcmdmatch_phrases(target_command) - for phrase in match_cmd_phrases: - if phrase not in command_phrases: success=False - else: # implying strictness==0; must contain all phrases in pattern - for phrase in match_cmd.split(): - if phrase not in target_command.split(): success=False - return success +## Database fetch caching -def match_content(content: bytes, command: Optional[str]=None, is_stderr: bool=False, pids: tuple=(-1,-1)) -> bytes: - # pids: (main_pid, current_tcpgrp) +_db_last_state: Optional[float]=None +_matches_cache: Dict[Optional[str],List[tuple]]={} - # Match order: - # 1. Match rules with exactcmdmatch option set - # 2. Match rules with command filter having the same first phrase - # - Command filters with greater number of phrases are prioritized over others - # 3. Match rules without command filter +def _is_db_updated() -> bool: + global _db_last_state + cur_state: Optional[float] + try: + # Check modification time + cur_state=os.stat(db_path).st_mtime + except FileNotFoundError: cur_state=None + updated=False + if cur_state!=_db_last_state: + updated=True + _db_last_state=cur_state + return updated - # retrieve a list of effective commands matching first argument - if not os.path.exists(db_path): raise sqlite3.OperationalError("file at db_path does not exist") +def _fetch_matches(command: Optional[str]) -> List[tuple]: + global _matches_cache + updated=_is_db_updated() + if _db_last_state==None: raise db_not_found("file at db_path does not exist") + if updated or _matches_cache.get(command)==None: + if updated: + _matches_cache.clear() + gc.collect() # Reduce memory leak + _matches_cache[command]=_get_matches(command) + return _matches_cache[command] + +## Output processing and matching + +def _get_matches(command: Optional[str]) -> List[tuple]: _connection=sqlite3.connect(db_path) final_cmdlist=[] final_cmdlist_exactmatch=[] @@ -164,11 +158,9 @@ def match_content(content: bytes, command: Optional[str]=None, is_stderr: bool=F if match_cmd not in final_cmdlist: final_cmdlist.append(match_cmd) final_cmdlist_exactmatch.append(strictness==2) - - content_str=copy.copy(content) matches=[] def fetch_matches_by_locale(filter_condition: str, filter_data: tuple=tuple()): - fetch_items=["match_pattern", "substitute_pattern", "is_regex", "end_match_here", "stdout_stderr_only", "unique_id", "foreground_only", "effective_command", "command_match_strictness"] + fetch_items=["match_pattern", "substitute_pattern", "is_regex", "end_match_here", "stdout_stderr_only", "unique_id", "foreground_only", "effective_command", "command_match_strictness", "file_id"] # get locales locales=_globalvar.get_locale() nonlocal matches @@ -187,19 +179,63 @@ def match_content(content: bytes, command: Optional[str]=None, is_stderr: bool=F # also append matches with other strictness fetch_matches_by_locale("effective_command=? AND command_match_strictness!=2", (cmd,)) fetch_matches_by_locale("typeof(effective_command)=typeof(null)") + return matches + +def _check_strictness(match_cmd: str, strictness: int, target_command: str): + def process_smartcmdmatch_phrases(match_cmd: str) -> List[str]: + match_cmd_phrases=[] + for p in range(len(match_cmd.split())): + ph=match_cmd.split()[p] + results=re.search(r"^-([^-]+)$",ph) + if p>0 and results!=None: + for character in results.groups()[0]: match_cmd_phrases.append("-"+character) + else: match_cmd_phrases.append(ph) + return match_cmd_phrases + success=True + if strictness==1: # must start with pattern in terms of space-separated phrases + condition=len(match_cmd.split())<=len(target_command.split()) and target_command.split()[:len(match_cmd.split())]==match_cmd.split() + if not condition==True: success=False + elif strictness==2: # must equal to pattern + if not re.sub(r" {2,}", " ", target_command).strip()==match_cmd: success=False + elif strictness==-1: # smartcmdmatch: split phrases starting with one '-' and split them. Then, perform strictness==0 operation + # process both phrases + match_cmd_phrases=process_smartcmdmatch_phrases(match_cmd) + command_phrases=process_smartcmdmatch_phrases(target_command) + for phrase in match_cmd_phrases: + if phrase not in command_phrases: success=False + else: # implying strictness==0; must contain all phrases in pattern + for phrase in match_cmd.split(): + if phrase not in target_command.split(): success=False + return success + +def match_content(content: bytes, command: Optional[str]=None, is_stderr: bool=False, pids: Tuple[int,int]=(-1,-1)) -> bytes: + # pids: (main_pid, current_tcpgrp) + + # Match order: + # 1. Match rules with exactcmdmatch option set + # 2. Match rules with command filter having the same first phrase + # - Command filters with greater number of phrases are prioritized over others + # 3. Match rules without command filter + + # retrieve a list of effective commands matching first argument + matches=_fetch_matches(command) + content_str=copy.copy(content) content_str=_handle_subst(matches, content_str, is_stderr, pids, command) return content_str # timeout value for each match operation match_timeout=_globalvar.output_subst_timeout -def _handle_subst(matches: list, content: bytes, is_stderr: bool, pids: tuple, target_command: Optional[str]): +def _handle_subst(matches: List[tuple], content: bytes, is_stderr: bool, pids: Tuple[int,int], target_command: Optional[str]) -> bytes: content_str=copy.copy(content) encountered_ids=set() + skipped_files=set() # File ids skipped with endmatchhere option for match_data in matches: if match_data[4]!=0 and is_stderr+1!=match_data[4]: continue # check stdout/stderr constraint if match_data[5] in encountered_ids: continue # check uuid else: encountered_ids.add(match_data[5]) + # check if corresponding file is skipped due to endmatchhere + if match_data[9] in skipped_files: continue # Check strictness if target_command!=None and match_data[7]!=None and \ _check_strictness(match_data[7], match_data[8], \ @@ -224,5 +260,5 @@ def _handle_subst(matches: list, content: bytes, is_stderr: bool, pids: tuple, t matched=bytes(match_data[0], 'utf-8') in content_str content_str=content_str.replace(bytes(match_data[0],'utf-8'), bytes(match_data[1],'utf-8')) if match_data[3]==True and matched: # endmatchhere is set - break + skipped_files.add(match_data[9]) return content_str diff --git a/src/clitheme/_get_resource.py b/src/clitheme/_get_resource.py index 794197065e0ee9fad4e151e734355e352e530506..83646b85653ba8dc848e1fe2b957cf015e13b7f7 100644 --- a/src/clitheme/_get_resource.py +++ b/src/clitheme/_get_resource.py @@ -1,3 +1,9 @@ +# Copyright © 2023-2024 swiftycode + +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . + """ Script to get contents of file inside the module """ diff --git a/src/clitheme/_globalvar.py b/src/clitheme/_globalvar.py index b99c5e5a7dcaf01e1988e5c090e4c16a1e9cbd66..eb83891e6bacc73b742714303947b82b5d9fc972 100644 --- a/src/clitheme/_globalvar.py +++ b/src/clitheme/_globalvar.py @@ -5,7 +5,7 @@ # You should have received a copy of the GNU General Public License along with this program. If not, see . """ -Global variable definitions and initialization operations for clitheme +Global variable definitions for clitheme """ import io @@ -13,29 +13,13 @@ import os import sys import re import string +import stat from copy import copy from . import _version +from typing import List # spell-checker:ignoreRegExp banphrase[s]{0,1} -## Initialization operations - -# Enable processing of escape characters in Windows Command Prompt -if os.name=="nt": - import ctypes - - try: - handle=ctypes.windll.kernel32.GetStdHandle(-11) # standard output handle - console_mode=ctypes.c_long() - if ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(console_mode))==0: - raise Exception("GetConsoleMode failed: "+str(ctypes.windll.kernel32.GetLastError())) - console_mode.value|=0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING - if ctypes.windll.kernel32.SetConsoleMode(handle, console_mode.value)==0: - raise Exception("SetConsoleMode failed: "+str(ctypes.windll.kernel32.GetLastError())) - except: - pass - - error_msg_str= \ """[clitheme] Error: unable to get your home directory or invalid home directory information. Please make sure that the {var} environment variable is set correctly. @@ -77,13 +61,13 @@ generator_info_v2filename=generator_info_filename+"_v2" # e.g. [...]/theme-info/ ## _generator.db_interface file and table names db_data_tablename="clitheme_subst_data" db_filename="subst-data.db" # e.g. ~/.local/share/clitheme/subst-data.db -db_version=3 +db_version=4 ## clitheme-exec timeout value for each output substitution operation output_subst_timeout=0.4 ## Sanity check function -entry_banphrases=['/','\\'] +entry_banphrases=['<', '>', ':', '"', '/', '\\', '|', '?', '*'] startswith_banphrases=['.'] banphrase_error_message="cannot contain '{char}'" banphrase_error_message_orig=copy(banphrase_error_message) @@ -102,7 +86,6 @@ def sanity_check(path: str, use_orig: bool=False) -> bool: global msg_retrieved global sanity_check_error_message, banphrase_error_message, startswith_error_message if not msg_retrieved: - handle_set_themedef(frontend, "_globalvar") msg_retrieved=True f=frontend.FetchDescriptor(domain_name="swiftycode", app_name="clitheme", subsections="generator") banphrase_error_message=f.feof("sanity-check-msg-banphrase-err", banphrase_error_message, char="{char}") @@ -123,15 +106,28 @@ def sanity_check(path: str, use_orig: bool=False) -> bool: ## Convenience functions -def splitarray_to_string(split_content) -> str: +class _direct_exit(Exception): + def __init__(self, code): + """ + Custom exception for handling return code inside another function callback + """ + self.code=code +def splitarray_to_string(split_content: List[str]) -> str: final="" for phrase in split_content: final+=phrase+" " return final.strip() def extract_content(line_content: str, begin_phrase_count: int=1) -> str: - results=re.search(r"(?:[ \t]*.+?[ \t]+){"+str(begin_phrase_count)+r"}(?P.+)", line_content.strip()) + results=re.search(r"(?:\s*.+?\s+){"+str(begin_phrase_count)+r"}(?P.+)", line_content.strip()) if results==None: raise ValueError("Match content failed (no matches)") else: return results.groupdict()['content'] +def list_directory(dirname: str): + lsdir_result=os.listdir(dirname) + final_result=[] + for name in lsdir_result: + if not name.startswith('.'): + final_result.append(name) + return final_result def make_printable(content: str) -> str: final_str="" for character in content: @@ -142,7 +138,7 @@ def make_printable(content: str) -> str: exp=re.sub(r"""^(?P['"]?)(?P.+)(?P=quote)$""", r"<\g>", exp) final_str+=exp return final_str -def get_locale(debug_mode: bool=False) -> list: +def get_locale(debug_mode: bool=False) -> List[str]: lang=[] def add_language(target_lang: str): nonlocal lang @@ -190,23 +186,45 @@ def handle_exception(): if env_var in os.environ and os.environ[env_var]=="1": raise -def handle_set_themedef(fr, debug_name: str): +def handle_stdin_prompt(path: str) -> bool: + fi=frontend.FetchDescriptor(domain_name="swiftycode", app_name="clitheme", subsections="cli apply-theme") + is_stdin=False + try: + if os.stat(path).st_ino==os.stat(sys.stdin.fileno()).st_ino: + is_stdin=True + print("\n"+fi.reof("reading-stdin-note", "Reading from standard input")) + if not stat.S_ISFIFO(os.stat(path).st_mode): + print(fi.feof("stdin-interactive-finish-prompt", "Input file content here and press {shortcut} to finish", shortcut="CTRL-D" if os.name=="posix" else "CTRL-Z+")) + except: pass + return is_stdin + +def handle_set_themedef(fr: frontend, debug_name: str): # type: ignore prev_mode=False # Prevent interference with other code piping stdout orig_stdout=sys.stdout try: files=["strings/generator-strings.clithemedef.txt", "strings/cli-strings.clithemedef.txt", "strings/exec-strings.clithemedef.txt", "strings/man-strings.clithemedef.txt"] - for x in range(len(files)): - filename=files[x] - msg=io.StringIO() - sys.stdout=msg - fr.global_debugmode=True - if not fr.set_local_themedef(_get_resource.read_file(filename), overlay=not x==0): raise RuntimeError("Full log below: \n"+msg.getvalue()) - fr.global_debugmode=prev_mode - sys.stdout=orig_stdout + file_contents=list(map(lambda name: _get_resource.read_file(name), files)) + msg=io.StringIO() + sys.stdout=msg + fr.set_debugmode(True) + if not fr.set_local_themedefs(file_contents): raise RuntimeError("Full log below: \n"+msg.getvalue()) + fr.set_debugmode(prev_mode) + sys.stdout=orig_stdout except: sys.stdout=orig_stdout - fr.global_debugmode=prev_mode - if _version.release<0: print(f"{debug_name} set_local_themedef failed: "+str(sys.exc_info()[1]), file=sys.__stdout__) - handle_exception() - finally: sys.stdout=orig_stdout \ No newline at end of file + fr.set_debugmode(prev_mode) + # If pre-release build or manual environment variable flag set, display error + if _version.release<0 or os.environ.get("CLITHEME_SHOW_TRACEBACK")=='1': + print(f"{debug_name} set_local_themedef failed: "+str(sys.exc_info()[1]), file=sys.__stdout__) + handle_exception() + finally: sys.stdout=orig_stdout +def result_sort_cmp(obj1,obj2) -> int: + cmp1='';cmp2='' + try: + cmp1=int(obj1); cmp2=int(obj2) + except ValueError: + cmp1=obj1; cmp2=obj2 + if cmp1>cmp2: return 1 + elif cmp1==cmp2: return 0 + else: return -1 \ No newline at end of file diff --git a/src/clitheme/_version.py b/src/clitheme/_version.py index 80fc14dd20a98d6aaf13f316fa2a9e1a9c1fe192..60360102b903fad91a87b6db01fc56eb7f83402c 100644 --- a/src/clitheme/_version.py +++ b/src/clitheme/_version.py @@ -1,3 +1,9 @@ +# Copyright © 2023-2024 swiftycode + +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . + """ Version information definition file """ @@ -5,11 +11,12 @@ Version information definition file # Version definition file; define the package version here # The __version__ variable must be a literal string; DO NOT use variables -__version__="2.0-beta2" +__version__="2.0-beta3" major=2 minor=0 release=-1 # -1 stands for "dev" +beta_release=3 # None if not beta # For PKGBUILD # version_main CANNOT contain hyphens (-); use underscores (_) instead -version_main="2.0_beta2" +version_main="2.0_beta3" version_buildnumber=1 \ No newline at end of file diff --git a/src/clitheme/cli.py b/src/clitheme/cli.py index 3a7fe8beb141e5da2bdd61c1f2c86a668f2ed277..aae1c7a97a69d76a6d78488162b8b246ca1853b6 100644 --- a/src/clitheme/cli.py +++ b/src/clitheme/cli.py @@ -16,51 +16,62 @@ import sys import shutil import re import io +import stat +import functools from . import _globalvar, _generator, frontend from ._globalvar import make_printable as fmt # A shorter alias of the function +from ._globalvar import _direct_exit +from typing import List, Optional # spell-checker:ignore pathnames lsdir inpstr -frontend.global_domain="swiftycode" -frontend.global_appname="clitheme" -frontend.global_subsections="cli" - -_globalvar.handle_set_themedef(frontend, "cli") +frontend.set_domain("swiftycode") +frontend.set_appname("clitheme") +frontend.set_subsections("cli") last_data_path="" -def apply_theme(file_contents: list, filenames: list, overlay: bool, preserve_temp=False, generate_only=False): +def apply_theme(file_contents: Optional[List[str]], filenames: List[str], overlay: bool=False, preserve_temp=False, generate_only=False, no_confirm=False): """ Apply the theme using the provided definition file contents and file pathnames in a list object. (Invokes 'clitheme apply-theme') + - Set file_contents=None to read file contents from specified filenames - Set overlay=True to overlay the theme on top of existing theme[s] - Set preserve_temp=True to preserve the temp directory (debugging purposes) - Set generate_only=True to generate the data hierarchy only (invokes 'clitheme generate-data' instead) + - Set no_confirm=True to skip the user confirmation prompt """ + if file_contents==None: + try: file_contents=_get_file_contents(filenames) + except _direct_exit as exc: return exc.code + except: + _globalvar.handle_exception() + return 1 if len(filenames)>0 and len(file_contents)!=len(filenames): # unlikely to happen raise ValueError("file_contents and filenames have different lengths") f=frontend.FetchDescriptor(subsections="cli apply-theme") - if len(filenames)>1 or True: # currently set to True for now - if generate_only: - print(f.reof("generate-data-msg", "The theme data will be generated from the following definition files in the following order:")) - else: - print(f.reof("apply-theme-msg", "The following definition files will be applied in the following order: ")) - for i in range(len(filenames)): - path=filenames[i] - print("\t{}: {}".format(str(i+1), path)) - if not generate_only: - if os.path.isdir(_globalvar.clitheme_root_data_path) and overlay==False: - print(f.reof("overwrite-notice", "The existing theme data will be overwritten if you continue.")) - if overlay==True: - print(f.reof("overlay-notice", "The definition files will be appended on top of the existing theme data.")) + # Display information and confirmation prompt + if generate_only: + print(f.reof("generate-data-msg", "The theme data will be generated from the following definition files in the following order:")) + else: + print(f.reof("apply-theme-msg", "The following definition files will be applied in the following order: ")) + for i in range(len(filenames)): + path=filenames[i] + print("\t{}: {}".format(str(i+1), fmt(path))) + if not generate_only: + if os.path.isdir(_globalvar.clitheme_root_data_path) and overlay==False: + print(f.reof("overwrite-notice", "The existing theme data will be overwritten if you continue.")) + if overlay==True: + print(f.reof("overlay-notice", "The definition files will be appended on top of the existing theme data.")) + if not no_confirm: inpstr=f.reof("confirm-prompt", "Do you want to continue? [y/n]") try: inp=input(inpstr+" ").strip().lower() except (KeyboardInterrupt, EOFError): print();return 130 if not (inp=="y" or inp=="yes"): return 1 if overlay: print(f.reof("overlay-msg", "Overlay specified")) - print(f.reof("generating-data", "==> Generating data...")) + print(f.reof("processing-files", "==> Processing files...")) index=1 generate_path=True if overlay: @@ -81,8 +92,9 @@ def apply_theme(file_contents: list, filenames: list, overlay: bool, preserve_te shutil.copytree(_globalvar.clitheme_root_data_path, _generator.path) generate_path=False final_path: str - line_prefix="\x1b[2K\r " # clear current line content and move cursor to beginning - print_progress=len(file_contents)>1 + line_prefix=f"\x1b[2K\r{' '*4}" # clear current line content and move cursor to beginning + print_progress=True #len(file_contents)>1 + newline="\n" if print_progress else "" orig_stdout=sys.stdout # Prevent interference with other code piping stdout for i in range(len(file_contents)): if print_progress: @@ -99,12 +111,12 @@ def apply_theme(file_contents: list, filenames: list, overlay: bool, preserve_te index+=1 except Exception as exc: sys.stdout=orig_stdout - print(("\n" if print_progress else ""), end='') + print(newline, end='') # Print any output messages if an error occurs if generator_msgs.getvalue()!='': # end='' because the pipe value already contains a newline due to the print statements print(generator_msgs.getvalue(), end='') - print(f.feof("generate-data-error", "[File {index}] An error occurred while generating the data:\n{message}", \ + print(f.feof("process-files-error", "[File {index}] An error occurred while processing the file:\n{message}", \ index=str(i+1), message=str(sys.exc_info()[1]))) if type(exc)==SyntaxError: _globalvar.handle_exception() else: raise # Always raise exception if other error occurred in _generator @@ -112,11 +124,9 @@ def apply_theme(file_contents: list, filenames: list, overlay: bool, preserve_te else: sys.stdout=orig_stdout # restore standard output if generator_msgs.getvalue()!='': - print(("\n" if print_progress else "")+generator_msgs.getvalue(), end='') + print(newline+generator_msgs.getvalue(), end='') finally: sys.stdout=orig_stdout # failsafe just in case something didn't work - if print_progress: - print(line_prefix+f.reof("all-finished", "> All finished")) - print(f.reof("generate-data-success", "Successfully generated data")) + print((line_prefix.rstrip(' ') if print_progress else "")+f.reof("process-files-success", "Successfully processed files")) global last_data_path; last_data_path=final_path if preserve_temp or generate_only: if os.name=="nt": @@ -170,9 +180,13 @@ def unset_current_theme(): print(f.reof("remove-data-success", "Successfully removed the current theme data")) return 0 -def get_current_theme_info(): +def get_current_theme_info(name: bool=False, file_path=False): """ - Get the current theme info + Displays the current theme info + + - Set name=True to only display the name of each theme + - Set file_path=True to only display the source file path of each theme + - Both information are displayed when both options are set to True (Invokes 'clitheme get-current-theme-info') """ @@ -181,24 +195,29 @@ def get_current_theme_info(): if not os.path.isdir(search_path): print(f.reof("no-theme", "No theme currently set")) return 1 - lsdir_result=os.listdir(search_path) - lsdir_result.sort(reverse=True) # sort by latest installed + lsdir_result=_globalvar.list_directory(search_path) + lsdir_result.sort(key=functools.cmp_to_key(_globalvar.result_sort_cmp)) lsdir_num=0 for x in lsdir_result: if os.path.isdir(search_path+"/"+x): lsdir_num+=1 - if lsdir_num<=1: - print(f.reof("current-theme-msg", "Currently installed theme:")) - else: - print(f.reof("overlay-history-msg", "Overlay history (sorted by latest installed):")) + print(f.reof("current-theme-msg", "Currently installed theme(s):")) + minimal_info: bool=name==True or file_path==True for theme_pathname in lsdir_result: target_path=search_path+"/"+theme_pathname.strip() if (not os.path.isdir(target_path)) or re.search(r"^\d+$", theme_pathname.strip())==None: continue # skip current_theme_index file # name - name="(Unknown)" - if os.path.isfile(target_path+"/"+_globalvar.generator_info_filename.format(info="name")): - name=open(target_path+"/"+_globalvar.generator_info_filename.format(info="name"), 'r', encoding="utf-8").read().strip() - print("[{}]: {}".format(theme_pathname, name)) + if minimal_info==False or (minimal_info==True and name==True): + theme_name="(Unknown)" + if os.path.isfile(target_path+"/"+_globalvar.generator_info_filename.format(info="name")): + theme_name=open(target_path+"/"+_globalvar.generator_info_filename.format(info="name"), 'r', encoding="utf-8").read().strip() + print("[{}]: {}".format(theme_pathname, theme_name)) + if minimal_info==True and file_path==True: + theme_filepath="(Unknown)" + if os.path.isfile(target_path+"/"+_globalvar.generator_info_filename.format(info="filepath")): + theme_filepath=open(target_path+"/"+_globalvar.generator_info_filename.format(info="filepath"), 'r', encoding="utf-8").read().strip() + print(theme_filepath) + if minimal_info==True: continue # --Stop here if either parameters are specified-- # version version="(Unknown)" if os.path.isfile(target_path+"/"+_globalvar.generator_info_filename.format(info="version")): @@ -237,24 +256,27 @@ def get_current_theme_info(): print(f.reof("supported-apps-str", "Supported apps:")) for app in supported_apps.split(): print(f.feof("list-item", "• {content}", content=fmt(app.strip()))) + + print() # Separate each entry with an empty line return 0 -def update_theme(): +def update_theme(no_confirm=False): """ Re-applies theme files from file paths specified in the previous apply-theme command (including all related apply-theme commands if --overlay is used) + - Set no_confirm=True to skip the user confirmation prompt + (Invokes 'clitheme update-theme') """ class invalid_theme(Exception): pass - file_contents: list - file_paths: list + file_paths: List[str] fi=frontend.FetchDescriptor(subsections="cli update-theme") try: search_path=_globalvar.clitheme_root_data_path+"/"+_globalvar.generator_info_pathname if not os.path.isdir(search_path): print(fi.reof("no-theme-err", "Error: no theme currently set")) return 1 - lsdir_result=os.listdir(search_path); lsdir_result.sort() + lsdir_result=_globalvar.list_directory(search_path); lsdir_result.sort(key=functools.cmp_to_key(_globalvar.result_sort_cmp)) lsdir_num=0 for x in lsdir_result: if os.path.isdir(search_path+"/"+x): lsdir_num+=1 @@ -271,11 +293,6 @@ def update_theme(): except: raise invalid_theme("Read error: "+str(sys.exc_info()[1])) file_paths.append(got_path) if len(file_paths)==0: raise invalid_theme("file_paths empty") - # Get file contents - try: file_contents=_get_file_contents(file_paths) - except: - _globalvar.handle_exception() - return 1 except invalid_theme: print(fi.reof("not-available-err", "update-theme cannot be used with the current theme setting\nPlease re-apply the current theme and try again")) _globalvar.handle_exception() @@ -284,7 +301,7 @@ def update_theme(): print(fi.feof("other-err", "An error occurred while processing file path information: {msg}\nPlease re-apply the current theme and try again", msg=fmt(str(sys.exc_info()[1])))) _globalvar.handle_exception() return 1 - return apply_theme(file_contents, file_paths, overlay=False) + return apply_theme(None, file_paths, overlay=False, no_confirm=no_confirm) def _is_option(arg): return arg.strip()[0:1]=="-" @@ -298,10 +315,10 @@ def _handle_help_message(full_help: bool=False): fd=frontend.FetchDescriptor(subsections="cli help-message") print(fd.reof("usage-str", "Usage:")) print( -"""\t{0} apply-theme [themedef-file] [--overlay] [--preserve-temp] -\t{0} get-current-theme-info +"""\t{0} apply-theme [themedef-file] [--overlay] [--preserve-temp] [--yes] +\t{0} get-current-theme-info [--name] [--file-path] \t{0} unset-current-theme -\t{0} update-theme +\t{0} update-theme [--yes] \t{0} generate-data [themedef-file] [--overlay] \t{0} --version \t{0} --help""".format(arg_first) @@ -309,28 +326,37 @@ def _handle_help_message(full_help: bool=False): if not full_help: return print(fd.reof("options-str", "Options:")) print("\t"+fd.reof("options-apply-theme", - "apply-theme: Applies the given theme definition file(s) into the current system.\nSpecify --overlay to append value definitions in the file(s) onto the current data.\nSpecify --preserve-temp to prevent the temporary directory from removed after the operation. (Debug purposes only)").replace("\n", "\n\t\t")) - print("\t"+fd.reof("options-get-current-theme-info", "get-current-theme-info: Outputs detailed information about the currently applied theme")) + "apply-theme: Apply the given theme definition file(s).\nSpecify --overlay to add file(s) onto the current data.\nSpecify --preserve-temp to preserve the temporary directory after the operation. (Debug purposes only)").replace("\n", "\n\t\t")) + print("\t"+fd.reof("options-get-current-theme-info", "get-current-theme-info: Show information about the currently applied theme(s)\nSpecify --name to only display the name of each theme\nSpecify --file-path to only display the source file path of each theme\n(Both will be displayed when both specified)").replace("\n", "\n\t\t")) print("\t"+fd.reof("options-unset-current-theme", "unset-current-theme: Remove the current theme data from the system")) - print("\t"+fd.reof("options-update-theme", "update-theme: Re-applies the theme definition files specified in the previous \"apply-theme\" command (previous commands if --overlay is used)")) - print("\t"+fd.reof("options-generate-data", "generate-data: [Debug purposes only] Generates a data hierarchy from specified theme definition files in a temporary directory")) - print("\t"+fd.reof("options-version", "--version: Outputs the current version of clitheme")) - print("\t"+fd.reof("options-help", "--help: Display this help message")) + print("\t"+fd.reof("options-update-theme", "update-theme: Re-apply the theme definition files specified in the previous \"apply-theme\" command (previous commands if --overlay is used)")) + print("\t"+fd.reof("options-generate-data", "generate-data: [Debug purposes only] Generate a data hierarchy from specified theme definition files in a temporary directory")) + print("\t"+fd.reof("options-yes", "[For supported commands, specify --yes to skip the confirmation prompt]")) + print("\t"+fd.reof("options-version", "--version: Show the current version of clitheme")) + print("\t"+fd.reof("options-help", "--help: Show this help message")) -def _get_file_contents(file_paths: list) -> list: +def _get_file_contents(file_paths: List[str]) -> List[str]: fi=frontend.FetchDescriptor(subsections="cli apply-theme") content_list=[] + line_prefix="\x1b[2K\r" # clear current line content and move cursor to beginning for i in range(len(file_paths)): path=file_paths[i] try: + print(line_prefix+fi.feof("reading-file","==> Reading file {filename}...", filename=f"({i+1}/{len(file_paths)})"), end='') + # Detect standard input + is_stdin=_globalvar.handle_stdin_prompt(path) content_list.append(open(path, 'r', encoding="utf-8").read()) + if is_stdin: print() # Print an extra newline + except KeyboardInterrupt: + print();raise _direct_exit(130) except: - print(fi.feof("read-file-error", "[File {index}] An error occurred while reading the file: \n{message}", \ + print("\n"+fi.feof("read-file-error", "[File {index}] An error occurred while reading the file: \n{message}", \ index=str(i+1), message=path+": "+fmt(str(sys.exc_info()[1])))) raise + print(line_prefix, end='') return content_list -def main(cli_args: list): +def main(cli_args: List[str]): """ Use this function invoke 'clitheme' with command line arguments @@ -347,49 +373,54 @@ def main(cli_args: list): for arg in cli_args: if not exclude_options or not _is_option(arg): c+=1 if ccount: - exit(_handle_usage_error(f.reof("too-many-arguments", "Error: too many arguments"), arg_first)) + raise _direct_exit(_handle_usage_error(f.reof("too-many-arguments", "Error: too many arguments"), arg_first)) - if cli_args[1]=="apply-theme" or cli_args[1]=="generate-data" or cli_args[1]=="generate-data-hierarchy": - check_enough_args(3) - generate_only=(cli_args[1]=="generate-data" or cli_args[1]=="generate-data-hierarchy") - paths=[] - overlay=False - preserve_temp=False - for arg in cli_args[2:]: - if _is_option(arg): - if arg.strip()=="--overlay": overlay=True - elif arg.strip()=="--preserve-temp" and not generate_only: preserve_temp=True + try: + if cli_args[1] in ("apply-theme", "generate-data", "generate-data-hierarchy"): + check_enough_args(3) + generate_only=(cli_args[1] in ("generate-data", "generate-data-hierarchy")) + paths=[] + overlay=False + preserve_temp=False + no_confirm=False + for arg in cli_args[2:]: + if _is_option(arg): + if arg.strip()=="--overlay": overlay=True + elif arg.strip()=="--preserve-temp" and not generate_only: preserve_temp=True + elif arg.strip()=="--yes" and not generate_only: no_confirm=True + else: return _handle_usage_error(f.feof("unknown-option", "Error: unknown option \"{option}\"", option=fmt(arg)), arg_first) + else: + paths.append(arg) + return apply_theme(file_contents=None, overlay=overlay, filenames=paths, preserve_temp=preserve_temp, generate_only=generate_only, no_confirm=no_confirm) + elif cli_args[1]=="get-current-theme-info": + name=False; file_path=False + for arg in cli_args[2:]: + if arg.strip()=="--name": name=True + elif arg.strip()=="--file-path": file_path=True else: return _handle_usage_error(f.feof("unknown-option", "Error: unknown option \"{option}\"", option=fmt(arg)), arg_first) - else: - paths.append(arg) - fi=frontend.FetchDescriptor(subsections="cli apply-theme") - content_list: list - try: content_list=_get_file_contents(paths) - except: - _globalvar.handle_exception() - return 1 - return apply_theme(content_list, overlay=overlay, filenames=paths, preserve_temp=preserve_temp, generate_only=generate_only) - elif cli_args[1]=="get-current-theme-info": - check_extra_args(2) # disabled additional options - return get_current_theme_info() - elif cli_args[1]=="unset-current-theme": - check_extra_args(2) - return unset_current_theme() - elif cli_args[1]=="update-theme": - check_extra_args(2) - return update_theme() - elif cli_args[1]=="--version": - check_extra_args(2) - print(f.feof("version-str", "clitheme version {ver}", ver=_globalvar.clitheme_version)) - else: - if cli_args[1]=="--help": + return get_current_theme_info(name=name, file_path=file_path) + elif cli_args[1]=="unset-current-theme": check_extra_args(2) - _handle_help_message(full_help=True) + return unset_current_theme() + elif cli_args[1]=="update-theme": + no_confirm=False + for arg in cli_args[2:]: + if arg.strip()=="--yes": no_confirm=True + else: return _handle_usage_error(f.feof("unknown-option", "Error: unknown option \"{option}\"", option=fmt(arg)), arg_first) + return update_theme(no_confirm=no_confirm) + elif cli_args[1]=="--version": + check_extra_args(2) + print(f.feof("version-str", "clitheme version {ver}", ver=_globalvar.clitheme_version)) else: - return _handle_usage_error(f.feof("unknown-command", "Error: unknown command \"{cmd}\"", cmd=fmt(cli_args[1])), arg_first) + if cli_args[1]=="--help": + check_extra_args(2) + _handle_help_message(full_help=True) + else: + return _handle_usage_error(f.feof("unknown-command", "Error: unknown command \"{cmd}\"", cmd=fmt(cli_args[1])), arg_first) + except _direct_exit as exc: return exc.code return 0 def _script_main(): # for script return main(sys.argv) diff --git a/src/clitheme/exec/__init__.py b/src/clitheme/exec/__init__.py index 0da7ec36c6f1fa0f71775e24f974b47f874d0318..d6005dead31d23fd5a4c61d1963de2a20a1e762b 100644 --- a/src/clitheme/exec/__init__.py +++ b/src/clitheme/exec/__init__.py @@ -15,17 +15,19 @@ import os import re import io import shutil +import functools def _labeled_print(msg: str): - print("[clitheme-exec] "+msg) + for line in msg.splitlines(): + print("[clitheme-exec] "+line) from .. import _globalvar, cli, frontend from .._generator import db_interface +from typing import List # spell-checker:ignore lsdir showhelp argcount nosubst -_globalvar.handle_set_themedef(frontend, "clitheme-exec") -frontend.global_domain="swiftycode" -frontend.global_appname="clitheme" +frontend.set_domain("swiftycode") +frontend.set_appname("clitheme") fd=frontend.FetchDescriptor(subsections="exec") # Prevent recursion dead loops and accurately simulate that regeneration is only triggered once @@ -40,13 +42,13 @@ def _check_regenerate_db(dest_root_path: str=_globalvar.clitheme_root_data_path) raise db_interface.need_db_regenerate("Forced database regeneration with $CLITHEME_REGENERATE_DB=1") else: db_interface.connect_db() except db_interface.need_db_regenerate: - _labeled_print(fd.reof("substrules-migrate-msg", "Migrating substrules database...")) + _labeled_print(fd.reof("substrules-update-msg", "Updating database...")) orig_stdout=sys.stdout try: # gather files search_path=_globalvar.clitheme_root_data_path+"/"+_globalvar.generator_info_pathname if not os.path.isdir(search_path): raise Exception(search_path+" not directory") - lsdir_result=os.listdir(search_path); lsdir_result.sort() + lsdir_result=_globalvar.list_directory(search_path); lsdir_result.sort(key=functools.cmp_to_key(_globalvar.result_sort_cmp)) lsdir_num=0 for x in lsdir_result: if os.path.isdir(search_path+"/"+x): lsdir_num+=1 @@ -63,20 +65,23 @@ def _check_regenerate_db(dest_root_path: str=_globalvar.clitheme_root_data_path) cli_msg=io.StringIO() sys.stdout=cli_msg if not cli.apply_theme(file_contents, filenames=paths, overlay=False, generate_only=True, preserve_temp=True)==0: - raise Exception(fd.reof("db-migration-generator-err", "Failed to generate data (full log below):")+"\n"+cli_msg.getvalue()+"\n") + raise Exception(fd.reof("db-update-generator-err", "Failed to generate data (full log below):")+"\n"+cli_msg.getvalue()+"\n") sys.stdout=orig_stdout try: os.remove(dest_root_path+"/"+_globalvar.db_filename) except FileNotFoundError: raise shutil.copy(cli.last_data_path+"/"+_globalvar.db_filename, dest_root_path+"/"+_globalvar.db_filename) - _labeled_print(fd.reof("db-migrate-success-msg", "Successfully completed migration, proceeding execution")) + _labeled_print(fd.reof("db-update-success-msg", "Successfully updated database, proceeding execution")) except: sys.stdout=orig_stdout - _labeled_print(fd.feof("db-migration-err", "An error occurred while migrating the database: {msg}\nPlease re-apply the theme and try again", msg=str(sys.exc_info()[1]))) + _labeled_print(fd.feof("db-update-err", "An error occurred while updating the database: {msg}\nPlease re-apply the theme and try again", msg=str(sys.exc_info()[1]))) _globalvar.handle_exception() return False except FileNotFoundError: pass - except: - _labeled_print(fd.feof("db-migration-err", "An error occurred while migrating the database: {msg}\nPlease re-apply the theme and try again", msg=str(sys.exc_info()[1]))) + except Exception as exc: + msg=fd.reof("db-invalid-version", "Invalid database version information")\ + if type(exc) in (ValueError, TypeError) else str(sys.exc_info()[1]) + # ValueError: value is not an integer; TypeError: fetched value is None + _labeled_print(fd.feof("db-read-err", "An error occurred while reading the database: {msg}\nPlease re-apply the theme and try again", msg=msg)) _globalvar.handle_exception() return False return True @@ -84,22 +89,22 @@ def _check_regenerate_db(dest_root_path: str=_globalvar.clitheme_root_data_path) def _handle_help_message(full_help: bool=False): fd2=frontend.FetchDescriptor(subsections="exec help-message") print(fd2.reof("usage-str", "Usage:")) - print("\tclitheme-exec [--debug] [--debug-color] [--debug-newlines] [--debug-showchars] [--debug-foreground] [--debug-nosubst] [command]") + print("\tclitheme-exec [--debug] [--debug-color] [--debug-newlines] [--showchars] [--foreground-stat] [--nosubst] [command]") if not full_help: return print(fd2.reof("options-str", "Options:")) print("\t"+fd2.reof("options-debug", "--debug: Display indicator at the beginning of each read output by line")) + print("\t\t"+fd2.reof("options-debug-newlines", "--debug-newlines: Use newlines to display output that does not end on a newline")) print("\t"+fd2.reof("options-debug-color", "--debug-color: Apply color on output; used to determine stdout or stderr (BETA: stdout/stderr not implemented)")) - print("\t"+fd2.reof("options-debug-newlines", "--debug-newlines: Use newlines to display output that does not end on a newline")) - print("\t"+fd2.reof("options-debug-showchars", "--debug-showchars: Display various control characters in plain text")) - print("\t"+fd2.reof("options-debug-foreground", "--debug-foreground: Display message when the foreground status of the process changes (value of tcgetpgrp)")) - print("\t"+fd2.reof("options-debug-nosubst", "--debug-nosubst: Do not perform any output substitutions even if a theme is set")) + print("\t"+fd2.reof("options-showchars", "--showchars: Display various control characters in plain text")) + print("\t"+fd2.reof("options-foreground-stat", "--foreground-stat: Display message when the foreground status of the process changes (value of tcgetpgrp)")) + print("\t"+fd2.reof("options-nosubst", "--nosubst: Do not perform any output substitutions even if a theme is set")) def _handle_error(message: str): print(message) print(fd.reof("help-usage-prompt", "Run \"clitheme-exec --help\" for usage information")) return 1 -def main(arguments: list): +def main(arguments: List[str]): """ Invoke clitheme-exec using the given command line arguments @@ -120,24 +125,25 @@ def main(arguments: list): debug_mode.append("color") elif arg=="--debug-newlines": debug_mode.append("newlines") - elif arg=="--debug-showchars": + elif arg in ("--showchars", "--debug-showchars"): debug_mode.append("showchars") - elif arg=="--debug-foreground": + elif arg in ("--foreground-stat", "--debug-foreground"): debug_mode.append("foreground") - elif arg=="--debug-nosubst": + elif arg in ("--nosubst", "--debug-nosubst"): subst=False elif arg=="--help": showhelp=True else: return _handle_error(fd.feof("unknown-option-err", "Error: unknown option \"{phrase}\"", phrase=arg)) + if "newlines" in debug_mode and not "normal" in debug_mode: + return _handle_error(fd.reof("debug-newlines-not-with-debug", "Error: \"--debug-newlines\" must be used with \"--debug\" option")) if len(arguments)<=1+argcount: if showhelp: _handle_help_message(full_help=True) return 0 else: _handle_help_message() - _handle_error(fd.reof("no-command-err", "Error: no command specified")) - return 1 + return _handle_error(fd.reof("no-command-err", "Error: no command specified")) # check database if subst: if not os.path.exists(f"{_globalvar.clitheme_root_data_path}/{_globalvar.db_filename}"): diff --git a/src/clitheme/exec/output_handler_posix.py b/src/clitheme/exec/output_handler_posix.py index 08fe8cc3d758ce6899a8db1850e668dc45557ce1..7283823d7b60b3bfaf6f137b8dfce00e574fe17a 100644 --- a/src/clitheme/exec/output_handler_posix.py +++ b/src/clitheme/exec/output_handler_posix.py @@ -15,6 +15,7 @@ import io import pty import select import termios +import stat import fcntl import signal import struct @@ -23,18 +24,20 @@ import re import sqlite3 import time import threading +import queue +from typing import Optional, List from .._generator import db_interface from .. import _globalvar, frontend +from .._globalvar import _direct_exit from . import _labeled_print -# spell-checker:ignore cbreak ICANON readsize splitarray ttyname RDWR preexec pgrp +# spell-checker:ignore cbreak ICANON readsize splitarray ttyname RDWR preexec pgrp pids -_globalvar.handle_set_themedef(frontend, "output_handler_posix") fd=frontend.FetchDescriptor(domain_name="swiftycode", app_name="clitheme", subsections="exec") # https://docs.python.org/3/library/stdtypes.html#str.splitlines newlines=(b'\n',b'\r',b'\r\n',b'\v',b'\f',b'\x1c',b'\x1d',b'\x1e',b'\x85') -def _process_debug(lines: list, debug_mode: list, is_stderr: bool=False, matched: bool=False, failed: bool=False) -> list: +def _process_debug(lines: List[bytes], debug_mode: List[str], is_stderr: bool=False, matched: bool=False, failed: bool=False) -> List[bytes]: final_lines=[] for x in range(len(lines)): line=lines[x] @@ -56,24 +59,24 @@ def _process_debug(lines: list, debug_mode: list, is_stderr: bool=False, matched except UnicodeDecodeError: line=re.sub(bytes(match_pattern, 'utf-8'), bytes(sub_pattern, 'utf-8'), line) line+=b'\x1b[0m' if "normal" in debug_mode: - # e.g. o{ ; o> line=bytes(f"\x1b[0;1;{'31' if is_stderr else '32'}{';47' if matched else ''}{';37;41' if failed else ''}m"+('e' if is_stderr else 'o')+'\x1b[0;1m'+(">")+"\x1b[0m ",'utf-8')+line+b"\x1b[0m" final_lines.append(line) return final_lines -def handler_main(command: list, debug_mode: list=[], subst: bool=True): +def handler_main(command: List[str], debug_mode: List[str]=[], subst: bool=True): do_subst=subst if do_subst==True: try: db_interface.connect_db() except FileNotFoundError: pass stdout_fd, stdout_slave=pty.openpty() stderr_fd, stderr_slave=pty.openpty() + readsize=io.DEFAULT_BUFFER_SIZE env=copy.copy(os.environ) # Prevent apps from using "less" or "more" as pager, as it won't work here env['PAGER']="cat" prev_attrs=None - try: prev_attrs=termios.tcgetattr(sys.stdin) + try: prev_attrs=termios.tcgetattr(sys.stdout) except termios.error: pass main_pid=os.getpid() process: subprocess.Popen @@ -89,14 +92,46 @@ def handler_main(command: list, debug_mode: list=[], subst: bool=True): signal.signal(signal.SIGTSTP, signal_handler) # Reset signal handler elif sig==signal.SIGTSTP: # suspend signal if os.tcgetpgrp(stdout_fd)!=process.pid: # e.g. A shell running another process - os.write(stdout_fd, b'\x1a') # Send '^Z' character; don't suspend the entire shell + if process.poll()==None: # Process is running + os.write(stdout_fd, b'\x1a') # Send '^Z' character; don't suspend the entire shell else: process.send_signal(signal.SIGSTOP) # Stop the process signal.signal(signal.SIGTSTP, signal.SIG_DFL) # Unset signal handler to prevent deadlock os.kill(main_pid, signal.SIGTSTP) # Suspend itself elif sig==signal.SIGINT: - os.write(stdout_fd, b'\x03') # '^C' character + if process.poll()==None: + os.write(stdout_fd, b'\x03') # '^C' character + else: + reset_terminal() + _labeled_print(fd.reof("output-interrupted-exit", "Output interrupted after command exit")) + # Prevent message being triggered multiple times + signal.signal(signal.SIGINT, signal.SIG_IGN) + raise _direct_exit(130) # Will be raised in main processing loop + elif sig==signal.SIGQUIT: + if process.poll()==None: + os.write(stdout_fd, b'\x1c') # '^\' character + handle_signals=[signal.SIGTSTP, signal.SIGCONT, signal.SIGINT, signal.SIGQUIT] try: + # Detect if stdin is piped (e.g. cat file|clitheme-exec grep content) + stdin_fd=stdout_slave + if stat.S_ISFIFO(os.stat(sys.stdin.fileno()).st_mode): + r,w=os.pipe() + def pipe_forward(): + # Background thread to forward stdin to subprocess pipe + nonlocal r,w + while True: + select.select([sys.stdin], [], []) + d=os.read(sys.stdin.fileno(), readsize) + if d==b'': # stdin is closed + os.close(w) + # Duplicate stdout terminal onto stdin to read user input + if os.isatty(sys.stdout.fileno()): + os.dup2(sys.stdout.fileno(), sys.stdin.fileno()) + break + os.write(w,d) + t=threading.Thread(target=pipe_forward, daemon=True) + t.start() + stdin_fd=r def child_init(): # Must start new session or some programs might not work properly os.setsid() @@ -107,103 +142,173 @@ def handler_main(command: list, debug_mode: list=[], subst: bool=True): tmp_fd = os.open(os.ttyname(stdout_slave), os.O_RDWR) tmp_fd2 = os.open(os.ttyname(stderr_slave), os.O_RDWR) os.close(tmp_fd);os.close(tmp_fd2) - process=subprocess.Popen(command, stdin=stdout_slave, stdout=stdout_slave, stderr=stdout_slave, bufsize=0, close_fds=True, env=env, preexec_fn=child_init) + process=subprocess.Popen(command, stdin=stdin_fd, stdout=stdout_slave, stderr=stdout_slave, env=env, preexec_fn=child_init) except: _labeled_print(fd.feof("command-fail-err", "Error: failed to run command: {msg}", msg=_globalvar.make_printable(str(sys.exc_info()[1])))) _globalvar.handle_exception() return 1 else: - signal.signal(signal.SIGTSTP, signal_handler) - signal.signal(signal.SIGCONT, signal_handler) - signal.signal(signal.SIGINT, signal_handler) - output_lines=[] # (line_content, is_stderr, do_subst_operation) - def get_terminal_size(): return fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('HHHH',0,0,0,0)) + for sig in handle_signals: + signal.signal(sig, signal_handler) + output_lines=queue.Queue() # (line_content, is_stderr, do_subst_operation, foreground_pid, term_attrs) + def get_terminal_size(): return fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, struct.pack('HHHH',0,0,0,0)) last_terminal_size=struct.pack('HHHH',0,0,0,0) # placeholder # this mechanism prevents user input from being processed through substrules last_input_content=None last_tcgetpgrp=os.tcgetpgrp(stdout_fd) + def reset_terminal(): + if prev_attrs!=None: termios.tcsetattr(sys.stdout, termios.TCSADRAIN, prev_attrs) # restore previous attributes + print("\x1b[0m\x1b[?1;1000;1001;1002;1003;1005;1006;1015;1016l\n\x1b[J", end='') # reset color, mouse reporting, and clear the rest of the screen def handle_debug_pgrp(foreground_pid: int): nonlocal last_tcgetpgrp if "foreground" in debug_mode and foreground_pid!=last_tcgetpgrp: message=f"\x1b[1m! \x1b[{'32' if foreground_pid==process.pid else '31'}mForeground: \x1b[4m{'True' if foreground_pid==process.pid else 'False'} ({foreground_pid})\x1b[0m\n" os.write(sys.stdout.fileno(), bytes(message, 'utf-8')) last_tcgetpgrp=foreground_pid + thread_exception_handled=False + def handle_exception(exc: Optional[Exception]=None): + nonlocal thread_exception_handled; thread_exception_handled=True + reset_terminal() + _labeled_print(fd.reof("internal-error-err", "Error: an internal error has occurred while executing the command (execution halted):")) + if exc!=None: raise exc + else: raise + thread_debug=0 + def thread_debug_handle(sig, frame): + nonlocal thread_debug + if sig==signal.SIGUSR1: thread_debug=1 + elif sig==signal.SIGUSR2: thread_debug=2 + signal.signal(signal.SIGUSR1, thread_debug_handle) + signal.signal(signal.SIGUSR2, thread_debug_handle) def output_read_loop(): nonlocal last_input_content, output_lines - unfinished_output=None - while True: - readsize=io.DEFAULT_BUFFER_SIZE - fds=select.select([stdout_fd, sys.stdin, stderr_fd], [], [], 0.002)[0] - # Handle user input from stdin - if sys.stdin in fds: - data=os.read(sys.stdin.fileno(), readsize) - # if input from last iteration did not end with newlines, append new content - if last_input_content!=None: last_input_content+=data - else: last_input_content=data - try: os.write(stdout_fd, data) - except OSError: pass # Handle input/output error that might occur after program terminates - # Handle output from stdout and stderr - output_handled=False - def handle_output(is_stderr: bool): - nonlocal unfinished_output, output_lines, output_handled + unfinished_output=None # (line,is_stderr,do_subst_operation,foreground_pid,term_attrs,initial_time) + try: + while True: + # Testing thread exception handling + nonlocal thread_debug + if thread_debug==1: raise Exception + elif thread_debug==2: break + + # Set a short timeout value if there are unfinished outputs + # Else, wait longer to reduce CPU usage + timeout=0.002 if unfinished_output!=None or last_input_content!=None else 0.5 + try: fds=select.select([stdout_fd, sys.stdin, stderr_fd], [], [], timeout)[0] + except OSError: fds=select.select([stdout_fd, stderr_fd], [], [], timeout)[0] + # Handle user input from stdin + if sys.stdin in fds: + data=os.read(sys.stdin.fileno(), readsize) + # if input from last iteration did not end with newlines, append new content + if last_input_content!=None: last_input_content+=data + else: last_input_content=data + try: os.write(stdout_fd, data) + except OSError: pass # Handle input/output error that might occur after program terminates + # Handle output from stdout and stderr + unfinished_output_handled=False + def process_block(data: tuple): + output_lines.put(data) + # lines=data[0].splitlines(keepends=True) + # for line in lines: + # output_lines.put((line,)+data[1:]) + def handle_output(is_stderr: bool): + nonlocal unfinished_output, output_lines, unfinished_output_handled, last_input_content + + data=os.read(stderr_fd if is_stderr else stdout_fd, readsize) + # If pipe closed and returns empty data, ignore + if data==b'': return + try: + term_attrs=termios.tcgetattr(stdout_fd) + # disable canonical and echo mode (enable cbreak) no matter what + term_attrs[3] &= ~(termios.ICANON | termios.ECHO) + except termios.error: term_attrs=None + foreground_pid=os.tcgetpgrp(stdout_fd) - data=os.read(stderr_fd if is_stderr else stdout_fd, readsize) - foreground_pid=os.tcgetpgrp(stdout_fd) - do_subst_operation=True - lines=data.splitlines(keepends=True) - for x in range(len(lines)): - line=lines[x] - # if unfinished output exists, append new content to it - if x==0 and unfinished_output!=None: - orig_data=unfinished_output - orig_line=orig_data[0] - if unfinished_output[3]==foreground_pid: - output_lines.append((orig_line+line,is_stderr,do_subst_operation, foreground_pid)) + do_subst_operation=True + # check if the output is user input. if yes, skip + if last_input_content!=None and not (unfinished_output!=None and unfinished_output[2]==True): + input_match_expression: bytes=re.escape(last_input_content).replace(b'\x7f', rb"(\x08 \x08|\x08\x1b\[K)") # type: ignore + input_equals=b'^'+input_match_expression+b'$' + # print(last_input_content, data, re.search(input_equals, data)!=None) # DEBUG + if re.search(input_equals, data)!=None: + do_subst_operation=False + last_input_content=None + unfinished_output_time=time.perf_counter() + if unfinished_output!=None: + orig_data=unfinished_output[0] + if unfinished_output[3]==foreground_pid and unfinished_output[1]==is_stderr: + # If exceeds maximum time or differing terminal attributes, append first line of data into unfinished output and process it + if time.perf_counter()-unfinished_output[5]>0.05 or term_attrs!=unfinished_output[4]: + lines=data.splitlines(keepends=True) + process_block((unfinished_output[0]+lines[0],)+unfinished_output[1:]) + data=data[len(lines[0]):] # Remove first line from data + else: + # Modify existing line data instead of directly pushing it + # to better handle multiple fragments in a single line + data=orig_data+data + unfinished_output_time=unfinished_output[5] else: - output_lines.append(unfinished_output) - output_lines.append((line,is_stderr,do_subst_operation, foreground_pid)) + # Shouldn't join them together in this case + process_block(unfinished_output) + # Don't push the current line just yet; leave it for newline check unfinished_output=None - output_handled=True + unfinished_output_handled=True + # If all data was appended to previous unfinished output and pushed, don't do anything + if data==b'': return # if last line of output did not end with newlines, leave for next iteration - elif x==len(lines)-1 and not line.endswith(newlines): - unfinished_output=(line,is_stderr,do_subst_operation, foreground_pid) - output_handled=True - else: - output_lines.append((line,is_stderr,do_subst_operation, foreground_pid)) + if not data.endswith(newlines): + unfinished_output=(data,is_stderr,do_subst_operation, foreground_pid, term_attrs, unfinished_output_time) + unfinished_output_handled=True + else: process_block((data, is_stderr, do_subst_operation, foreground_pid, term_attrs)) - if stdout_fd in fds: handle_output(is_stderr=False) - if stderr_fd in fds: handle_output(is_stderr=True) - # if no unfinished_output is handled by handle_output, append the unfinished output if exists - if not output_handled and unfinished_output!=None: - output_lines.append(unfinished_output) - unfinished_output=None + if stdout_fd in fds: handle_output(is_stderr=False) + if stderr_fd in fds: handle_output(is_stderr=True) + # if no unfinished_output is handled by handle_output, append the unfinished output if exists + if not unfinished_output_handled and unfinished_output!=None: + process_block(unfinished_output) + unfinished_output=None + # Reset last input content if no output is made within timeout + if not sys.stdin in fds and last_input_content!=None: + last_input_content=None - if process.poll()!=None: break + if process.poll()!=None: + # Send termination signal + output_lines.put(None) + break + except: handle_exception() - thread=threading.Thread(target=output_read_loop, daemon=True) + thread=threading.Thread(target=output_read_loop, name="output-reader", daemon=True) thread.start() + + def update_window_size(*args): + # update terminal size + nonlocal last_terminal_size + try: + new_term_size=get_terminal_size() + if new_term_size!=last_terminal_size: + last_terminal_size=new_term_size + fcntl.ioctl(stdout_fd, termios.TIOCSWINSZ, new_term_size) + fcntl.ioctl(stderr_fd, termios.TIOCSWINSZ, new_term_size) + process.send_signal(signal.SIGWINCH) + except: pass + signal.signal(signal.SIGWINCH, update_window_size) + # Call this function for the first time to set initial window size + update_window_size() + # Initially set terminal attributes + try: + term_attrs=termios.tcgetattr(stdout_fd) + # disable canonical and echo mode (enable cbreak) no matter what + term_attrs[3] &= ~(termios.ICANON | termios.ECHO) + termios.tcsetattr(sys.stdout, termios.TCSADRAIN, term_attrs) + except termios.error: pass + # If had output on the previous run, use shorter timeout to minimize delay in --foreground-stat output + had_output=False while True: try: - if process.poll()!=None and len(output_lines)==0: break - - # update terminal attributes from what the program sets - try: - attrs=termios.tcgetattr(stdout_fd) - # disable canonical and echo mode (enable cbreak) no matter what - attrs[3] &= ~(termios.ICANON | termios.ECHO) - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, attrs) - except termios.error: pass - # update terminal size - try: - new_term_size=get_terminal_size() - if new_term_size!=last_terminal_size: - last_terminal_size=new_term_size - fcntl.ioctl(stdout_fd, termios.TIOCSWINSZ, new_term_size) - fcntl.ioctl(stderr_fd, termios.TIOCSWINSZ, new_term_size) - process.send_signal(signal.SIGWINCH) - except: pass + if not thread.is_alive() and not process.poll()!=None: + if not thread_exception_handled: handle_exception(RuntimeError("Output read loop terminated unexpectedly")) + else: return 1 + if thread_exception_handled: continue # Prevent conflict with setting terminal attributes # Process outputs def process_line(line: bytes, line_data): @@ -212,8 +317,6 @@ def handler_main(command: list, debug_mode: list=[], subst: bool=True): subst_line=copy.copy(line) failed=False foreground_pid=line_data[3] - # Print message if foreground process changed - if line_data[2]==True: handle_debug_pgrp(foreground_pid) if do_subst and line_data[2]==True: def operation(): nonlocal subst_line, failed, foreground_pid @@ -221,7 +324,7 @@ def handler_main(command: list, debug_mode: list=[], subst: bool=True): subst_line=db_interface.match_content(line, _globalvar.splitarray_to_string(command), is_stderr=line_data[1], pids=(process.pid, foreground_pid)) except TimeoutError: failed=True # Happens when no theme is set/no subst-data.db - except sqlite3.OperationalError: pass + except db_interface.db_not_found: pass def raise_error(sig_num, frame): raise TimeoutError("Execution time out") signal.signal(signal.SIGALRM, raise_error) signal.setitimer(signal.ITIMER_REAL, db_interface.match_timeout) @@ -230,30 +333,41 @@ def handler_main(command: list, debug_mode: list=[], subst: bool=True): signal.setitimer(signal.ITIMER_REAL, 0) if line_data[2]==True: subst_line=_process_debug([subst_line], debug_mode, is_stderr=line_data[1], matched=not subst_line==line, failed=failed)[0] return subst_line - time.sleep(0.001) # Prevent high CPU usage - if len(output_lines)==0: + if output_lines.empty(): handle_debug_pgrp(os.tcgetpgrp(stdout_fd)) - while not len(output_lines)==0: - line_data=output_lines.pop(0) - line: bytes=line_data[0] - # check if the output is user input. if yes, skip - # print(last_input_content, line) # DEBUG - if line==last_input_content: line_data=(line_data[0],line_data[1],False, line_data[3]); last_input_content=None - elif last_input_content!=None and last_input_content.startswith(line): - line_data=(line_data[0],line_data[1],False, line_data[3]) - last_input_content=last_input_content[len(line):] - else: last_input_content=None - # subst operation and print output - os.write(sys.stderr.fileno() if line_data[1]==True else sys.stdout.fileno(), process_line(line, line_data)) - except: - if prev_attrs!=None: termios.tcsetattr(sys.stdin, termios.TCSADRAIN, prev_attrs) # restore previous attributes - print("\x1b[0m\x1b[?1;1000;1001;1002;1003;1005;1006;1015;1016l", end='') # reset color and mouse reporting - _labeled_print(fd.reof("internal-error-err", "Error: an internal error has occurred while executing the command (execution halted):")) - raise - if prev_attrs!=None: termios.tcsetattr(sys.stdin, termios.TCSADRAIN, prev_attrs) # restore previous attributes + try: line_data=output_lines.get(block=True, timeout=0.05 if had_output else 0.5) + except queue.Empty: + had_output=False + continue + # --Output processing-- + had_output=True + # None: termination signal + if line_data==None: break + # Process output line by line + output=b'' + for line in line_data[0].splitlines(keepends=True): + output+=process_line(line, line_data) + # Print message if foreground process changed and not user input + if line_data[2]==True: handle_debug_pgrp(line_data[3]) + # update terminal attributes from what the program sets + if line_data[4]!=None: + try: termios.tcsetattr(sys.stdout, termios.TCSADRAIN, line_data[4]) + except termios.error: pass + # subst operation and print output + os.write(sys.stderr.fileno() if line_data[1]==True else sys.stdout.fileno(),output) + except _direct_exit: break + except: + if not thread_exception_handled: handle_exception() + else: raise + if prev_attrs!=None: termios.tcsetattr(sys.stdout, termios.TCSADRAIN, prev_attrs) # restore previous attributes exit_code=process.poll() try: if exit_code!=None and exit_code<0: # Terminated by signal + # Block signal handlers before the kill operation to prevent unexpected behavior + for sig in handle_signals: + signal.signal(sig, signal.SIG_IGN) os.kill(os.getpid(), abs(exit_code)) + # Properly return exit code for corresponding signals + return 128+abs(exit_code) except: pass return exit_code diff --git a/src/clitheme/frontend.py b/src/clitheme/frontend.py index 5255afb2c9f4cc21efa1dcd0359d13b67b0abf21..5c88d91302808b0260d8191e3280eb4f10908c6f 100644 --- a/src/clitheme/frontend.py +++ b/src/clitheme/frontend.py @@ -18,13 +18,61 @@ import string import re import hashlib import shutil -from typing import Optional +import inspect +from typing import Optional, List, Union, Dict from . import _globalvar # spell-checker:ignore newhash numorig numcur data_path=_globalvar.clitheme_root_data_path+"/"+_globalvar.generator_data_pathname +_setting_defs: Dict[str, type]={ + "domain": str, + "appname": str, + "subsections": str, + "debugmode": bool, + "lang": str, + "disablelang": bool, +} + +_local_settings: Dict[str, Dict[str, Union[None,str,bool]]]={} + +for name in _setting_defs.keys(): + _local_settings[name]={} + +def _get_caller() -> str: + assert len(inspect.stack())>=4, "Cannot determine filename from call stack" + # inspect.stack(): [0: this function, 1: update/get settings, 2: function in frontend module, 3: target calling function] + filename=inspect.stack()[3].filename + # Find the first function in the stack OUTSIDE of frontend module + # (The stack[3] may also be some function in frontend) + for s in inspect.stack()[3:]: + filename=s.filename + if filename!=__file__: break + return filename + +def _update_local_settings(key: str, value: Union[None,str,bool]): + _local_settings[key][_get_caller()]=value + if _get_setting("debugmode", _get_caller())==True: + print(f"[Debug] Set {key}={value} for file \"{_get_caller()}\"") + + +_desc=\ +""" +Set default value for `{}` option in future FetchDescriptor instances. +This setting is valid for the module/code file that invokes this function. + +- Set value=None to unset the default value and use values defined in global variables +- Change global variables (e.g. global_domain, global_debugmode) to set the default value for all files in an invoking module +""" + +def set_domain(value: Optional[str]): _desc.format("domain_name");_update_local_settings("domain", value) +def set_appname(value: Optional[str]): _desc.format("app_name");_update_local_settings("appname", value) +def set_subsections(value: Optional[str]): _desc.format("subsections");_update_local_settings("subsections", value) +def set_debugmode(value: Optional[bool]): _desc.format("debug_mode");_update_local_settings("debugmode", value) +def set_lang(value: Optional[str]): _desc.format("lang");_update_local_settings("lang", value) +def set_disablelang(value: Optional[bool]): _desc.format("disable_lang");_update_local_settings("disablelang", value) + global_domain="" global_appname="" global_subsections="" @@ -32,9 +80,18 @@ global_debugmode=False global_lang="" # Override locale global_disablelang=False +def _get_setting(key: str, caller: Optional[str]=None) -> Union[str,bool]: + # Get local settings + value=_local_settings[key].get(caller if caller!=None else _get_caller()) + if value!=None: return value + # Get global settings if not found + else: + return eval(f"global_{key}") + _alt_path=None _alt_path_dirname=None _alt_path_hash=None +_alt_info_index: int=1 # Support for setting a local definition file # - Generate the data in a temporary directory named after content hash # - First try alt_path then data_path @@ -80,26 +137,26 @@ def set_local_themedef(file_content: str, overlay: bool=False) -> bool: else: local_path_hash=d # else, use generated hash dir_name=f"clitheme-data-{local_path_hash}" _generator.generate_custom_path() # prepare _generator.path - global _alt_path_dirname + global _alt_path_dirname, _alt_info_index global global_debugmode path_name=_globalvar.clitheme_temp_root+"/"+dir_name if _alt_path_dirname!=None and overlay==True: # overlay if not os.path.exists(path_name): shutil.copytree(_globalvar.clitheme_temp_root+"/"+_alt_path_dirname, _generator.path) - if global_debugmode: print("[Debug] "+path_name) + if _get_setting("debugmode"): print("[Debug] set_local_themedef data path: "+path_name) # Generate data hierarchy as needed if not os.path.exists(path_name): _generator.silence_warn=True return_val: str - d_copy=global_debugmode + d_copy=(global_debugmode, _generator.silence_warn) try: # Set this to prevent extra messages from being displayed global_debugmode=False - return_val=_generator.generate_data_hierarchy(file_content, custom_path_gen=False) + return_val=_generator.generate_data_hierarchy(file_content, custom_path_gen=False, custom_infofile_name=str(_alt_info_index)) + _alt_info_index+=1 except SyntaxError: - if global_debugmode: print("[Debug] Generator error: "+str(sys.exc_info()[1])) + if _get_setting("debugmode"): print("[Debug] Generator error: "+str(sys.exc_info()[1])) return False - finally: global_debugmode=d_copy - # I GIVE UP on solving the callback cycle HELL on _generator.generate_data_hierarchy -> new GeneratorObject -> db_interface import -> set_local_themedef -> [generates data directory] so I'm going to add this CRAP fix + finally: global_debugmode, _generator.silence_warn=d_copy if not os.path.exists(path_name): shutil.copytree(return_val, path_name) try: shutil.rmtree(return_val) @@ -109,6 +166,27 @@ def set_local_themedef(file_content: str, overlay: bool=False) -> bool: _alt_path=path_name+"/"+_globalvar.generator_data_pathname _alt_path_dirname=dir_name return True + +def set_local_themedefs(file_contents: List[str], overlay: bool=False): + """ + Sets multiple local theme definition files for the current frontend instance. + When set, the FetchDescriptor functions will try the local definition before falling back to global theme data. + + - Set overlay=True to overlay on top of existing local definition data (if exists) + + WARNING: Pass the file content in str to this function; DO NOT pass the path to the file. + + This function returns True if successful, otherwise returns False. + """ + global _alt_path, _alt_path_hash, _alt_path_dirname + orig=(_alt_path, _alt_path_hash, _alt_path_dirname) + for x in range(len(file_contents)): + content=file_contents[x] + if not set_local_themedef(content, overlay=(x>0 or overlay)): + _alt_path, _alt_path_hash, _alt_path_dirname=orig + return False + return True + def unset_local_themedef(): """ Unset the local theme definition file for the current frontend instance. @@ -117,6 +195,7 @@ def unset_local_themedef(): global _alt_path; _alt_path=None global _alt_path_dirname; _alt_path_dirname=None global _alt_path_hash; _alt_path_hash=None + global _alt_info_index; _alt_info_index=1 class FetchDescriptor(): """ @@ -135,37 +214,37 @@ class FetchDescriptor(): # Leave domain and app names blank for global reference if domain_name==None: - self.domain_name=global_domain.strip() + self.domain_name: str=_get_setting("domain").strip() #type:ignore else: self.domain_name=domain_name.strip() if len(self.domain_name.split())>1: raise SyntaxError("Only one phrase is allowed for domain_name") if app_name==None: - self.app_name=global_appname.strip() + self.app_name: str=_get_setting("appname").strip() #type:ignore else: self.app_name=app_name.strip() if len(self.app_name.split())>1: raise SyntaxError("Only one phrase is allowed for app_name") if subsections==None: - self.subsections=global_subsections.strip() + self.subsections: str=_get_setting("subsections").strip() #type:ignore else: self.subsections=subsections.strip() self.subsections=re.sub(" {2,}", " ", self.subsections) if lang==None: - self.lang=global_lang.strip() + self.lang=_get_setting("lang").strip() #type:ignore else: self.lang=lang.strip() if debug_mode==None: - self.debug_mode=global_debugmode + self.debug_mode: bool=_get_setting("debugmode") #type:ignore else: self.debug_mode=debug_mode if disable_lang==None: - self.disable_lang=global_disablelang + self.disable_lang: bool=_get_setting("disablelang") #type:ignore else: self.disable_lang=disable_lang diff --git a/src/clitheme/man.py b/src/clitheme/man.py index 2a198927992e1c25ab2ab16f78649731f0ca411f..941fdc356a235593331bed276fb4df1ae0e8a005 100644 --- a/src/clitheme/man.py +++ b/src/clitheme/man.py @@ -17,15 +17,15 @@ import shutil import signal import time from . import _globalvar, frontend +from typing import List def _labeled_print(msg: str): print("[clitheme-man] "+msg) -_globalvar.handle_set_themedef(frontend, "clitheme-man") -frontend.global_domain="swiftycode" -frontend.global_appname="clitheme" +frontend.set_domain("swiftycode") +frontend.set_appname("clitheme") fd=frontend.FetchDescriptor(subsections="man") -def main(args: list): +def main(args: List[str]): """ Invoke clitheme-man using the given command line arguments @@ -64,11 +64,13 @@ def main(args: list): except KeyboardInterrupt: process.send_signal(signal.SIGINT) return process.poll() # type: ignore returncode=run_process(env) - if returncode!=0 and theme_set: + # Return code is negative when exited due to signal + if returncode>0 and theme_set: _labeled_print(fd.reof("prev-command-fail", "Executing \"man\" with custom path failed, trying execution with normal settings")) env["MANPATH"]=prev_manpath if prev_manpath!=None else '' returncode=run_process(os.environ) - return returncode + # If return code is a signal, handle the exit code properly + return 128+abs(returncode) if returncode<0 else returncode def _script_main(): # for script return main(sys.argv) diff --git a/src/clitheme/strings/cli-strings.clithemedef.txt b/src/clitheme/strings/cli-strings.clithemedef.txt index 8b91fa57cad0854aa4ab131a9116a5418faabb3f..314c8f82942138526e1de8e266291da7c9e6ce55 100644 --- a/src/clitheme/strings/cli-strings.clithemedef.txt +++ b/src/clitheme/strings/cli-strings.clithemedef.txt @@ -45,6 +45,18 @@ in_domainapp swiftycode clitheme # apply-theme 和 generate-data 指令 # apply-theme and generate-data commands in_subsection cli apply-theme + [entry] reading-file + # locale:default ==> Reading file {filename}... + locale:zh_CN ==> 正在读取文件{filename}... + [/entry] + [entry] reading-stdin-note + # locale:default Reading from standard input + locale:zh_CN 正在从stdin读取文件 + [/entry] + [entry] stdin-interactive-finish-prompt + # locale:default Input file content here and press {shortcut} to finish + locale:zh_CN 在此输入文件内容,并按下{shortcut}以结束 + [/entry] [entry] generate-data-msg # locale:default The theme data will be generated from the following definition files in the following order: locale:zh_CN 主题定义数据将会从以下顺序的主题定义文件生成: @@ -69,21 +81,17 @@ in_domainapp swiftycode clitheme # locale:default Overlay specified locale:zh_CN 已使用数据叠加模式 [/entry] - [entry] generating-data - # locale:default ==> Generating data... - locale:zh_CN ==> 正在生成数据... + [entry] processing-files + # locale:default ==> Processing files... + locale:zh_CN ==> 正在处理文件... [/entry] [entry] processing-file # locale:default > Processing file {filename}... - locale:zh_CN > 正在处理文件{filename} + locale:zh_CN > 正在处理文件{filename}... [/entry] - [entry] all-finished - # locale:default > All finished - locale:zh_CN > 已处理全部文件 - [/entry] - [entry] generate-data-success - # locale:default Successfully generated data - locale:zh_CN 已成功生成数据 + [entry] process-files-success + # locale:default Successfully processed files + locale:zh_CN 已成功处理文件 [/entry] [entry] view-temp-dir # locale:default View at {path} @@ -127,13 +135,13 @@ in_domainapp swiftycode clitheme 请移除当前数据和重新设定主题后重试 [/locale] [/entry] - [entry] generate-data-error + [entry] process-files-error # [locale] default - # [File {index}] An error occurred while generating the data: + # [File {index}] An error occurred while processing the file: # {message} # [/locale] [locale] zh_CN - [文件{index}] 生成数据时发生了错误: + [文件{index}] 处理文件时发生了错误: {message} [/locale] [/entry] @@ -166,13 +174,9 @@ in_domainapp swiftycode clitheme locale:zh_CN 当前没有设定任何主题 [/entry] [entry] current-theme-msg - # locale:default Currently installed theme: + # locale:default Currently installed theme(s): locale:zh_CN 当前设定的主题: [/entry] - [entry] overlay-history-msg - # locale:default Overlay history (sorted by latest installed): - locale:zh_CN 叠加历史记录(根据最新安装的主题排序): - [/entry] [entry] version-str # locale:default Version: {ver} locale:zh_CN 版本:{ver} @@ -229,38 +233,52 @@ in_domainapp swiftycode clitheme [/entry] [entry] options-apply-theme # [locale] default - # apply-theme: Applies the given theme definition file(s) into the current system. - # Specify --overlay to append value definitions in the file(s) onto the current data. - # Specify --preserve-temp to prevent the temporary directory from removed after the operation. (Debug purposes only) + # apply-theme: Apply the given theme definition file(s). + # Specify --overlay to add file(s) onto the current data. + # Specify --preserve-temp to preserve the temporary directory after the operation. (Debug purposes only) # [/locale] [locale] zh_CN - apply-theme:将指定的主题定义文件应用到当前系统中 - 指定"--overlay"选项以保留当前主题数据的情况下应用(添加到当前数据中) + apply-theme:应用指定的主题定义文件 + 指定"--overlay"选项以将文件添加到当前数据中 指定"--preserve-temp"以保留该操作生成的临时目录(调试用途) [/locale] [/entry] [entry] options-get-current-theme-info - # locale:default get-current-theme-info: Outputs detailed information about the currently applied theme - locale:zh_CN get-current-theme-info:输出当前主题设定的详细信息 + # [locale] default + # get-current-theme-info: Show information about the currently applied theme(s) + # Specify --name to only display the name of each theme + # Specify --file-path to only display the source file path of each theme + # (Both will be displayed when both specified) + # [/locale] + [locale] zh_CN + get-current-theme-info:显示当前主题设定的详细信息 + 指定"--name"以仅显示每个主题的名称 + 指定"--file-path"以仅显示每个主题的源文件路径 + (同时指定时,两者都会显示) + [/locale] [/entry] [entry] options-unset-current-theme # locale:default unset-current-theme: Remove the current theme data from the system locale:zh_CN unset-current-theme:取消设定当前主题定义和数据 [/entry] [entry] options-update-theme - # locale:default update-theme: Re-applies the theme definition files specified in the previous "apply-theme" command (previous commands if --overlay is used) + # locale:default update-theme: Re-apply the theme definition files specified in the previous \"apply-theme\" command (previous commands if --overlay is used) locale:zh_CN update-theme:重新应用上一个apply-theme操作中指定的主题定义文件(前几次操作,如果使用了"--overlay") [/entry] [entry] options-generate-data - # locale:default generate-data: [Debug purposes only] Generates a data hierarchy from specified theme definition files in a temporary directory + # locale:default generate-data: [Debug purposes only] Generate a data hierarchy from specified theme definition files in a temporary directory locale:zh_CN generate-data:【仅供调试用途】对于指定的主题定义文件在临时目录中生成一个数据结构 [/entry] + [entry] options-yes + # locale:default [For supported commands, specify --yes to skip the confirmation prompt] + locale:zh_CN 【在支持的指令中,指定"--yes"以跳过确认提示】 + [/entry] [entry] options-version - # locale:default --version: Outputs the current version of clitheme - locale:zh_CN --version:输出clitheme的当前版本信息 + # locale:default --version: Show the current version of clitheme + locale:zh_CN --version:显示clitheme的当前版本信息 [/entry] [entry] options-help - # locale:default --help: Display this help message - locale:zh_CN --help:输出这个帮助提示 + # locale:default --help: Show this help message + locale:zh_CN --help:显示这个帮助提示 [/entry] {/entries_section} diff --git a/src/clitheme/strings/exec-strings.clithemedef.txt b/src/clitheme/strings/exec-strings.clithemedef.txt index cff2092c39f80884845b5ffe01c48bc988d44358..efee0c8af57189717e49d82ad0b063df9b134c5c 100644 --- a/src/clitheme/strings/exec-strings.clithemedef.txt +++ b/src/clitheme/strings/exec-strings.clithemedef.txt @@ -38,17 +38,17 @@ in_domainapp swiftycode clitheme # locale:default --debug-newlines: Use newlines to display output that does not end on a newline locale:zh_CN --debug-newlines:使用新的一行来显示没有新行的输出 [/entry] - [entry] options-debug-showchars - # locale:default --debug-showchars: Display various control characters in plain text - locale:zh_CN --debug-showchars:使用明文显示终端控制符号 + [entry] options-showchars + # locale:default --showchars: Display various control characters in plain text + locale:zh_CN --showchars:使用明文显示终端控制符号 [/entry] - [entry] options-debug-foreground - # locale:default --debug-foreground: Display message when the foreground status of the process changes (value of tcgetpgrp) - locale:zh_CN --debug-foreground: 当进程的前台状态(tcgetpgrp的返回值)变动时,显示提示信息 + [entry] options-foreground-stat + # locale:default --foreground-stat: Display message when the foreground status of the process changes (value of tcgetpgrp) + locale:zh_CN --foreground-stat: 当进程的前台状态(tcgetpgrp的返回值)变动时,显示提示信息 [/entry] - [entry] options-debug-nosubst - # locale:default --debug-nosubst: Do not perform any output substitutions even if a theme is set - locale:zh_CN --debug-nosubst:不进行任何输出替换,即使已设定主题 + [entry] options-nosubst + # locale:default --nosubst: Do not perform any output substitutions even if a theme is set + locale:zh_CN --nosubst:不进行任何输出替换,即使已设定主题 [/entry] in_subsection exec [entry] help-usage-prompt @@ -59,6 +59,10 @@ in_domainapp swiftycode clitheme # locale:default Error: unknown option "{phrase}" locale:zh_CN 错误:未知选项"{phrase}" [/entry] + [entry] debug-newlines-not-with-debug + # locale:default Error: "--debug-newlines" must be used with "--debug" option + locale:zh_CN 错误:"--debug-newlines"选项必须与"--debug"选项同时指定 + [/entry] [entry] no-command-err # locale:default Error: no command specified locale:zh_CN 错误:未指定命令 @@ -67,27 +71,41 @@ in_domainapp swiftycode clitheme # locale:default Warning: no theme set or theme does not have substrules locale:zh_CN 警告:没有设定主题或当前主题没有substrules定义 [/entry] - [entry] substrules-migrate-msg - # locale:default Migrating substrules database... - locale:zh_CN 正在迁移substrules数据库... + [entry] substrules-update-msg + # locale:default Updating database... + locale:zh_CN 正在更新数据库... [/entry] - [entry] db-migration-generator-err + [entry] db-update-generator-err # locale:default Failed to generate data (full log below): locale:zh_CN 无法生成数据(完整日志在此): [/entry] - [entry] db-migration-err + [entry] db-update-err + # [locale] default + # An error occurred while updating the database: {msg} + # Please re-apply the theme and try again + # [/locale] + [locale] zh_CN + 更新数据库时发生了错误:{msg} + 请重新应用当前主题,然后重试 + [/locale] + [/entry] + [entry] db-read-err # [locale] default - # An error occurred while migrating the database: {msg} + # An error occurred while reading the database: {msg} # Please re-apply the theme and try again # [/locale] [locale] zh_CN - 迁移数据库时发生了错误:{msg} + 读取数据库时发生了错误:{msg} 请重新应用当前主题,然后重试 [/locale] [/entry] - [entry] db-migrate-success-msg - # locale:default Successfully completed migration, proceeding execution - locale:zh_CN 数据库迁移完成,继续执行命令 + [entry] db-invalid-version + # locale:default Invalid database version information + locale:zh_CN 无效数据库版本信息 + [/entry] + [entry] db-update-success-msg + # locale:default Successfully updated database, proceeding execution + locale:zh_CN 数据库更新完成,继续执行命令 [/entry] [entry] command-fail-err # locale:default Error: failed to run command: {msg} @@ -97,4 +115,8 @@ in_domainapp swiftycode clitheme # locale:default Error: an internal error has occurred while executing the command (execution halted): locale:zh_CN 错误:执行命令时发生内部错误(执行已终止): [/entry] + [entry] output-interrupted-exit + # locale:default Output interrupted after command exit + locale:zh_CN 输出在命令退出后被中断 + [/entry] {/entries_section} \ No newline at end of file diff --git a/src/clitheme/strings/generator-strings.clithemedef.txt b/src/clitheme/strings/generator-strings.clithemedef.txt index bea4f0a4142d81717b0b20864b58debca66f439b..e8d14f557bd532ec7a14e7ab3286912d3caa981c 100644 --- a/src/clitheme/strings/generator-strings.clithemedef.txt +++ b/src/clitheme/strings/generator-strings.clithemedef.txt @@ -56,6 +56,18 @@ in_domainapp swiftycode clitheme # locale:default Failed to migrate existing substrules database; try performing the operation without using "--overlay" locale:zh_CN 无法升级当前的substrules的数据库;请尝试不使用"--overlay"再次执行此操作 [/entry] + [entry] invalid-version-err + # locale:default Invalid version information "{ver}" on line {num} + locale:zh_CN 第{num}行:无效版本信息"{ver}" + [/entry] + [entry] unsupported-version-err + # locale:default Current version of clitheme ({cur_ver}) does not support this file (requires {req_ver} or higher) + locale:zh_CN 当前版本的clitheme({cur_ver})不支持此文件(需要 {req_ver} 或更高版本) + [/entry] + [entry] phrase-precedence-err + # locale:default Line {num}: header macro "{phrase}" must be specified before other lines + locale:zh_CN 第{num}行:头定义"{phrase}"必须在其他行之前声明 + [/entry] # 选项错误提示信息 # Options error messages [entry] option-not-allowed-err @@ -108,6 +120,10 @@ in_domainapp swiftycode clitheme # locale:default Missing "as " phrase on next line of line {num} locale:zh_CN 第{num}行:在下一行缺少"as <文件名>"语句 [/entry] + [entry] missing-info-err + # locale:default {sect_name} section missing required entries: {entries} + locale:zh_CN {sect_name}段落缺少必要条目:{entries} + [/entry] # 警告提示 # Warning messages [entry] warning-str @@ -159,6 +175,15 @@ in_domainapp swiftycode clitheme # locale:default Line {num}: manpage paths {sanitycheck_msg}; use spaces to denote subdirectories locale:zh_CN 第{num}行:manpage路径{sanitycheck_msg};使用空格以指定子路径 [/entry] + [entry] set-substvar-warn + # locale:default Line {num}: attempted to reference a defined variable, but "substvar" option is not enabled + locale:zh_CN 第{num}行:尝试引用定义的变量,但"substvar"选项未被启用 + [/entry] + [entry] set-substesc-warn + # Must use "{{{{ESC}}}}" to represent "{{ESC}}" in the message + # locale:default Line {num}: attempted to use "{{{{ESC}}}}", but "substesc" option is not enabled + locale:zh_CN Line {num}: 尝试引用"{{{{ESC}}}}",但"substesc"选项未被启用 + [/entry] # 以下提示会是上方定义中"{sanitycheck_msg}"的内容 # The following messages are the contents of "{sanitycheck_msg}" in the above entries [entry] sanity-check-msg-banphrase-err diff --git a/src/clithemedef-test_testprogram.py b/src/clithemedef-test_testprogram.py index 4271d75f0284b111f1610f6befde666d2e3f84da..1dde998ceda98de3f6d4632737375db80e80fae7 100644 --- a/src/clithemedef-test_testprogram.py +++ b/src/clithemedef-test_testprogram.py @@ -1,3 +1,9 @@ +# Copyright © 2023-2024 swiftycode + +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . + import shutil from clitheme import _generator from clitheme import _globalvar @@ -44,8 +50,8 @@ for line in expected_data.splitlines(): # Test frontend print("Testing frontend...") from clitheme import frontend -frontend.global_lang="en_US.UTF-8" -frontend.global_debugmode=True +frontend.set_debugmode(True) +frontend.set_lang("en_US.UTF-8") frontend.data_path=generator_path+"/"+_globalvar.generator_data_pathname expected_data_frontend=open(root_directory+"/testprogram-data/clithemedef-test_expected-frontend.txt", 'r', encoding="utf-8").read() current_path_frontend="" @@ -85,10 +91,12 @@ if errorcount>0: exit(1) else: print("Generator test OK") - shutil.rmtree(generator_path) # remove the temp directory print("==> ",end='') if errorcount_frontend>0: print("Frontend test error: "+str(errorcount_frontend)+" errors found") + print("See "+generator_path+" for more details") exit(1) else: - print("Frontend test OK") \ No newline at end of file + print("Frontend test OK") +if errorcount>0 and errorcount_frontend>0: + shutil.rmtree(generator_path) # remove the temp directory \ No newline at end of file diff --git a/src/db_interface_tests.py b/src/db_interface_tests.py index abdf9894cb947bfe4f94b3a3c73c09edfcb72a3c..9f623713385aee17c9ba91471cb2a2ef918a7e58 100644 --- a/src/db_interface_tests.py +++ b/src/db_interface_tests.py @@ -1,8 +1,12 @@ +# Copyright © 2023-2024 swiftycode + +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . + from clitheme._generator import db_interface from clitheme import _generator, _globalvar import shutil -import os -import unittest # sample input for testing sample_inputs=[("rm: missing operand", "rm"),