diff --git a/.gitignore b/.gitignore index 2e4a816b6b3ba8ab4fc0688908924f4e73b71fcd..e9df3afbed1455854cdc72094864c07b82d5af17 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,15 @@ __pycache__ __pycache__/* dist dist/* +build +build/* +venv +venv/* +src/*.egg-info .vscode .vscode/* +# Vim swap file +**/*.sw? # excluded files (ex) debian/*.ex debian/clitheme @@ -18,4 +25,5 @@ buildtmp buildtmp/* srctmp srctmp/* +*.pkg.tar.zst .DS_Store \ No newline at end of file diff --git a/PKGBUILD b/PKGBUILD index 5aa4a153fdf38ea030b7a2c8d2fa89981ea04197..570264f64b39d3e306991ba276eec17474bc067a 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,23 +1,23 @@ # Maintainer: swiftycode <3291929745@qq.com> pkgname='clitheme' -pkgver=UNKNOWN # to be filled out by pkgver() -pkgrel=1 # to be filled out by pkgver() +pkgver=2.0_beta2 +pkgrel=1 pkgdesc="A text theming library for command line applications" arch=('any') url="https://gitee.com/swiftycode/clitheme" license=('GPL3') -depends=('python>=3.7') -makedepends=('git' 'python-hatch' 'python-installer' 'python-wheel') +depends=('python>=3.8' 'sqlite>=3' 'man-db') +makedepends=('git' 'python-setuptools' 'python-build' 'python-installer' 'python-wheel' 'gzip') checkdepends=() optdepends=() -provides=($pkgname) -conflicts=($pkgname 'clitheme') +provides=() +conflicts=($pkgname) replaces=() backup=() options=() install= -changelog= -source=("srctmp::git+file://$PWD") +changelog='debian/changelog' +source=("srctmp::git+file://$PWD") # Commit any active changes before building the package! noextract=() md5sums=('SKIP') validpgpkeys=() @@ -32,7 +32,7 @@ pkgver(){ build() { cd srctmp - hatch build -t wheel + python3 -m build --wheel --no-isolation } check() { @@ -45,6 +45,12 @@ check() { echo -n "docs/clitheme.1 ..." test ! -f docs/clitheme.1 && echo "Error" && return 1 echo "OK" + echo -n "docs/clitheme-exec.1 ..." + test ! -f docs/clitheme-exec.1 && echo "Error" && return 1 + echo "OK" + echo -n "docs/clitheme-man.1 ..." + test ! -f docs/clitheme-man.1 && echo "Error" && return 1 + echo "OK" } package() { @@ -53,4 +59,6 @@ package() { # install manpage mkdir -p $pkgdir/usr/share/man/man1 gzip -c docs/clitheme.1 > $pkgdir/usr/share/man/man1/clitheme.1.gz + gzip -c docs/clitheme-exec.1 > $pkgdir/usr/share/man/man1/clitheme-exec.1.gz + gzip -c docs/clitheme-man.1 > $pkgdir/usr/share/man/man1/clitheme-man.1.gz } diff --git a/README-frontend.en.md b/README-frontend.en.md new file mode 100644 index 0000000000000000000000000000000000000000..3137ac232ebad749ed409d0f4393591467aeed00 --- /dev/null +++ b/README-frontend.en.md @@ -0,0 +1,130 @@ +# Frontend API and string entries demo + +## Data hierarchy and path naming + +Applications use **path names** to specify the string definitions they want. Subsections in the path name is separated using spaces. The first two subsections are usually reserved for the developer and application name. Theme definition files will use this path name to adopt corresponding string definitions, achieving the effect of output customization. + +For example, the path name `com.example example-app example-text` refers to the `example-text` string definition for the `example-app` application developed by `com.example`. + +It is not required to always follow this path naming convention and specifying global definitions (not related to any specific application) is allowed. For example, `global-entry` and `global-example global-text` are also valid path names. + +### Directly accessing the theme data hierarchy + +One of the key design principles of `clitheme` is that the use of frontend module is not needed to access the theme data hierarchy, and its method is easy to understand and implement. This is important especially in applications written in languages other than Python because Python is the only language supported by the frontend module. + +The data hierarchy is organized in a **subfolder structure**, meaning that every subsection in the path name represent a file or folder in the data hierarchy. + +For example, the contents of string definition `com.example example-app example-text` is stored in the directory `/com.example/example-app`. `` is `$XDG_DATA_HOME/clitheme/theme-data` or `~/.local/share/clitheme/theme-data` under Linux and macOS systems. + +Under Windows systems, `` is `%USERPROFILE%\.local\share\clitheme\theme-data` or `C:\Users\\.local\share\clitheme\theme-data`. + +To access a specific language of a string definition, add `__` plus the locale name to the end of the directory path. For example: `/com.example/example-app/example-text__en_US` + +In conclusion, to directly access a specific string definition, convert the path name to a directory path and access the file located there. + +## Frontend implementation and writing theme definition files + +### Using the built-in frontend module + +Using the frontend module provided by `clitheme` is very easy and straightforward. To access a string definition in the current theme setting, create a new `frontend.FetchDescriptor` object and use the provided `retrieve_entry_or_fallback` function. + +You need to pass the path name and a fallback string to this function. If the current theme setting does not provide the specified path name and string definition, the function will return the fallback string. + +You can pass the `domain_name`, `app_name`, and `subsections` arguments when creating a new `frontend.FetchDescriptor` object. When specified, these arguments will be automatically appended in front of the path name provided when calling the `retrieve_entry_or_fallback` function. + +Let's demonstrate it using the previous examples: + +```py +from clitheme import frontend + +# Create a new FetchDescriptor object +f=frontend.FetchDescriptor(domain_name="com.example", app_name="example-app") + +# Corresponds to "Found 2 files in current directory" +fcount="[...]" +f.retrieve_entry_or_fallback("found-file", "在当前目录找到了{}个文件".format(str(fcount))) + +# Corresponds to "-> Installing "example-file"..." +filename="[...]" +f.retrieve_entry_or_fallback("installing-file", "-> 正在安装\"{}\"...".format(filename)) + +# Corresponds to "Successfully installed 2 files" +f.retrieve_entry_or_fallback("install-success", "已成功安装{}个文件".format(str(fcount))) + +# Corresponds to "Error: File "foo-nonexist" not found" +filename_err="[...]" +f.retrieve_entry_or_fallback("file-not-found", "错误:找不到文件 \"{}\"".format(filename_err)) +``` + +### Using the fallback frontend module + +You can integrate the fallback frontend module provided by this project to better handle situations when `clitheme` does not exist on the system. This fallback module contains all the functions in the frontend module, and its functions will always return fallback values. + +Import the `frontend_fallback.py` file from the repository and insert the following code in your project to use it: + +```py +try: + from clitheme import frontend +except (ModuleNotFoundError, ImportError): + import frontend_fallback as frontend +``` + +The fallback module provided by this project will update accordingly with new versions. Therefore, it is recommended to import the latest version of this module to adopt the latest features. + +### Information your application should provide + +To allow users to write theme definition files of your application, your application should provide information about supported string definitions with its path name and default string. + +For example, your app can implement a feature to output all supported string definitions: + +``` +$ example-app --clitheme-output-defs +com.example example-app found-file +Found {} files in current directory + +com.example example-app installing-file +-> Installing "{}"... + +com.example example-app install-success +Successfully installed {} files + +com.example example-app file-not-found +Error: file "{}" not found +``` + +You can also include this information in your project's official documentation. The demo application in this repository provides an example of it and the corresponding README file is located in the folder `example-clithemedef`. + +### Writing theme definition files + +Consult the Wiki pages and documentation for detailed syntax of theme definition files. An example is provided below: + +``` +{header_section} + name Example theme + version 1.0 + locales en_US + supported_apps frontend_demo +{/header_section} + +{entries_section} + in_domainapp com.example example-app + [entry] found-file + locale:default o(≧v≦)o Great! Found {} files in current directory! + locale:en_US o(≧v≦)o Great! Found {} files in current directory! + [/entry] + [entry] installing-file + locale:default (>^ω^<) Installing "{}"... + locale:en_US (>^ω^<) Installing "{}"... + [/entry] + [entry] install-success + locale:default o(≧v≦)o Successfully installed {} files! + locale:en_US o(≧v≦)o Successfully installed {} files! + [/entry] + [entry] file-not-found + locale:default ಥ_ಥ Oh no, something went wrong! File "foo-nonexist" not found + locale:en_US ಥ_ಥ Oh no, something went wrong! File "foo-nonexist" not found + [/entry] +{/entries_section} +``` + +Use the command `clitheme apply-theme ` to apply the theme definition file onto the system. Supported applications will start using the string definitions listed in this file. diff --git a/README-frontend.md b/README-frontend.md new file mode 100644 index 0000000000000000000000000000000000000000..8b7d01471977b6f0b5188181b902bd7573cc3af3 --- /dev/null +++ b/README-frontend.md @@ -0,0 +1,130 @@ +# 应用程序API和字符串定义示范 + +## 数据结构和路径名称 + +应用程序是主要通过**路径名称**来指定所需的字符串。这个路径由空格来区别子路径(`subsections`)。大部分时候路径的前两个名称是用来指定开发者和应用名称的。主题文件会通过该路径名称来适配对应的字符串,从而达到自定义输出的效果。 + +比如`com.example example-app example-text`指的是`com.example`开发的`example-app`中的`example-text`字符串。 + +当然,路径名称也可以是全局的(不和任何应用信息关联),如`global-entry`或`global-example global-text`。 + +### 直接访问主题数据结构 + +`clitheme`的核心设计理念之一包括无需使用frontend模块就可以访问主题数据,并且访问方法直观易懂。这一点在使用其他语言编写的程序中尤其重要,因为frontend模块目前只提供Python程序的支持。 + +`clitheme`的数据结构采用了**子文件夹**的结构,意味着路径中的每一段代表着数据结构中的一个文件夹/文件。 + +比如说,`com.example example-app example-text` 的字符串会被存储在`/com.example/example-app/example-text`。在Linux和macOS系统下,``是 `$XDG_DATA_HOME/clitheme/theme-data`或`~/.local/share/clitheme/theme-data`。 + +在Windows系统下,``是`%USERPROFILE%\.local\share\clitheme\theme-data`。(`C:\Users\<用户名称>\.local\share\clitheme\theme-data`) + +如果需要访问该字符串的其他语言,直接在路径的最后添加`__`加上locale名称就可以了。比如:`/com.example/example-app/example-text__zh_CN` + +所以说,如果需要直接访问字符串信息,只需要访问对应的文件路径就可以了。 + +## 前端实施和编写主题文件 + +### 使用内置frontend模块 + +使用`clitheme`的frontend模块非常简单。只需要新建一个`frontend.FetchDescriptor`实例然后调用该实例中的`retrieve_entry_or_fallback`即可。 + +该函数需要提供路径名称和默认字符串。如果当前主题设定没有适配该字符串,则函数会返回提供的默认字符串。 + +如果新建`FetchDescriptor`时提供了`domain_name`,`app-name`,或`subsections`,则调用函数时会自动把它添加到路径名称前。 + +我们拿上面的样例来示范: + +```py +from clitheme import frontend + +# 新建FetchDescriptor实例 +f=frontend.FetchDescriptor(domain_name="com.example", app_name="example-app") + +# 对应 “在当前目录找到了2个文件” +fcount="[...]" +f.retrieve_entry_or_fallback("found-file", "在当前目录找到了{}个文件".format(str(fcount))) + +# 对应 “-> 正在安装 "example-file"...” +filename="[...]" +f.retrieve_entry_or_fallback("installing-file", "-> 正在安装\"{}\"...".format(filename)) + +# 对应 “已成功安装2个文件” +f.retrieve_entry_or_fallback("install-success", "已成功安装{}个文件".format(str(fcount))) + +# 对应 “错误:找不到文件 "foo-nonexist"” +filename_err="[...]" +f.retrieve_entry_or_fallback("file-not-found", "错误:找不到文件 \"{}\"".format(filename_err)) +``` + +### 使用fallback模块 + +应用程序还可以在src中内置本项目提供的fallback模块,以便更好的处理`clitheme`模块不存在时的情况。该fallback模块包括了frontend模块中的所有定义和功能,并且会永远返回失败时的默认值(fallback)。 + +如需使用,请在你的项目文件中导入`frontend_fallback.py`文件,并且在你的程序中包括以下代码: + +```py +try: + from clitheme import frontend +except (ModuleNotFoundError, ImportError): + import frontend_fallback as frontend +``` + +本项目提供的fallback文件会随版本更新而更改,所以请定期往你的项目里导入最新的fallback文件以适配最新的功能。 + +### 应用程序应该提供的信息 + +为了让用户更容易编写主题文件,应用程序应该加入输出字符串定义的功能。该输出信息应该包含路径名称和默认字符串。 + +比如说,应用程序可以通过`--clitheme-output-defs`来输出所有的字符串定义: + +``` +$ example-app --clitheme-output-defs +com.example example-app found-file +在当前目录找到了{}个文件 + +com.example example-app installing-file +-> 正在安装"{}"... + +com.example example-app install-success +已成功安装{}个文件 + +com.example example-app file-not-found +错误:找不到文件 "{}" +``` + +应用程序还可以在对应的官方文档中包括此信息。如需样例,请参考本仓库中`example-clithemedef`文件夹的[README文件](example-clithemedef/README.zh-CN.md)。 + +### 编写主题文件 + +关于主题文件的详细语法请见Wiki文档,下面将展示一个样例: + +``` +{header_section} + name 样例主题 + version 1.0 + locales zh_CN + supported_apps frontend_demo +{/header_section} + +{entries_section} + in_domainapp com.example example-app + [entry] found-file + locale:default o(≧v≦)o 太好了,在当前目录找到了{}个文件! + locale:zh_CN o(≧v≦)o 太好了,在当前目录找到了{}个文件! + [/entry] + [entry] installing-file + locale:default (>^ω^<) 正在安装 "{}"... + locale:zh_CN (>^ω^<) 正在安装 "{}"... + [/entry] + [entry] install-success + locale:default o(≧v≦)o 已成功安装{}个文件! + locale:zh_CN o(≧v≦)o 已成功安装{}个文件! + [/entry] + [entry] file-not-found + locale:default ಥ_ಥ 糟糕,出错啦!找不到文件 "{}" + locale:zh_CN ಥ_ಥ 糟糕,出错啦!找不到文件 "{}" + [/entry] +{/entries_section} +``` + +编写好主题文件后,使用 `clitheme apply-theme `来应用主题。应用程序会直接采用主题中适配的字符串。 diff --git a/README.en.md b/README.en.md index 7639254c7be78f4556172fcc9da237e4a5c0ed2b..44c3b081b6d4daed0754641e690e6326d0c4e876 100644 --- a/README.en.md +++ b/README.en.md @@ -1,267 +1,254 @@ -# clitheme - A CLI application framework for output customization +# clitheme - Command line customization utility -[中文](README.md) | **English** +[中文](./README.md) | **English** + +--- `clitheme` allows you to customize the output of command line applications, giving them the style and personality you want. Example: +```plaintext +$ clang test.c +test.c:1:1: error: unknown type name 'bool' +bool *func(int *a) { +^ +test.c:4:3: warning: incompatible pointer types assigning to 'char *' from 'int *' [-Wincompatible-pointer-types] + b=a; + ^~ +2 errors generated ``` -$ example-app install-files -Found 2 files in current directory --> Installing "example-file"... --> Installing "example-file-2"... -Successfully installed 2 files -$ example-app install-file foo-nonexist -Error: File "foo-nonexist" not found -``` -``` -$ clitheme apply-theme example-app-theme_clithemedef.txt +```plaintext +$ clitheme apply-theme clang-theme.clithemedef.txt ==> Generating data... Successfully generated data ==> Applying theme...Success Theme applied successfully ``` -``` -$ example-app install-files -o(≧v≦)o Great! Found 2 files in current directory! -(>^ω^<) Installing "example-file"... -(>^ω^<) Installing "example-file-2:"... -o(≧v≦)o Successfully installed 2 files! -$ example-app install-file foo-nonexist -ಥ_ಥ Oh no, something went wrong! File "foo-nonexist" not found +```plaintext +$ clitheme-exec clang test.c +test.c:1:1: Error! : unknown type name 'bool', you forgot to d……define it!~ಥ_ಥ +bool *func(int *a) { +^ +test.c:4:3: note: incompatible pointer types 'char *' and 'int *', they're so……so incompatible!~ [-Wincompatible-pointer-types] + b=a; + ^~ +2 errors generated. ``` ## Features -- Multi-language (Internationalization) support -- Supports applying multiple themes simultaneously -- Clear and easy-to-understand theme definition file (`clithemedef`) syntax -- The theme data can be easily accessed without the use of frontend module - -Not only `clitheme` can customize the output of command-line applications, it can also: -- Add support for another language for an application -- Support GUI applications - -# Basic usage - -## Data hierarchy and path naming - -Applications use **path names** to specify the string definitions they want. Subsections in the path name is separated using spaces. The first two subsections are usually reserved for the developer and application name. Theme definition files will use this path name to adopt corresponding string definitions, achieving the effect of output customization. - -For example, the path name `com.example example-app example-text` refers to the `example-text` string definition for the `example-app` application developed by `com.example`. - -It is not required to always follow this path naming convention and specifying global definitions (not related to any specific application) is allowed. For example, `global-entry` and `global-example global-text` are also valid path names. - -### Directly accessing the theme data hierarchy - -One of the key design principles of `clitheme` is that the use of frontend module is not needed to access the theme data hierarchy, and its method is easy to understand and implement. This is important especially in applications written in languages other than Python because Python is the only language supported by the frontend module. - -The data hierarchy is organized in a **subfolder structure**, meaning that every subsection in the path name represent a file or folder in the data hierarchy. - -For example, the contents of string definition `com.example example-app example-text` is stored in the directory `/com.example/example-app`. `` is `$XDG_DATA_HOME/clitheme/theme-data` or `~/.local/share/clitheme/theme-data` under Linux and macOS systems. - -Under Windows systems, `` is `%USERPROFILE%\.local\share\clitheme\theme-data` or `C:\Users\\.local\share\clitheme\theme-data`. - -To access a specific language of a string definition, add `__` plus the locale name to the end of the directory path. For example: `/com.example/example-app/example-text__en_US` - -In conclusion, to directly access a specific string definition, convert the path name to a directory path and access the file located there. - -## Frontend implementation and writing theme definition files +`clitheme` has these main features: -### Using the built-in frontend module +- Customize and modify the output of any command line application through defining substitution rules +- Customize Unix/Linux manual pages (man pages) +- A frontend API for applications similar to localization toolkits (like GNU gettext), which can help users better customize output messages -Using the frontend module provided by `clitheme` is very easy and straightforward. To access a string definition in the current theme setting, create a new `frontend.FetchDescriptor` object and use the provided `retrieve_entry_or_fallback` function. +Other characteristics: -You need to pass the path name and a fallback string to this function. If the current theme setting does not provide the specified path name and string definition, the function will return the fallback string. +- Multi-language/internalization support + - This means that you can also use `clitheme` to add internalization support for command line applications +- Easy-to-understand **theme definition file** syntax +- The string entries in the current theme setting can be accessed without using the frontend API (easy-to-understand data structure) -You can pass the `domain_name`, `app_name`, and `subsections` arguments when creating a new `frontend.FetchDescriptor` object. When specified, these arguments will be automatically appended in front of the path name provided when calling the `retrieve_entry_or_fallback` function. +For more information, please see the project's Wiki documentation page. It can be accessed through the following links: -Let's demonstrate it using the previous examples: +- https://gitee.com/swiftycode/clitheme/wikis/pages +- https://gitee.com/swiftycode/clitheme-wiki-repo +- https://github.com/swiftycode256/clitheme-wiki-repo -```py -from clitheme import frontend +# Feature examples and demos -# Create a new FetchDescriptor object -f=frontend.FetchDescriptor(domain_name="com.example", app_name="example-app") +## Command line output substitution -# Corresponds to "Found 2 files in current directory" -fcount="[...]" -f.retrieve_entry_or_fallback("found-file", "在当前目录找到了{}个文件".format(str(fcount))) +Get the command line output, including any terminal control characters: -# Corresponds to "-> Installing "example-file"..." -filename="[...]" -f.retrieve_entry_or_fallback("installing-file", "-> 正在安装\"{}\"...".format(filename)) +```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) -# Corresponds to "Successfully installed 2 files" -f.retrieve_entry_or_fallback("install-success", "已成功安装{}个文件".format(str(fcount))) - -# Corresponds to "Error: File "foo-nonexist" not found" -filename_err="[...]" -f.retrieve_entry_or_fallback("file-not-found", "错误:找不到文件 \"{}\"".format(filename_err)) +$ clitheme-exec --debug --debug-showchars --debug-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 +e> {{ESC}}[0m{{ESC}}[1mtest.c:4:3: {{ESC}}[0m{{ESC}}[0;1;35mwarning: {{ESC}}[0m{{ESC}}[1mincompatible pointer types assigning to 'char *' from 'int *' [-Wincompatible-pointer-types]{{ESC}}[0m\r\n +e> b=a;\r\n +e> {{ESC}}[0;1;32m ^~\r\n +e> {{ESC}}[0m2 errors generated.\r\n ``` -### Using the fallback frontend module - -You can integrate the fallback frontend module provided by this project to better handle situations when `clitheme` does not exist on the system. This fallback module contains all the functions in the frontend module, and its functions will always return fallback values. - -Import the `clitheme_fallback.py` file from the repository and insert the following code in your project to use it: - -```py -try: - from clitheme import frontend -except (ModuleNotFoundError, ImportError): - import clitheme_fallback as frontend +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 clang example theme + [description] + An example theme for clang (for demonstration purposes) + [/description] +{/header_section} + +{substrules_section} + # Set "substesc" option: "{{ESC}}" in content will be replaced with the ASCII Escape terminal control character + set_options substesc + # Command filter: following substitution rules will be applied only if these commands are invoked. It is recommended as it can prevent unwanted output substitutions. + [filter_commands] + clang + clang++ + 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.+)' + # 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.+)' + locale:default \gError! : \gunknown type name '\g', you forgot to d……define it!~ಥ_ಥ + [/substitute_regex] +{/substrules_section} ``` -The fallback module provided by this project will update accordingly with new versions. Therefore, it is recommended to import the latest version of this module to adopt the latest features. - -### Information your application should provide - -To allow users to write theme definition files of your application, your application should provide information about supported string definitions with its path name and default string. - -For example, your app can implement a feature to output all supported string definitions: - +After applying the theme with `clitheme apply-theme `, execute the command with `clitheme-exec` to apply the substitution rules onto the output: + +```plaintext +$ clitheme apply-theme clang-theme.clithemedef.txt +$ clitheme-exec clang test.c +test.c:1:1: Error! : unknown type name 'bool', you forgot to d……define it!~ಥ_ಥ +bool *func(int *a) { +^ +test.c:4:3: note: incompatible pointer types 'char *' and 'int *', they're so……so incompatible!~ [-Wincompatible-pointer-types] + b=a; + ^~ +2 errors generated. ``` -$ example-app --clitheme-output-defs -com.example example-app found-file -Found {} files in current directory -com.example example-app installing-file --> Installing "{}"... +## Custom man pages -com.example example-app install-success -Successfully installed {} files +Write/edit the source code of the man page and save it into a location: -com.example example-app file-not-found -Error: file "{}" not found +```plaintext +$ nano man-pages/1/ls-custom.txt +# +$ nano man-pages/1/cat-custom.txt +# ``` -You can also include this information in your project's official documentation. The demo application in this repository provides an example of it and the corresponding README file is located in the folder `example-clithemedef`. - -### Writing theme definition files +Write a theme definition file: + +```plaintext +{header_section} + name Example manual page theme + description An example man page theme +{/header_section} + +{manpage_section} + # Add the file path *separated by spaces* after "include_file" (with the directory the theme definition file is placed as the parent directory) + # Add the target file path (e.g. where the file is placed under `/usr/share/man`) *separated by spaces* after "as" + include_file man-pages 1 ls-custom.txt + as man1 ls.1 + include_file man-pages 1 cat-custom.txt + as man1 cat.1 +{/manpage_section} +``` -Consult the Wiki pages and documentation for detailed syntax of theme definition files. An example is provided below: +After applying the theme with `clitheme apply-theme `, use `clitheme-man` to view these custom man pages (arguments and options are the same as `man`): -``` -begin_header - name Example theme - version 1.0 - locales en_US - supported_apps clitheme_demo -end_header - -begin_main - in_domainapp com.example example-app - entry found-file - locale default o(≧v≦)o Great! Found {} files in current directory! - locale en_US o(≧v≦)o Great! Found {} files in current directory! - end_entry - entry installing-file - locale default (>^ω^<) Installing "{}"... - locale en_US (>^ω^<) Installing "{}"... - end_entry - entry install-success - locale default o(≧v≦)o Successfully installed {} files! - locale en_US o(≧v≦)o Successfully installed {} files! - end_entry - entry file-not-found - locale default ಥ_ಥ Oh no, something went wrong! File "foo-nonexist" not found - locale en_US ಥ_ಥ Oh no, something went wrong! File "foo-nonexist" not found - end_entry -end_main +```plaintext +$ clitheme apply-theme manpage-theme.clithemedef.txt +$ clitheme-man cat +$ clitheme-man ls ``` -Use the command `clitheme apply-theme ` to apply the theme definition file onto the system. Supported applications will start using the string definitions listed in this file. +## Application frontend API and string entries -# Installation +Please see [this article](./README-frontend.en.md) -Installing `clitheme` is very easy. You can use the provided Arch Linux, Debian, or pip package to install it. +# Installing and building -### Install using pip +`clitheme` can be installed through pip package, Debian package, and Arch Linux package. -Download the whl file from the latest release and install it using `pip`: +### Install using pip package - $ pip install clitheme--py3-none-any.whl +Download the `.whl` file from latest distribution page and install it using `pip`: + + $ pip install ./clitheme--py3-none-any.whl ### Install using Arch Linux package -Because `pip` cannot be used to install Python packages onto an Arch Linux system, this project provides an Arch Linux package. - -Because the built package only supports a specific Python version and will stop working when Python is upgraded, this project only provides files needed to build the package. Please see **Build Arch Linux package** for more information. +Because each build of the Arch Linux package only supports a specific Python version and upgrading Python will break the package, pre-built packages are not provided and you need to build the package. Please see **Building Arch Linux package** below. ### Install using Debian package -Because `pip` cannot be used to install Python packages onto certain Debian Linux distributions, this project provides a Debian package. - -Download the `.deb` file from the latest release and install it using `apt`: +Download the `.deb` file from the latest distribution page and install using `apt`: $ sudo apt install ./clitheme__all.deb -## Building the installation package +## Building packages -You can also build the installation package from source code, which allows you to include the latest or custom changes. This is the only method to install the latest development version of `clitheme`. +You can build the package from the repository source code, which includes any latest or custom changes. You can also use this method to install the latest development version. ### Build pip package -`clitheme` uses the `hatchling` build backend, so installing it is required for building the package. +`clitheme` uses the `setuptools` build system, so it needs to be installed beforehand. -First, install the `hatch` package. You can use the software package provided by your Linux distribution, or install it using `pip`: +First, install `setuptools`, `build`, and `wheel` packages. You can use the packages provided by your Linux distribution, or install using `pip`: - $ pip install hatch + $ pip install --upgrade setuptools build wheel -Next, making sure that you are under the project directory, use `hatch build` to build the package: +Then, switch to project directory and use the following command to build the package: - $ hatch build + $ python3 -m build --wheel --no-isolation -If this command does not work, try using `hatchling build` instead. - -The corresponding pip package (whl file) can be found in the `dist` folder under the working directory. +The package file can be found in the `dist` folder after build finishes. ### Build Arch Linux package -Make sure that the `base-devel` package is installed before building the package. You can install it using the following command: +Ensure that the `base-devel` package is installed before building. Use the following command to install: $ sudo pacman -S base-devel -To build the package, run `makepkg` under the project directory. You can use the following commands: +Before build the package, make sure that any changes in the repository are committed (git commit): + + $ git add . + $ git commit + +Execute `makepkg` to build the package. Use the following commands to perform these operations: ```sh -# Delete the temporary directories if makepkg has been run before. Issues will occur if you do not do so. +# If makepkg is executed before, delete the temporary directories to prevent issues rm -rf buildtmp srctmp makepkg -si -# -s: install dependencies required for building the package -# -i: automatically install the built package +# -s: Automatically install required build dependencies (e.g. python-setuptools, python-build) +# -i:Automatically install the built package -# You can remove the temporary directories after you are finished +# You can delete the temporary directories after it completes rm -rf buildtmp srctmp ``` -**Warning:** You must rebuild the package every time Python is upgraded, because the package only works under the Python version when the package is built. +**Note:** The package must be re-built every time Python is upgraded, because the package only works with the version of Python installed during build ### Build Debian package -Because `pip` cannot be used to install Python packages onto certain Debian Linux distributions, this project provides a Debian package. - -The following packages are required prior to building the package: +Install the following packages before building: - `debhelper` - `dh-python` -- `python3-hatchling` +- `python3-setuptools` - `dpkg-dev` +- `pybuild-plugin-pyproject` -They can be installed using this command: +You can use the following command to install: - sudo apt install debhelper dh-python python3-hatchling dpkg-dev + sudo apt install debhelper dh-python python3-setuptools dpkg-dev pybuild-plugin-pyproject -Run `dpkg-buildpackage -b` to build the package. A `.deb` file will be generated in the upper folder after the build process finishes. +While in the repo directory, execute `dpkg-buildpackage -b` to build the package. A `.deb` file will be generated at the parent directory (`..`) after build completes. -## More information +# More information -- For more information, please reference the project's Wiki pages: https://gitee.com/swiftycode/clitheme/wikis/pages - - You can also access the pages in these repositories: - - https://gitee.com/swiftycode/clitheme-wiki-repo - - https://github.com/swiftycode256/clitheme-wiki-repo - This repository is also synced onto GitHub (using Gitee automatic sync feature): https://github.com/swiftycode256/clitheme -- You are welcome to propose suggestions and changes using Issues and Pull Requests - - Use the Wiki repositories listed above for Wiki-related suggestions \ No newline at end of file +- The latest developments, future plans, and in-development features of this project are detailed in the Issues section of the Gitee repository: https://gitee.com/swiftycode/clitheme/issues +- Feel free to propose suggestions and changes using Issues and Pull Requests + - Use the Wiki repositories listed above for Wiki-related suggestions diff --git a/README.md b/README.md index bea038a6138bed582f9ab8458defbfb1bbf5f09f..dd6ddda4a20c973869507bb6bfed146bf967051c 100644 --- a/README.md +++ b/README.md @@ -1,198 +1,185 @@ -# clitheme - 命令行应用文本主题框架 +# clitheme - 命令行自定义工具 -**中文** | [English](README.en.md) +**中文** | [English](./README.en.md) -`clitheme` 允许你定制命令行应用程序的输出,给它们一个你想要的风格和个性。 +--- + +`clitheme`允许你对命令行输出进行个性化定制,给它们一个你想要的风格和个性。 样例: +```plaintext +$ clang test.c +test.c:1:1: error: unknown type name 'bool' +bool *func(int *a) { +^ +test.c:4:3: warning: incompatible pointer types assigning to 'char *' from 'int *' [-Wincompatible-pointer-types] + b=a; + ^~ +2 errors generated ``` -$ example-app install-files -在当前目录找到了2个文件 --> 正在安装 "example-file"... --> 正在安装 "example-file-2"... -已成功安装2个文件 -$ example-app install-file foo-nonexist -错误:找不到文件 "foo-nonexist" -``` -``` -$ clitheme apply-theme example-app-theme_clithemedef.txt +```plaintext +$ clitheme apply-theme clang-theme.clithemedef.txt ==> Generating data... Successfully generated data ==> Applying theme...Success Theme applied successfully ``` -``` -$ example-app install-files -o(≧v≦)o 太好了,在当前目录找到了2个文件! -(>^ω^<) 正在安装 "example-file"... -(>^ω^<) 正在安装 "example-file-2:"... -o(≧v≦)o 已成功安装2个文件! -$ example-app install-file foo-nonexist -ಥ_ಥ 糟糕,出错啦!找不到文件 "foo-nonexist" +```plaintext +$ clitheme-exec clang test.c +test.c:1:1: 错误!: 未知的类型名'bool',忘记定义了~ಥ_ಥ +bool *func(int *a) { +^ +test.c:4:3: 提示: 'char *'从不兼容的指针类型赋值为'int *',两者怎么都……都说不过去!^^; [-Wincompatible-pointer-types] + b=a; + ^~ +2 errors generated. ``` ## 功能 -- 多语言支持 -- 支持同时应用多个主题 -- 简洁易懂的主题信息文件(`clithemedef`)语法 -- 无需frontend模块也可访问当前主题数据(易懂的数据结构) - -`clitheme` 不仅可以定制命令行应用的输出,它还可以: -- 为应用程序添加多语言支持 -- 支持图形化应用 - -# 基本用法 - -## 数据结构和路径名称 - -应用程序是主要通过**路径名称**来指定所需的字符串。这个路径由空格来区别子路径(`subsections`)。大部分时候路径的前两个名称是用来指定开发者和应用名称的。主题文件会通过该路径名称来适配对应的字符串,从而达到自定义输出的效果。 - -比如`com.example example-app example-text`指的是`com.example`开发的`example-app`中的`example-text`字符串。 - -当然,路径名称也可以是全局的(不和任何应用信息关联),如`global-entry`或`global-example global-text`。 - -### 直接访问主题数据结构 - -`clitheme`的核心设计理念之一包括无需使用frontend模块就可以访问主题数据,并且访问方法直观易懂。这一点在使用其他语言编写的程序中尤其重要,因为frontend模块目前只提供Python程序的支持。 - -`clitheme`的数据结构采用了**子文件夹**的结构,意味着路径中的每一段代表着数据结构中的一个文件夹/文件。 - -比如说,`com.example example-app example-text` 的字符串会被存储在`/com.example/example-app/example-text`。在Linux和macOS系统下,``是 `$XDG_DATA_HOME/clitheme/theme-data`或`~/.local/share/clitheme/theme-data`。 - -在Windows系统下,``是`%USERPROFILE%\.local\share\clitheme\theme-data`。(`C:\Users\<用户名称>\.local\share\clitheme\theme-data`) - -如果需要访问该字符串的其他语言,直接在路径的最后添加`__`加上locale名称就可以了。比如:`/com.example/example-app/example-text__zh_CN` - -所以说,如果需要直接访问字符串信息,只需要访问对应的文件路径就可以了。 - -## 前端实施和编写主题文件 - -### 使用内置frontend模块 - -使用`clitheme`的frontend模块非常简单。只需要新建一个`frontend.FetchDescriptor`实例然后调用该实例中的`retrieve_entry_or_fallback`即可。 - -该函数需要提供路径名称和默认字符串。如果当前主题设定没有适配该字符串,则函数会返回提供的默认字符串。 - -如果新建`FetchDescriptor`时提供了`domain_name`,`app-name`,或`subsections`,则调用函数时会自动把它添加到路径名称前。 +`clitheme`包含以下主要功能: -我们拿上面的样例来示范: +- 对任何命令行应用程序的输出通过定义替换规则进行修改和自定义 +- 自定义Unix/Linux文档手册(manpage) +- 包含类似于本地化套件(如GNU gettext)的应用程序API,帮助用户更好的自定义提示信息的内容 -```py -from clitheme import frontend +其他特性: -# 新建FetchDescriptor实例 -f=frontend.FetchDescriptor(domain_name="com.example", app_name="example-app") - -# 对应 “在当前目录找到了2个文件” -fcount="[...]" -f.retrieve_entry_or_fallback("found-file", "在当前目录找到了{}个文件".format(str(fcount))) - -# 对应 “-> 正在安装 "example-file"...” -filename="[...]" -f.retrieve_entry_or_fallback("installing-file", "-> 正在安装\"{}\"...".format(filename)) - -# 对应 “已成功安装2个文件” -f.retrieve_entry_or_fallback("install-success", "已成功安装{}个文件".format(str(fcount))) - -# 对应 “错误:找不到文件 "foo-nonexist"” -filename_err="[...]" -f.retrieve_entry_or_fallback("file-not-found", "错误:找不到文件 \"{}\"".format(filename_err)) +- 多语言支持 + - 这意味着你也可以用`clitheme`来为应用程序添加多语言支持 +- 简洁易懂的**主题定义文件**语法 +- 无需应用程序API也可以访问当前主题中的字符串定义(易懂的数据结构) + +更多信息请见本项目的Wiki文档页面。你可以通过以下位置访问这些文档: +- https://gitee.com/swiftycode/clitheme/wikis/pages +- https://gitee.com/swiftycode/clitheme-wiki-repo +- https://github.com/swiftycode256/clitheme-wiki-repo + +# 功能样例和示范 + +## 命令行输出自定义 + +获取包含终端控制符号的原始输出内容: + +```plaintext +# --debug:在每一行的输出前添加标记;包含输出是否为stdout或stderr的信息("o>"或"e>") +# --debug-showchars:显示输出中的终端控制符号 +# --debug-nosubst:即使设定了主题,不对输出应用替换规则(获取原始输出) + +$ clitheme-exec --debug --debug-showchars --debug-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 +e> {{ESC}}[0m{{ESC}}[1mtest.c:4:3: {{ESC}}[0m{{ESC}}[0;1;35mwarning: {{ESC}}[0m{{ESC}}[1mincompatible pointer types assigning to 'char *' from 'int *' [-Wincompatible-pointer-types]{{ESC}}[0m\r\n +e> b=a;\r\n +e> {{ESC}}[0;1;32m ^~\r\n +e> {{ESC}}[0m2 errors generated.\r\n ``` -### 使用fallback模块 - -应用程序还可以在src中内置本项目提供的fallback模块,以便更好的处理`clitheme`模块不存在时的情况。该fallback模块包括了frontend模块中的所有定义和功能,并且会永远返回失败时的默认值(fallback)。 - -如需使用,请在你的项目文件中导入`clitheme_fallback.py`文件,并且在你的程序中包括以下代码: - -```py -try: - from clitheme import frontend -except (ModuleNotFoundError, ImportError): - import clitheme_fallback as frontend +根据输出内容编写主题定义文件和替换规则: + +```plaintext +# 在header_section中定义一些关于该主题定义的基本信息;必须包括 +{header_section} + # 这里建议至少包括name和description信息 + name clang样例主题 + [description] + 一个为clang打造的的样例主题,为了演示作用 + [/description] +{/header_section} + +{substrules_section} + # 设定"substesc"选项:内容中的"{{ESC}}"字样会被替换成ASCII Escape终端控制符号 + set_options substesc + # 命令限制条件:以下的替换规则仅会在以下命令被调用时被应用。建议设定这个条件,因为可以尽量防止不应该的输出替换。 + [filter_commands] + clang + clang++ + 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.+)' + # 如果你想仅在系统语言设定为中文(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.+)' + locale:default \g错误!: \g未知的类型名'\g',忘记定义了~ಥ_ಥ + [/substitute_regex] +{/substrules_section} ``` -本项目提供的fallback文件会随版本更新而更改,所以请定期往你的项目里导入最新的fallback文件以适配最新的功能。 - -### 应用程序应该提供的信息 - -为了让用户更容易编写主题文件,应用程序应该加入输出字符串定义的功能。该输出信息应该包含路径名称和默认字符串。 - -比如说,应用程序可以通过`--clitheme-output-defs`来输出所有的字符串定义: - +使用`clitheme apply-theme <文件>`应用主题后,使用`clitheme-exec`执行命令以对输出应用这些替换规则: + +```plaintext +$ clitheme apply-theme clang-theme.clithemedef.txt +$ clitheme-exec clang test.c +test.c:1:1: 错误!: 未知的类型名'bool',忘记定义了~ಥ_ಥ +bool *func(int *a) { +^ +test.c:4:3: 提示: 'char *'从不兼容的指针类型赋值为'int *',两者怎么都……都说不过去!^^; [-Wincompatible-pointer-types] + b=a; + ^~ +2 errors generated. ``` -$ example-app --clitheme-output-defs -com.example example-app found-file -在当前目录找到了{}个文件 -com.example example-app installing-file --> 正在安装"{}"... +## 自定义manpage文档 -com.example example-app install-success -已成功安装{}个文件 +编写/编辑manpage文档的源代码,并且保存在一个位置中: -com.example example-app file-not-found -错误:找不到文件 "{}" +```plaintext +$ nano man-pages/1/ls-custom.txt +# <编辑文件> +$ nano man-pages/1/cat-custom.txt +# <编辑文件> ``` -应用程序还可以在对应的官方文档中包括此信息。如需样例,请参考本仓库中`example-clithemedef`文件夹的[README文件](example-clithemedef/README.zh-CN.md)。 - -### 编写主题文件 +编写主题定义文件: + +```plaintext +{header_section} + name 样例文档手册主题 + description 一个manpage文档手册样例主题 +{/header_section} + +{manpage_section} + # 在"include_file"后添加*由空格分开*的文件路径(以主题定义文件所在的路径为父路径) + # 在"as"后添加*由空格分开*的目标文件路径(放在如`/usr/share/man`文件夹下的文件路径) + include_file man-pages 1 ls-custom.txt + as man1 ls.1 + include_file man-pages 1 cat-custom.txt + as man1 cat.1 +{/manpage_section} +``` -关于主题文件的详细语法请见Wiki文档,下面将展示一个样例: +使用`clitheme apply-theme <文件>`应用主题后,使用`clitheme-man`查看这些自定义文档(使用方法和选项和`man`一样): +```plaintext +$ clitheme apply-theme manpage-theme.clithemedef.txt +$ clitheme-man cat +$ clitheme-man ls ``` -begin_header - name 样例主题 - version 1.0 - locales zh_CN - supported_apps clitheme_demo -end_header - -begin_main - in_domainapp com.example example-app - entry found-file - locale default o(≧v≦)o 太好了,在当前目录找到了{}个文件! - locale zh_CN o(≧v≦)o 太好了,在当前目录找到了{}个文件! - end_entry - entry installing-file - locale default (>^ω^<) 正在安装 "{}"... - locale zh_CN (>^ω^<) 正在安装 "{}"... - end_entry - entry install-success - locale default o(≧v≦)o 已成功安装{}个文件! - locale zh_CN o(≧v≦)o 已成功安装{}个文件! - end_entry - entry file-not-found - locale default ಥ_ಥ 糟糕,出错啦!找不到文件 "{}" - locale zh_CN ಥ_ಥ 糟糕,出错啦!找不到文件 "{}" - end_entry -end_main -``` -编写好主题文件后,使用 `clitheme apply-theme `来应用主题。应用程序会直接采用主题中适配的字符串。 +## 应用程序API和字符串定义 + +请见[此文档](./README-frontend.md) -# 安装 +# 安装与构建 -安装`clitheme`非常简单,您可以通过Arch Linux软件包,Debian软件包,或者pip软件包安装。 +安装`clitheme`非常简单,您可以通过pip软件包,Arch Linux软件包,或者Debian软件包安装。 ### 通过pip软件包安装 -从最新发行版页面下载whl文件,使用`pip`直接安装即可: +从最新发行版页面下载`.whl`文件,使用`pip`直接安装即可: - $ pip install clitheme--py3-none-any.whl + $ pip install ./clitheme--py3-none-any.whl ### 通过Arch Linux软件包安装 -因为Arch Linux上无法使用`pip`往系统里直接安装pip软件包,所以本项目支持通过Arch Linux软件包安装。 - 因为构建的Arch Linux软件包只兼容特定的Python版本,并且升级Python版本后会导致原软件包失效,本项目仅提供构建软件包的方式,不提供构建好的软件包。详细请见下方的**构建Arch Linux软件包**。 ### 通过Debian软件包安装 -因为部分Debian系统(如Ubuntu)上无法使用`pip`往系统里直接安装pip软件包,所以本项目提供Debian软件包。 - 如需在Debian系统上安装,请从最新发行版页面下载`.deb`文件,使用`apt`安装即可: $ sudo apt install ./clitheme__all.deb @@ -203,17 +190,15 @@ end_main ### 构建pip软件包 -`clitheme`使用的是`hatchling`构建器,所以构建软件包前需要安装它。 - -首先,安装`hatch`软件包。你可以通过你使用的Linux发行版提供的软件包,或者使用以下命令通过`pip`安装: +`clitheme`使用的是`setuptools`构建器,所以构建软件包前需要安装它。 - $ pip install hatch +首先,安装`setuptools`、`build`、和`wheel`软件包。你可以通过你使用的Linux发行版提供的软件包,或者使用以下命令通过`pip`安装: -然后,切换到项目目录,使用`hatch build`构建软件包: + $ pip install --upgrade setuptools build wheel - $ hatch build +然后,切换到项目目录,使用以下命令构建软件包: -如果这个指令无法正常运行,请尝试运行`hatchling build`。 + $ python3 -m build --wheel --no-isolation 构建完成后,相应的安装包文件可以在当前目录中的`dist`文件夹中找到。 @@ -223,6 +208,11 @@ end_main $ sudo pacman -S base-devel +构建软件包前,请先确保任何对仓库文件的更改以被提交(git commit): + + $ git add . + $ git commit + 构建软件包只需要在仓库目录中执行`makepkg`指令就可以了。你可以通过以下一系列命令来完成这些操作: ```sh @@ -237,31 +227,27 @@ makepkg -si rm -rf buildtmp srctmp ``` -**注意:** 每次升级Python版本时,你需要重新构建并安装软件包,因为软件包只兼容构建时使用的Python版本。 +**注意:** 每次升级Python时,你需要重新构建并安装软件包,因为软件包只兼容构建时使用的Python版本。 ### 构建Debian软件包 -因为部分Debian系统(如Ubuntu)上无法使用`pip`往系统里直接安装pip软件包,所以本项目提供Debian软件包。 - 构建Debian软件包前,你需要安装以下用于构建的系统组件: - `debhelper` - `dh-python` -- `python3-hatchling` +- `python3-setuptools` - `dpkg-dev` +- `pybuild-plugin-pyproject` 你可以使用以下命令安装: - sudo apt install debhelper dh-python python3-hatchling dpkg-dev + sudo apt install debhelper dh-python python3-setuptools dpkg-dev pybuild-plugin-pyproject 安装完后,请在仓库目录中执行`dpkg-buildpackage -b`以构建软件包。完成后,你会在上层目录中获得一个`.deb`的文件。 -## 更多信息 +# 更多信息 -- 更多的详细信息和文档请参考本项目Wiki页面:https://gitee.com/swiftycode/clitheme/wikis/pages - - 你也可以通过以下仓库访问这些Wiki页面: - - https://gitee.com/swiftycode/clitheme-wiki-repo - - https://github.com/swiftycode256/clitheme-wiki-repo - 本仓库中的代码也同步在GitHub上(使用Gitee仓库镜像功能自动同步):https://github.com/swiftycode256/clitheme +- 该项目的最新进展、未来计划、和开发中的新功能会在这里Gitee仓库中的Issues里列出:https://gitee.com/swiftycode/clitheme/issues - 欢迎通过Issues和Pull Requests提交建议和改进。 - Wiki页面也可以;你可以在上方列出的仓库中提交Issues和Pull Requests \ No newline at end of file diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000000000000000000000000000000000000..f1a7d2a40dc6c6b28ac785e9765f6be6ec886c5a --- /dev/null +++ b/cspell.json @@ -0,0 +1,31 @@ +// cSpell Settings +{ + // Version of the setting file. Always 0.2 + "version": "0.2", + // language - current active spelling language + "language": "en", + // words - list of words to be always considered correct + "words": [ + "globalvar", + "swiftycode", + "clitheme", + "feof", "reof", + "themedef", "clithemeinfo", "clithemedef", "infofile", + "substrules", "manpages", + "exactcmdmatch", "smartcmdmatch", "endmatchhere", + "leadtabindents", "leadspaces", + "strictcmdmatch", "normalcmdmatch", "exactcmdmatch", "smartcmdmatch", + "subststdoutonly", "subststderronly", "substall", "foregroundonly", + "substvar", "substesc", + "PKGBUILD", "MANPATH", + "tcgetattr", "tcsetattr", "getpid", "setsid", "setitimer", "tcgetpgrp", "tcsetpgrp", + "TIOCGWINSZ", "TIOCSWINSZ", "TCSADRAIN", "SIGWINCH", "SIGALRM", "ITIMER", "SIGTSTP", "SIGSTOP", "SIGCONT", + "showchars", "keepends", + "appname", "domainapp", + "sanitycheck", "debugmode", "splitarray", "disablelang" + ], + // flagWords - list of words to be always considered incorrect + // This is useful for offensive words and common spelling errors. + "flagWords": [ + ] +} \ No newline at end of file diff --git a/debian/changelog b/debian/changelog index 37f8e4ce9abc7d6169c2c810397336005ea60fd7..90b611f89a6d440e41fcc6f3767e98113b6bef1a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,95 @@ +clitheme (2.0-beta2-1) unstable; urgency=low + + 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`) + * Support specifying `substesc` and `substvar` options on `[/substitute_string]` and `[/substitute_regex]` in `{substrules_section}`; affects match expression + * Support specifying `substvar` on `[/entry]` in `{entries_section}`; affects path name + + Bug fixes and improvements + + * Non-printable characters in CLI output messages will be displayed in its plain-text representation + * Fix compatibility issues with Python 3.8 + * Fixes an issue where pressing CTRL-C in `clitheme-man` causes the program to unexpectedly terminate + * Fixes an issue where terminal control characters are unexpectedly displayed on screen in Windows Command Prompt + * Theme definition file `{substrules_section}`: Fixes and issue where substitution rules with `strictcmdmatch` option are not applied if executed command arguments are the same in command filter + * Fixes an issue where "specifying multiple `[entry]` phrases" feature in `{entries_section}` does not work properly + * Fix many compatibility issues with applications in `clitheme-exec` + * Theme definition file: Optimize processing of command filter definitions with same commands and different match options + * Theme definition file: Fix unexpected "Option not allowed here" error when using multi-line content blocks + * Theme definition file: Fix and optimize content variable processing + * Theme definition file: Fix missing "Unexpected phrase" error in `{manpage_section}` when encountering invalid file syntax + + Latest code changes and development version: https://gitee.com/swiftycode/clitheme/tree/v1.2_dev + + Documentation: https://gitee.com/swiftycode/clitheme-wiki-repo + + Full release notes for v2.0: [please see v2.0-beta1 release page] + + -- swiftycode <3291929745@qq.com> Thu, 18 Jul 2024 00:12:00 +0800 + +clitheme (2.0-beta1-2) unstable; urgency=low + + * Fixed an issue where clitheme-man might use system man pages instead of custom defined man pages + + -- swiftycode <3291929745@qq.com> Sat, 15 Jun 2024 23:55:00 +0800 + +clitheme (2.0-beta1-1) unstable; urgency=low + + Known issues (Beta version) + + * Because redirecting stdout and stderr to separate streams cannot guarantee original output order as of now: + * The `subststdoutonly` and `subststderronly` options in theme definition file is currently unavailable + * The output marker will always show that output is stdout (`o>`) when using `clitheme-exec --debug` + * When executing certain commands using `clitheme-exec`, error messages such as `No access to TTY` or `No job control` might appear + * When executing `fish`, the error message `Inappropriate ioctl for device` will appear and will not run properly + * When executing `vim` and suspending the program (using `:suspend` or `^Z`), terminal settings/attributes will not be restored to normal + * Command line output substitution feature does not currently support Windows systems + * This feature might be delayed for version `v2.1` + + Code repository and latest code changes: https://gitee.com/swiftycode/clitheme/tree/v1.2_dev + + Documentation: https://gitee.com/swiftycode/clitheme-wiki-repo/tree/v1.2_dev + + Full release notes for v2.0: [please see v2.0-beta1 release page] + + -- swiftycode <3291929745@qq.com> Thu, 13 Jun 2024 08:44:00 +0800 + +clitheme (1.1-r2-1) unstable; urgency=medium + + Version 1.1 release 2 + + Bug fixes: + * Fixes an issue where setting overlay=True in frontend.set_local_themedef causes the function to not work properly + * Fixes an issue where the subsections in frontend functions are incorrectly parsed + + For more information please see: + https://gitee.com/swiftycode/clitheme/releases/tag/v1.1-r2 + + -- swiftycode <3291929745@qq.com> Sat, 02 Mar 2024 23:49:00 +0800 + clitheme (1.1-r1-1) unstable; urgency=medium - * Version 1.1-r1 in Debian package format + Version 1.1 release 1 + + New features: + * New description header definition for theme definition files + * Add support for multi-line/block input in theme definition files + * Support local deployment of theme definition files + * The CLI interface can be modified using theme definition files + * frontend module: Add format_entry_or_fallback function + * Add support for Windows + * CLI interface: Support specifying multiple files on apply-theme and generate-data-hierarchy commands + + Improvements and bug fixes: + * frontend module: Optimize language detection process + * CLI interface: Rename generate-data-hierarchy command to generate-data (the original command can still be used) + * Fix some grammar and spelling errors in output messages + For more information please see: https://gitee.com/swiftycode/clitheme/releases/tag/v1.1-r1 @@ -8,7 +97,8 @@ clitheme (1.1-r1-1) unstable; urgency=medium clitheme (1.0-r2-1) unstable; urgency=medium - * Version 1.0-r2 in Debian package format + Version 1.0 release 2 + For more information please see: https://gitee.com/swiftycode/clitheme/releases/tag/v1.0-r2 @@ -16,6 +106,6 @@ clitheme (1.0-r2-1) unstable; urgency=medium clitheme (1.0-r1-1) unstable; urgency=medium - * Version 1.0_r1 in Debian package format + Version 1.0 release 1 -- swiftycode <3291929745@qq.com> Wed, 13 Dec 2023 17:16:33 +0800 diff --git a/debian/clitheme.manpages b/debian/clitheme.manpages index 906208ab6fe7454c319fd647e594b5497eb54018..16d56f2bfb7bc7858a1569335829b796f2905152 100644 --- a/debian/clitheme.manpages +++ b/debian/clitheme.manpages @@ -1 +1,3 @@ -docs/clitheme.1 \ No newline at end of file +docs/clitheme.1 +docs/clitheme-exec.1 +docs/clitheme-man.1 \ No newline at end of file diff --git a/debian/control b/debian/control index 8227c380c52aa8eb5dd9363fab2e9d4422cf32c7..040754ba1cb84c0f6cf97d6f187db62683cf52ef 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,7 @@ Section: libs Priority: optional Maintainer: swiftycode <3291929745@qq.com> Rules-Requires-Root: no -Build-Depends: debhelper-compat (= 13), python3, dh-python, python3-hatchling +Build-Depends: debhelper-compat (= 13), python3, dh-python, python3-setuptools, pybuild-plugin-pyproject Standards-Version: 4.6.2 Homepage: https://gitee.com/swiftycode/clitheme Vcs-Git: https://gitee.com/swiftycode/clitheme.git @@ -11,7 +11,7 @@ Vcs-Git: https://gitee.com/swiftycode/clitheme.git Package: clitheme Architecture: all Multi-Arch: foreign -Depends: ${misc:Depends}, python3 (> 3.7) +Depends: ${misc:Depends}, python3 (> 3.8), man-db, sqlite3 Description: Application framework for text theming clitheme allows users to customize the output of supported programs, such as multi-language support or mimicking your favorite cartoon character. It has an diff --git a/example-clithemedef/README.zh-CN.md b/demo-clithemedef/README.zh-CN.md similarity index 86% rename from example-clithemedef/README.zh-CN.md rename to demo-clithemedef/README.zh-CN.md index d05e345356fc602ba327bc99f6e26b3b95714ac4..bfd0cfa28a936f81d74ff33d367feca255e201e0 100644 --- a/example-clithemedef/README.zh-CN.md +++ b/demo-clithemedef/README.zh-CN.md @@ -1,6 +1,6 @@ # 字符串定义说明 -本仓库中的`clitheme_example.py`支持以下字符串定义。默认字符串文本会以blockquote样式显示在路径名称下方。部分定义会包含额外的说明。 +本仓库中的`frontend_demo.py`支持以下字符串定义。默认字符串文本会以blockquote样式显示在路径名称下方。部分定义会包含额外的说明。 --- diff --git a/demo-clithemedef/demo-theme-textemojis.clithemedef.txt b/demo-clithemedef/demo-theme-textemojis.clithemedef.txt new file mode 100644 index 0000000000000000000000000000000000000000..b43d20f6e3e972a98938f7284b22f2d4b9815a57 --- /dev/null +++ b/demo-clithemedef/demo-theme-textemojis.clithemedef.txt @@ -0,0 +1,67 @@ +{header_section} + name 颜文字样例主题 + version 1.0 + # testing block input + [locales] + Simplified Chinese + 简体中文 + zh_CN + [/locales] + [supported_apps] + clitheme example + clitheme 样例应用 + clitheme_example + [/supported_apps] + [description] + 适配项目中提供的example程序的一个颜文字主题,把它的输出变得可爱。 + 应用这个主题,沉浸在颜文字的世界中吧! + + 不要小看我的年龄,人家可是非常萌的~! + [/description] +{/header_section} + +{entries_section} + in_domainapp com.example example-app + [entry] found-file + locale:default o(≧v≦)o 太好了,在当前目录找到了{}个文件! + locale:zh_CN o(≧v≦)o 太好了,在当前目录找到了{}个文件! + [/entry] + [entry] installing-file + locale:default (>^ω^<) 正在安装 "{}"... (>^ω^<) + locale:zh_CN (>^ω^<) 正在安装 "{}"... (>^ω^<) + [/entry] + [entry] install-success + locale:default o(≧v≦)o 已成功安装{}个文件! + locale:zh_CN o(≧v≦)o 已成功安装{}个文件! + [/entry] + [entry] install-success-file + locale:default o(≧v≦)o 已成功安装"{}"! + locale:zh_CN o(≧v≦)o 已成功安装"{}"! + [/entry] + [entry] file-not-found + locale:default ಥ_ಥ 糟糕,出错啦!找不到文件 "{}"!呜呜呜~ + locale:zh_CN ಥ_ಥ 糟糕,出错啦!找不到文件 "{}" !呜呜呜~ + [/entry] + [entry] format-error + locale:default ಥ_ಥ 糟糕,命令语法不正确!(ToT)/~~~ + locale:zh_CN ಥ_ಥ 糟糕,命令语法不正确!(ToT)/~~~ + [/entry] + [entry] directory-empty + locale:default ಥ_ಥ 糟糕,当前目录里没有任何文件!呜呜呜~ + locale:zh_CN ಥ_ಥ 糟糕,当前目录里没有任何文件!呜呜呜~ + [/entry] + + in_subsection helpmessage + [entry] description-general + locale:default (⊙ω⊙) 文件安装程序样例(不会修改系统中的文件哦~) + locale:zh_CN (⊙ω⊙) 文件安装程序样例(不会修改系统中的文件哦~) + [/entry] + [entry] description-usageprompt + locale:default (>﹏<) 使用方法:(◐‿◑) + locale:zh_CN (>﹏<) 使用方法:(◐‿◑) + [/entry] + [entry] unknown-command + locale:default ಥ_ಥ 找不到指令"{}"!呜呜呜~ + locale:zh_CN ಥ_ಥ 找不到指令"{}"!呜呜呜~ + [/entry] +{/entries_section} diff --git a/docs/clitheme-exec.1 b/docs/clitheme-exec.1 new file mode 100644 index 0000000000000000000000000000000000000000..8016e8c646497afbaad59a14b8cf1af783133b46 --- /dev/null +++ b/docs/clitheme-exec.1 @@ -0,0 +1,59 @@ +.TH clitheme-exec 1 2024-06-21 +.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 +.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 +.TP +.B --debug +Display an indicator at the beginning of each line of output. The indicator contains information about stdout/stderr and whether if substitution happened. +.P +.RS 14 +- \fIo>\fR: stdout output + +- \fIe>\fR: stderr output +.RE +.TP +.B --debug-color +Applies color on the output contents; used to determine whether output is stdout or stderr. + +For stdout, yellow color is applied. For stderr, red color is applied. +.TP +.B --debug-newlines +For output that does not end on a newline, display the output ending with newlines. +.TP +.B --debug-showchars +Display various control characters in plain text. The following characters will be displayed as its code name: +.P +.RS 14 +- ASCII escape character (ESC) + +- Carriage return (\\r) + +- Newline character (\\n) + +- Backspace character (\\x08) + +- Bell character (\\x07) +.RE +.TP +.B --debug-foreground +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. +.P +.RS 14 +- "! Foreground: False ()": Process exits foreground state + +- "! Foreground: True ()": Process enters (re-enters) foreground state +.RE +.TP +.B --debug-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. + +.SH SEE ALSO +\fIclitheme(1)\fR \ No newline at end of file diff --git a/docs/clitheme-man.1 b/docs/clitheme-man.1 new file mode 100644 index 0000000000000000000000000000000000000000..3b20930e60cdc89cb91459a074b1b66dc5c8b545 --- /dev/null +++ b/docs/clitheme-man.1 @@ -0,0 +1,13 @@ +.TH clitheme-man 1 2024-05-07 +.SH NAME +clitheme\-man \- access manual pages in the current theme +.SH SYNOPSIS +.B clitheme-man [OPTIONS] +.SH DESCRIPTION +\fIclitheme-man\fR is a wrapper for \fIman(1)\fR that accesses the manual pages defined in a theme definition file. The current theme definition on the system is controlled through \fIclitheme(1)\fR. +.P +\fIclitheme-man\fR is designed to be used the same way as \fIman(1)\fR; the same arguments and options will work on \fIclitheme-man\fR. +.SH OPTIONS +For a list of options you can use, please see \fIman(1)\fR. +.SH SEE ALSO +\fIclitheme(1)\fR, \fIman(1)\fR \ No newline at end of file diff --git a/docs/clitheme.1 b/docs/clitheme.1 index d9614b2915b24e750d17bbb555aead001101814b..c64e7577969a8d96b98ca574bafdb3b92676f0d9 100644 --- a/docs/clitheme.1 +++ b/docs/clitheme.1 @@ -1,57 +1,47 @@ -.TH clitheme 1 2024-01-20 +.TH clitheme 1 2024-06-17 .SH NAME clitheme \- frontend to customize output of applications .SH SYNOPSIS .B clitheme [COMMAND] [OPTIONS] .SH DESCRIPTION -clitheme 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. +\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 -.P +.TP .B apply-theme [themedef-file(s)] [--overlay] [--preserve-temp] -.RS 7 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--preserve-temp\fR to prevent the temporary directory from removed after the operation. -.RE -.P +.TP .B get-current-theme-info -.RS 7 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. -.RE -.P +.TP .B unset-current-theme -.RS 7 Removes the current theme data from the system. Supported applications will immediately stop using the defined values after this operation. -.RE -.P +.TP +.B update-theme +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. +.TP .B generate-data [themedef-file(s)] [--overlay] -.RS 7 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. -.RE -.P +.TP .B --help -.RS 7 Outputs a short help message consisting of available commands. -.RE -.P +.TP .B --version -.RS 7 Outputs the current version of the program. -.RE .SH SEE ALSO -For more information, please see the -.UR https://gitee.com/swiftycode/clitheme -project homepage -.UE -and the -.UR https://gitee.com/swiftycode/clitheme/wikis -project wiki: -.UE +\fIclitheme-exec(1)\fR, \fIclitheme-man(1)\fR + +For more information, please see the project homepage and the project wiki: .P -https://gitee.com/swiftycode/clitheme +.I https://gitee.com/swiftycode/clitheme .P -https://gitee.com/swiftycode/clitheme/wikis \ No newline at end of file +.I https://gitee.com/swiftycode/clitheme/wikis/pages +or +.I https://gitee.com/swiftycode/clitheme-wiki-repo \ No newline at end of file diff --git a/docs/docs.clithemedef.txt b/docs/docs.clithemedef.txt new file mode 100644 index 0000000000000000000000000000000000000000..43b374b9e4805a12153ae6118b87d23b48c229d0 --- /dev/null +++ b/docs/docs.clithemedef.txt @@ -0,0 +1,20 @@ +{header_section} + name clitheme manual pages + version 2.0 + [description] + This file contains manual pages for clitheme. + Apply this theme to install the manual pages in the "docs" directory. + After that, use "clitheme-man" to access them. + [/description] +{/header_section} + +{manpage_section} + set_options substvar + setvar:name clitheme + include_file {{name}}.1 + as man1 {{name}}.1 + include_file {{name}}-exec.1 + as man1 {{name}}-exec.1 + include_file {{name}}-man.1 + as man1 {{name}}-man.1 +{/manpage_section} \ No newline at end of file diff --git a/example-clithemedef/example-theme-textemojis.clithemedef.txt b/example-clithemedef/example-theme-textemojis.clithemedef.txt deleted file mode 100644 index afd87ce2b7c15a1d6c52bfdf78492a224842f339..0000000000000000000000000000000000000000 --- a/example-clithemedef/example-theme-textemojis.clithemedef.txt +++ /dev/null @@ -1,68 +0,0 @@ -begin_header - name 颜文字样例主题 - version 1.0 - # testing block input - locales_block - Simplified Chinese - 简体中文 - zh_CN - end_block - supported_apps_block - clitheme example - clitheme 样例应用 - clitheme_example - end_block - description_block - 适配项目中提供的example程序的一个颜文字主题,把它的输出变得可爱。 - 应用这个主题,沉浸在颜文字的世界中吧! - - 不要小看我的年龄,人家可是非常萌的~! - end_block - -end_header - -begin_main - in_domainapp com.example example-app - entry found-file - locale default o(≧v≦)o 太好了,在当前目录找到了{}个文件! - locale zh_CN o(≧v≦)o 太好了,在当前目录找到了{}个文件! - end_entry - entry installing-file - locale default (>^ω^<) 正在安装 "{}"... (>^ω^<) - locale zh_CN (>^ω^<) 正在安装 "{}"... (>^ω^<) - end_entry - entry install-success - locale default o(≧v≦)o 已成功安装{}个文件! - locale zh_CN o(≧v≦)o 已成功安装{}个文件! - end_entry - entry install-success-file - locale default o(≧v≦)o 已成功安装"{}"! - locale zh_CN o(≧v≦)o 已成功安装"{}"! - end_entry - entry file-not-found - locale default ಥ_ಥ 糟糕,出错啦!找不到文件 "{}"!呜呜呜~ - locale zh_CN ಥ_ಥ 糟糕,出错啦!找不到文件 "{}" !呜呜呜~ - end_entry - entry format-error - locale default ಥ_ಥ 糟糕,命令语法不正确!(ToT)/~~~ - locale zh_CN ಥ_ಥ 糟糕,命令语法不正确!(ToT)/~~~ - end_entry - entry directory-empty - locale default ಥ_ಥ 糟糕,当前目录里没有任何文件!呜呜呜~ - locale zh_CN ಥ_ಥ 糟糕,当前目录里没有任何文件!呜呜呜~ - end_entry - - in_subsection helpmessage - entry description-general - locale default (⊙ω⊙) 文件安装程序样例(不会修改系统中的文件哦~) - locale zh_CN (⊙ω⊙) 文件安装程序样例(不会修改系统中的文件哦~) - end_entry - entry description-usageprompt - locale default (>﹏<) 使用方法:(◐‿◑) - locale zh_CN (>﹏<) 使用方法:(◐‿◑) - end_entry - entry unknown-command - locale default ಥ_ಥ 找不到指令"{}"!呜呜呜~ - locale zh_CN ಥ_ಥ 找不到指令"{}"!呜呜呜~ - end_entry -end_main diff --git a/clitheme_demo.py b/frontend_demo.py similarity index 90% rename from clitheme_demo.py rename to frontend_demo.py index 533d0f318dead8cdc4b0b6324db8058ecba12f43..713a1c757e690580b4eb15379e63e98371dd0892 100755 --- a/clitheme_demo.py +++ b/frontend_demo.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 -# This file is originally named clitheme_example.py +# This program is a demo of the clitheme frontend API for applications. Apply a theme definition file in the folder "demo-clithemedef" to see it in action. +# 这个程序展示了clitheme的应用程序frontend API。请应用一个在"demo-clithemedef"文件夹中的任意一个主题定义文件以观察它的效果。 import os import sys diff --git a/clitheme_fallback.py b/frontend_fallback.py similarity index 85% rename from clitheme_fallback.py rename to frontend_fallback.py index 8b94b6b266c1ea426ee1e7b8d4ca4c4107934820..f07bb0af8cf36cf5900d2415893a9e23535d5ea4 100644 --- a/clitheme_fallback.py +++ b/frontend_fallback.py @@ -1,5 +1,5 @@ """ -clitheme fallback frontend for 1.1 (returns fallback values for all functions) +clitheme fallback frontend for version 2.0 (returns fallback values for all functions) """ from typing import Optional @@ -13,8 +13,10 @@ global_lang="" global_disablelang=False alt_path=None +alt_path_dirname=None +alt_path_hash=None -def set_local_themedef(file_content: str) -> bool: +def set_local_themedef(file_content: str, overlay: bool=False) -> bool: """Fallback set_local_themedef function (always returns False)""" return False def unset_local_themedef(): diff --git a/pyproject.toml b/pyproject.toml index 9a1592f47b41579d4a728d4767567cc5892d1a8e..d39afd05239b0700d99fd45d587cbcddfbdd2629 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,16 @@ [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" -[tool.hatch.version] -path = "src/clitheme/_version.py" +[tool.setuptools.dynamic] +version = {attr = "clitheme._version.__version__"} + +[tool.setuptools.packages.find] +where = ["src"] +exclude = ["*test*"] + +[tool.setuptools.package-data] +"*" = ["*"] [project] name = "clitheme" @@ -12,19 +19,22 @@ authors = [ { name="swiftycode", email="3291929745@qq.com" }, ] description = "A text theming library for command line applications" -readme = "README.md" -license = {file = "LICENSE"} -requires-python = ">=3.7" +readme = "README.en.md" +license = {text = "GNU General Public License v3 (GPLv3)"} +requires-python = ">=3.8" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", + "Private :: Do Not Upload" ] [project.scripts] -clitheme = "clitheme:cli.script_main" +clitheme = "clitheme:cli._script_main" +clitheme-exec = "clitheme:exec._script_main" +clitheme-man = "clitheme:man._script_main" [project.urls] -Homepage = "https://gitee.com/swiftycode/clitheme" -Documentation = "https://gitee.com/swiftycode/clitheme/wikis" +Repository = "https://gitee.com/swiftycode/clitheme" +Documentation = "https://gitee.com/swiftycode/clitheme/wikis/pages" Issues = "https://gitee.com/swiftycode/clitheme/issues" \ No newline at end of file diff --git a/clitheme-testblock_testprogram.py b/src/clitheme-testblock_testprogram.py similarity index 73% rename from clitheme-testblock_testprogram.py rename to src/clitheme-testblock_testprogram.py index b18aa132c70f2a4368daf0a57bc1ddd3d8d6f81b..af3b642216ab9dd081d2d3a8b19bda444faa87d3 100644 --- a/clitheme-testblock_testprogram.py +++ b/src/clitheme-testblock_testprogram.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # Program for testing multi-line (block) processing of _generator -from src.clitheme import _generator, frontend +from clitheme import _generator, frontend file_data=""" begin_header @@ -9,6 +9,7 @@ begin_header end_header begin_main + set_options leadtabindents:1 entry test_entry locale_block default en_US en C @@ -24,6 +25,17 @@ begin_main end_block + end_entry +end_main +""" + +file_data_2=""" +begin_header + name untitled +end_header + +begin_main + entry test_entry locale_block zh_CN @@ -36,7 +48,7 @@ begin_main should have leading 3 lines and trailing 2 lines - end_block + end_block leadspaces:4 end_entry end_main """ @@ -45,6 +57,9 @@ frontend.global_debugmode=True if frontend.set_local_themedef(file_data)==False: print("Error: set_local_themedef failed") exit(1) +if frontend.set_local_themedef(file_data_2, overlay=True)==False: # test overlay function + print("Error: set_local_themedef failed") + exit(1) f=frontend.FetchDescriptor() print("Default locale:") f.disable_lang=True @@ -59,14 +74,6 @@ for lang in ["C", "en", "en_US", "zh_CN"]: f.disable_lang=True name=f"test_entry__{lang}" if f.entry_exists(name): - print(f"{name} OK") + print(f"{name} found") else: - print(f"{name} not found") - -import sys -if sys.argv.__contains__("--preserve-temp"): - print(f"View generated data at {_generator.path}") - exit() - -import shutil -shutil.rmtree(_generator.path) \ No newline at end of file + print(f"{name} not found") \ No newline at end of file diff --git a/src/clitheme/__init__.py b/src/clitheme/__init__.py index 9629bb6f86e294c298e4fbd37fb4da799b68b362..6b724b11af4d9fc4ca1f1dc5dc68f4746d07652b 100644 --- a/src/clitheme/__init__.py +++ b/src/clitheme/__init__.py @@ -1 +1 @@ -__all__=["frontend", "cli"] \ No newline at end of file +__all__=["frontend", "cli", "man", "exec"] \ No newline at end of file diff --git a/src/clitheme/_generator.py b/src/clitheme/_generator.py deleted file mode 100644 index e927ffacf6e23ff3c0987bdb7e84f538c31844c1..0000000000000000000000000000000000000000 --- a/src/clitheme/_generator.py +++ /dev/null @@ -1,294 +0,0 @@ -""" -Generator function used in applying themes (should not be invoked directly) -""" -import os -import string -import random -import re -try: - from . import _globalvar - from . import frontend -except ImportError: # for test program - import _globalvar - import frontend - -path="" # to be generated by function - -fd=frontend.FetchDescriptor(domain_name="swiftycode", app_name="clitheme", subsections="generator") - -def handle_error(message): - raise SyntaxError(fd.feof("error-str", "Syntax error: {msg}", msg=message)) -def handle_warning(message): - print(fd.feof("warning-str", "Warning: {msg}", msg=message)) -def recursive_mkdir(path, entry_name, line_number_debug): # recursively generate directories (excluding file itself) - current_path=path - current_entry="" # for error output - for x in entry_name.split()[:-1]: - current_entry+=x+" " - current_path+="/"+x - if os.path.isfile(current_path): # conflict with entry file - handle_error(fd.feof("subsection-conflict-err", "Line {num}: cannot create subsection \"{name}\" because an entry with the same name already exists", \ - num=str(line_number_debug), name=current_entry)) - elif os.path.isdir(str(current_path))==False: # directory does not exist - os.mkdir(current_path) -def add_entry(path, entry_name, entry_content, line_number_debug): # add entry to where it belongs (assuming recursive_mkdir already completed) - target_path=path - for x in entry_name.split(): - target_path+="/"+x - if os.path.isdir(target_path): - handle_error(fd.feof("entry-conflict-err", "Line {num}: cannot create entry \"{name}\" because a subsection with the same name already exists", \ - num=str(line_number_debug), name=entry_name)) - elif os.path.isfile(target_path): - handle_warning(fd.feof("repeated-entry-warn", "Line {num}: repeated entry \"{name}\", overwriting", \ - num=str(line_number_debug), name=entry_name)) - f=open(target_path,'w', encoding="utf-8") - f.write(entry_content+"\n") -def splitarray_to_string(split_content): - final="" - for phrase in split_content: - final+=phrase+" " - return final.strip() -def write_infofile(path,filename,content,line_number_debug, header_name_debug): - if not os.path.isdir(path): - os.makedirs(path) - target_path=path+"/"+filename - if os.path.isfile(target_path): - handle_warning(fd.feof("repeated-header-warn", "Line {num}: repeated header info \"{name}\", overwriting", \ - num=str(line_number_debug), name=header_name_debug)) - f=open(target_path,'w', encoding="utf-8") - f.write(content+'\n') - -def write_infofile_v2(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 - if os.path.isfile(target_path): - handle_warning(fd.feof("repeated-header-warn", "Line {num}: repeated header info \"{name}\", overwriting", \ - num=str(line_number_debug), name=header_name_debug)) - f=open(target_path,'w', encoding="utf-8") - for line in content_phrases: - f.write(line+"\n") - -def generate_custom_path(): - # Generate a temporary path - global path - path=_globalvar.clitheme_temp_root+"/clitheme-temp-" - for x in range(8): - path+=random.choice(string.ascii_letters) - -# Returns true for success or error message -def generate_data_hierarchy(file_content, custom_path_gen=True, custom_infofile_name="1"): - """ - Generate the data hierarchy in a temporary directory from a definition file (accessible with _generator.path) - - This function should not be invoked directly unless absolutely necessary. - """ - if custom_path_gen: - generate_custom_path() - if not os.path.exists(path): os.mkdir(path) - datapath=path+"/"+_globalvar.generator_data_pathname - if not os.path.exists(datapath): os.mkdir(datapath) - current_status="" # header, main, entry - linenumber=0 - # To detect repeated blocks - headerparsed=False - mainparsed=False - - current_domainapp="" # for in_domainapp and unset_domainapp in main block - current_entry_name="" # for entry - current_subsection="" # for in_subsection - - current_entry_locale="" # for handling locale_block - current_entry_linenumber=-1 - - current_header_entry="" # for block input in header - current_header_linenumber=-1 - - blockinput=False # for multi-line (block) input - blockinput_data="" # data of current block input - blockinput_minspaces=-1 # min number of whitespaces - for line in file_content.splitlines(): - linenumber+=1 - phrases=line.split() - if blockinput==False and (line.strip()=="" or line.strip()[0]=="#"): # if empty line or comment (except in block input mode) - continue - - if blockinput==True: - if len(phrases)>0 and phrases[0]=="end_block": - if blockinput_minspaces!=-1: - # process whitespaces - # trim amount of leading whitespaces on each line - pattern=r"(?P\n|^)[ ]{"+str(blockinput_minspaces)+"}" - blockinput_data=re.sub(pattern,r"\g", blockinput_data) - if current_status=="entry": - for this_locale in current_entry_locale.split(): - target_entry=current_entry_name - if this_locale!="default": - target_entry+="__"+this_locale - add_entry(datapath,target_entry, blockinput_data, current_entry_linenumber) - # clear data - current_entry_locale="" - current_entry_linenumber=-1 - elif current_status=="header": - if current_header_entry!="description": - # trim all leading whitespaces - blockinput_data=re.sub(r"(?P\n|^)[ ]+",r"\g", blockinput_data) - # trim all trailing whitespaces - blockinput_data=re.sub(r"[ ]+(?P\n|$)",r"\g", blockinput_data) - # trim all leading/trailing newlines - blockinput_data=re.sub(r"(\A\n+|\n+\Z)", "", blockinput_data) - filename="clithemeinfo_"+current_header_entry+"_v2" - if current_header_entry=="description": - filename="clithemeinfo_"+current_header_entry - write_infofile( \ - path+"/"+_globalvar.generator_info_pathname+"/"+custom_infofile_name, \ - filename,\ - blockinput_data,current_header_linenumber,current_header_entry) # e.g. [...]/theme-info/1/clithemeinfo_description_v2 - # clear data - current_header_entry="" - current_header_linenumber=-1 - else: # the unlikely case - handle_error(fd.feof("internal-error-blockinput", "Line {num}: internal error while handling block input; please file a bug report", num=str(linenumber))) - # clear data - blockinput=False - blockinput_data="" - else: - if blockinput_data!="": blockinput_data+="\n" - line_content=line.strip() - if line_content=="": # empty line - if blockinput_data=="": blockinput_data+=" " - continue - # Calculate whitespaces - spaces=-1 - ws_match=re.search(r"^\s+", line) # match leading whitespaces - if ws_match==None: # no leading spaces - spaces=0 - else: - leading_whitespace=ws_match.group() - # substitute \t with 8 spaces - leading_whitespace=re.sub(r"\t"," "*8, leading_whitespace) - # append it to line_content - line_content=leading_whitespace+line_content - # write line_content to data - blockinput_data+=line_content - spaces=len(leading_whitespace) - # update min count - if spaces!=-1 and (spaces3: - handle_error(fd.feof("extra-arguments-err", "Extra arguments after \"{phrase}\" on line {num}", num=str(linenumber), phrase=phrases[0])) - # sanity check - if _globalvar.sanity_check(phrases[1]+" "+phrases[2])==False: - handle_error(fd.feof("sanity-check-domainapp-err", "Line {num}: domain and app names {sanitycheck_msg}", num=str(linenumber), sanitycheck_msg=_globalvar.sanity_check_error_message)) - current_domainapp=phrases[1]+" "+phrases[2] - current_subsection="" - elif phrases[0]=="in_subsection": - if len(phrases)<2: - handle_error(fd.feof("not-enough-args-err", "Not enough arguments for \"{phrase}\" at line {num}", phrase=phrases[0], num=str(linenumber))) - # check if in_domainapp is set - if current_domainapp=="": - handle_error(fd.feof("subsection-before-domainapp-err", "Line {num}: in_subsection used before in_domainapp", num=str(linenumber))) - # sanity check - if _globalvar.sanity_check(splitarray_to_string(phrases[1:]))==False: - handle_error(fd.feof("sanity-check-subsection-err", "Line {num}: subsection names {sanitycheck_msg}", num=str(linenumber), sanitycheck_msg=_globalvar.sanity_check_error_message)) - current_subsection=splitarray_to_string(phrases[1:]) - elif phrases[0]=="unset_domainapp": - if len(phrases)!=1: - handle_error(fd.feof("extra-arguments-err", "Extra arguments after \"{phrase}\" on line {num}", num=str(linenumber), phrase=phrases[0])) - current_domainapp="" - current_subsection="" - elif phrases[0]=="unset_subsection": - if len(phrases)!=1: - handle_error(fd.feof("extra-arguments-err", "Extra arguments after \"{phrase}\" on line {num}", num=str(linenumber), phrase=phrases[0])) - current_subsection="" - elif phrases[0]=="end_main": - if len(phrases)!=1: - handle_error(fd.feof("extra-arguments-err", "Extra arguments after \"{phrase}\" on line {num}", num=str(linenumber), phrase=phrases[0])) - current_status="" - mainparsed=True - else: handle_error(fd.feof("invalid-phrase-err", "Unexpected \"{phrase}\" on line {num}", phrase=phrases[0], num=str(linenumber))) - elif current_status=="entry": # expect locale, end_entry - if phrases[0]=="locale": - if len(phrases)<3: - handle_error(fd.feof("not-enough-args-err", "Not enough arguments for \"{phrase}\" at line {num}", phrase=phrases[0], num=str(linenumber))) - content=splitarray_to_string(phrases[2:]) - target_entry=current_entry_name - if phrases[1]!="default": - target_entry+="__"+phrases[1] - add_entry(datapath,target_entry,content,linenumber) - elif phrases[0]=="locale_block": - if len(phrases)<2: - handle_error(fd.feof("not-enough-args-err", "Not enough arguments for \"{phrase}\" at line {num}", phrase=phrases[0], num=str(linenumber))) - current_entry_locale=splitarray_to_string(phrases[1:]) - current_entry_linenumber=linenumber - blockinput=True # start block input - elif phrases[0]=="end_entry": - if len(phrases)!=1: - handle_error(fd.feof("extra-arguments-err", "Extra arguments after \"{phrase}\" on line {num}", num=str(linenumber), phrase=phrases[0])) - current_status="main" - current_entry_name="" - else: handle_error(fd.feof("invalid-phrase-err", "Unexpected \"{phrase}\" on line {num}", phrase=phrases[0], num=str(linenumber))) - if not headerparsed or not mainparsed: - handle_error(fd.reof("incomplete-block-err", "Missing or incomplete header or main block")) - # Update current theme index - theme_index=open(path+"/"+_globalvar.generator_info_pathname+"/"+_globalvar.generator_index_filename, 'w', encoding="utf-8") - theme_index.write(custom_infofile_name+"\n") - return True # Everything is successful! :) diff --git a/src/clitheme/_generator/__init__.py b/src/clitheme/_generator/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..618b624315c5dee8e9344002407b2e90df729d61 --- /dev/null +++ b/src/clitheme/_generator/__init__.py @@ -0,0 +1,78 @@ +# 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 . + +""" +Generator function used in applying themes (should not be invoked directly) +""" +import os +import string +import random +from typing import Optional + +# spell-checker:ignore infofile splitarray datapath lineindex banphrases cmdmatch minspaces blockinput optline matchoption endphrase filecontent + +path="" +silence_warn=False +__all__=["generate_data_hierarchy"] + +def generate_custom_path() -> str: + # Generate a temporary path + global path + path=_globalvar.clitheme_temp_root+"/clitheme-temp-" + for x in range(8): + path+=random.choice(string.ascii_letters) + return path + + +def generate_data_hierarchy(file_content: str, custom_path_gen=True, custom_infofile_name="1", filename: str="") -> str: + # make directories + 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) + + ## Main code + 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}": + _header_parser.handle_header_section(obj, first_phrase) + elif first_phrase=="begin_main" or first_phrase==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) + else: obj.handle_invalid_phrase(first_phrase) + + def is_content_parsed() -> bool: + content_sections=["entries", "substrules", "manpage"] + for section in content_sections: + if section in obj.parsed_sections: return True + return False + if obj.section_parsing or not "header" in obj.parsed_sections or not is_content_parsed(): + obj.handle_error(obj.fd.reof("incomplete-section-err", "Missing or incomplete header or content sections")) + # record file content for database migration/upgrade feature + obj.write_infofile(obj.path+"/"+_globalvar.generator_info_pathname+"/"+obj.custom_infofile_name, "file_content", obj.file_content, obj.lineindex+1, "") + # record *full* file path for update-themes feature + obj.write_infofile(obj.path+"/"+_globalvar.generator_info_pathname+"/"+obj.custom_infofile_name, _globalvar.generator_info_filename.format(info="filepath"), os.path.abspath(filename), obj.lineindex+1, "") + # Update current theme index + theme_index=open(obj.path+"/"+_globalvar.generator_info_pathname+"/"+_globalvar.generator_index_filename, 'w', encoding="utf-8") + 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/_dataclass.py b/src/clitheme/_generator/_dataclass.py new file mode 100644 index 0000000000000000000000000000000000000000..7c939d1f9f1b39fecef2b62b8e7cdac837471491 --- /dev/null +++ b/src/clitheme/_generator/_dataclass.py @@ -0,0 +1,387 @@ +# 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 . + +""" +Class object for sharing data between section parsers (internal module) +""" + +import sys +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 + +class GeneratorObject(_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"] + + # options used in handle_block_input + block_input_options=lead_indent_options+content_subst_options + + # value options: options requiring an integer value + value_options=lead_indent_options + # on/off options (use no<...> to disable) + bool_options=content_subst_options+["endmatchhere", "foregroundonly"] + # only one of these options can be set to true at the same time (specific to groups) + switch_options=[command_filter_options[:4]] + # Disable these options for now (BETA) + # switch_options+=[subst_limiting_options[:3]] + + def __init__(self, file_content: str, custom_infofile_name: str, filename: str, path: str, silence_warn: bool): + # data to keep track of + self.section_parsing=False + self.parsed_sections=[] + self.lines_data=file_content.splitlines() + self.lineindex=-1 # counter extra +1 operation at beginning + self.global_options={} + self.really_really_global_options={} # options defined outside any sections + self.global_variables={} + self.really_really_global_variables={} # variables defined outside any sections + # For in_domainapp and in_subsection in {entries_section} + self.in_domainapp="" + self.in_subsection="" + + self.custom_infofile_name=custom_infofile_name + self.filename=filename + self.file_content=file_content + _handlers.DataHandlers.__init__(self, path, silence_warn) + from . import db_interface + self.db_interface=db_interface + def is_ignore_line(self) -> bool: + return self.lines_data[self.lineindex].strip()=="" or self.lines_data[self.lineindex].strip().startswith('#') + def goto_next_line(self) -> bool: + while self.lineindexcount + 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 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: + # merge_global_options: 0 - Don't merge; 1 - Merge self.global_options; 2 - Merge self.really_really_global_options + 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 + 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) + if option_name_preserve_no in self.value_options: # must not begin with "no" + # get value + results=re.search(r"^(?P.+?):(?P.+)$", each_option) + value: int + if results==None: # no value specified + self.handle_error(self.fd.feof("option-without-value-err", "No value specified for option \"{phrase}\" on line {num}", num=str(self.lineindex+1), phrase=self.fmt(option_name))) + else: + try: value=int(results.groupdict()['value']) + except ValueError: self.handle_error(self.fd.feof("option-value-not-int-err", "The value specified for option \"{phrase}\" is not an integer on line {num}", num=str(self.lineindex+1), phrase=self.fmt(option_name))) + # set option + final_options[option_name]=value + elif option_name in self.bool_options: + # if starts with no, set to false; else, set to true + final_options[option_name]=not option_name_preserve_no.startswith("no") + else: + for option_group in self.switch_options: + if option_name_preserve_no in option_group: + for opt in options_data: + if opt!=option_name_preserve_no and opt in option_group: + self.handle_error(self.fd.feof("option-conflict-err", "The option \"{option1}\" can't be set at the same time with \"{option2}\" on line {num}", num=str(self.lineindex+1), option1=self.fmt(option_name_preserve_no), option2=self.fmt(opt))) + # set all other options to false + for opt in option_group: final_options[opt]=False + # set the option + final_options[option_name_preserve_no]=True + 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: + 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): + # 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) + def handle_setup_global_options(self): + # reset global_options to contents of really_really_global_options + self.global_options=copy.copy(self.really_really_global_options) + 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 + # get all variables used in content + new_content=copy.copy(content) + encountered_variables=set() + offset=0 + for match in re.finditer(r"{{([^\s]+?)??}}", 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 + var_content: str + try: + var_content=self.global_variables[var_name] + except KeyError: + if not silence_warnings and var_name not in encountered_variables: self.handle_warning(self.fd.feof("unknown-variable-warn", "Line {num}: unknown variable \"{name}\", not performing substitution", \ + num=line_number_debug if line_number_debug!=None else str(self.lineindex+1), name=self.fmt(var_name))) + else: + new_content=new_content[:match.start()+offset]+var_content+new_content[match.end()+offset:] + offset+=len(var_content)-(match.end()-match.start()) + encountered_variables.add(var_name) # Prevent repeated warnings + return new_content + def handle_set_variable(self, line_content: str, really_really_global: bool=False): + if not line_content.split()[0].startswith("setvar:"): return + # match variable name + self.check_enough_args(line_content.split(), 2) + results=re.search(r"setvar:(?P.+)", line_content.split()[0]) + var_name: str + if results==None: + self.handle_error(self.fd.feof("not-enough-args-err", "Not enough arguments for \"{phrase}\" at line {num}", phrase="setvar:", num=str(self.lineindex+1))) + else: var_name=results.groupdict()['name'] + # 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: + 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) + # set variable + if really_really_global: self.really_really_global_variables[var_name]=var_content + else: 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)) + self.section_parsing=True + self.handle_setup_global_options() + 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") + 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: + 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) + return target_content + + ## 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: + minspaces=math.inf + blockinput_data="" + begin_line_number=self.lineindex+1+1 + while self.lineindex"+end_phrase, line.strip()) + # update minspaces + minspaces=min(minspaces, len(leading_whitespace)) + else: # don't preserve whitespaces + line=re.sub(r"^\\([\\]*)"+end_phrase, r"\g<1>"+end_phrase, line.strip()) + # write to data + blockinput_data+="\n"+line + # remove the extra leading newline + blockinput_data=re.sub(r"\A\n", "", blockinput_data) + # remove all whitespaces except common minspaces (if preserve_indents) + 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 + 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]) + 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 diff --git a/src/clitheme/_generator/_entries_parser.py b/src/clitheme/_generator/_entries_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..1faae2a59310fbc6dc950bbb38043b7f4f932a06 --- /dev/null +++ b/src/clitheme/_generator/_entries_parser.py @@ -0,0 +1,62 @@ +# 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 . + +""" +entries_section parser function (internal module) +""" +from typing import Optional +from .. import _globalvar +from . import _dataclass + +# 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): + obj.handle_begin_section("entries") + end_phrase="end_main" if first_phrase=="begin_main" else r"{/entries_section}" + if first_phrase=="begin_main": + obj.handle_warning(obj.fd.feof("syntax-phrase-deprecation-warn", "Line {num}: phrase \"{old_phrase}\" is deprecated in this version; please use \"{new_phrase}\" instead", num=str(obj.lineindex+1), old_phrase="begin_main", new_phrase=r"{entries_section}")) + obj.in_domainapp="" + obj.in_subsection="" + 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() + 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] + if _globalvar.sanity_check(obj.in_domainapp)==False: + obj.handle_error(obj.fd.feof("sanity-check-domainapp-err", "Line {num}: domain and app names {sanitycheck_msg}", num=str(obj.lineindex+1), sanitycheck_msg=_globalvar.sanity_check_error_message)) + obj.in_subsection="" # clear subsection + 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) + 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": + obj.check_extra_args(phrases, 1, use_exact_count=True) + obj.in_domainapp=""; obj.in_subsection="" + 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]": + 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 phrases[0]==end_phrase: + obj.check_extra_args(phrases, 1, use_exact_count=True) + obj.handle_end_section("entries") + # deprecation warning + if phrases[0]=="end_main": + obj.handle_warning(obj.fd.feof("syntax-phrase-deprecation-warn", "Line {num}: phrase \"{old_phrase}\" is deprecated in this version; please use \"{new_phrase}\" instead", num=str(obj.lineindex+1), old_phrase="end_main", new_phrase=r"{/entries_section}")) + break + else: obj.handle_invalid_phrase(phrases[0]) diff --git a/src/clitheme/_generator/_handlers.py b/src/clitheme/_generator/_handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..3412fd4b8222da4d78909c858f36be71d2ede5cb --- /dev/null +++ b/src/clitheme/_generator/_handlers.py @@ -0,0 +1,93 @@ +# 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 . + +""" +Functions for data processing and others (internal module) +""" +import os +import gzip +import re +from typing import Optional +from .. import _globalvar, frontend + +# spell-checker:ignore datapath + +class DataHandlers: + frontend=frontend + + def __init__(self, path: str, silence_warn: bool): + self.path=path + self.silence_warn=silence_warn + if not os.path.exists(self.path): os.mkdir(self.path) + self.datapath=self.path+"/"+_globalvar.generator_data_pathname + 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) + raise SyntaxError(output) + def handle_warning(self, message: str): + output=self.fd.feof("warning-str", "Warning: {msg}", msg=message) + if not self.silence_warn: print(output) + def recursive_mkdir(self, path: str, entry_name: str, line_number_debug: int): # recursively generate directories (excluding file itself) + current_path=path + current_entry="" # for error output + for x in entry_name.split()[:-1]: + current_entry+=x+" " + current_path+="/"+x + if os.path.isfile(current_path): # conflict with entry file + self.handle_error(self.fd.feof("subsection-conflict-err", "Line {num}: cannot create subsection \"{name}\" because an entry with the same name already exists", \ + num=str(line_number_debug), name=self.fmt(current_entry))) + elif os.path.isdir(str(current_path))==False: # directory does not exist + os.mkdir(current_path) + def add_entry(self, path: str, entry_name: str, entry_content: str, line_number_debug: int): # add entry to where it belongs + self.recursive_mkdir(path, entry_name, line_number_debug) + target_path=path + for x in entry_name.split(): + target_path+="/"+x + if os.path.isdir(target_path): + self.handle_error(self.fd.feof("entry-conflict-err", "Line {num}: cannot create entry \"{name}\" because a subsection with the same name already exists", \ + num=str(line_number_debug), name=self.fmt(entry_name))) + elif os.path.isfile(target_path): + self.handle_warning(self.fd.feof("repeated-entry-warn", "Line {num}: repeated entry \"{name}\", overwriting", \ + num=str(line_number_debug), name=self.fmt(entry_name))) + f=open(target_path,'w', encoding="utf-8") + f.write(entry_content+"\n") + def write_infofile(self, path: str, filename: str, content: str, line_number_debug: int, header_name_debug: str): + if not os.path.isdir(path): + os.makedirs(path) + target_path=path+"/"+filename + if os.path.isfile(target_path): + self.handle_warning(self.fd.feof("repeated-header-warn", "Line {num}: repeated header info \"{name}\", overwriting", \ + 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): + if not os.path.isdir(path): + os.makedirs(path) + target_path=path+"/"+filename + if os.path.isfile(target_path): + self.handle_warning(self.fd.feof("repeated-header-warn", "Line {num}: repeated header info \"{name}\", overwriting", \ + num=str(line_number_debug), name=self.fmt(header_name_debug))) + 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): + 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))) + 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))) + try: + # write the compressed and original version of the file + 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 diff --git a/src/clitheme/_generator/_header_parser.py b/src/clitheme/_generator/_header_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..94dc8e2ea09d88d2ac2960df5390507e3068fe86 --- /dev/null +++ b/src/clitheme/_generator/_header_parser.py @@ -0,0 +1,64 @@ +# 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 . + +""" +header_section parser function (internal module) +""" +import re +from typing import Optional +from .. import _globalvar +from . import _dataclass + +# 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): + obj.handle_begin_section("header") + end_phrase="end_header" if first_phrase=="begin_header" else r"{/header_section}" + while obj.goto_next_line(): + phrases=obj.lines_data[obj.lineindex].split() + if phrases[0]=="name" or phrases[0]=="version" or phrases[0]=="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) + 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": + obj.check_enough_args(phrases, 2) + content=obj.subst_variable_content(_globalvar.splitarray_to_string(phrases[1:])).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]": + 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]": + 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: + content=obj.handle_block_input(preserve_indents=False, preserve_empty_lines=False, end_phrase=endphrase, disable_substesc=True) + file_name=_globalvar.generator_info_v2filename.format(info=re.sub(r'_block$', '', phrases[0]).replace('[','').replace(']','')) + obj.write_infofile( \ + 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 phrases[0]==end_phrase: + obj.check_extra_args(phrases, 1, use_exact_count=True) + obj.handle_end_section("header") + break + else: obj.handle_invalid_phrase(phrases[0]) \ No newline at end of file diff --git a/src/clitheme/_generator/_manpage_parser.py b/src/clitheme/_generator/_manpage_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..85e802ca55d4a599c81f5221fe1727fe94c232d9 --- /dev/null +++ b/src/clitheme/_generator/_manpage_parser.py @@ -0,0 +1,102 @@ +# 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 . + +""" +substrules_section parser function (internal module) +""" +import os +import sys +from typing import Optional +from .. import _globalvar +from . import _dataclass + +# 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): + 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: + # 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 + if obj.filename.strip()!="": + parent_dir+=os.path.dirname(obj.filename) + file_dir=parent_dir+("/" if parent_dir!="" else "")+_globalvar.splitarray_to_string(filepath).replace(" ","/") + # get file content + 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])) + # 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: + obj.check_enough_args(p, 2) + filepath=obj.subst_variable_content(_globalvar.splitarray_to_string(p[1:])).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)) + return filepath + file_paths=[handle(phrases)] + # handle additional [file_content] phrases + prev_line_index=obj.lineindex + while obj.goto_next_line(): + p=obj.lines_data[obj.lineindex].split() + if p[0]=="[file_content]": + prev_line_index=obj.lineindex + file_paths.append(handle(p)) + else: + obj.lineindex=prev_line_index + break + content=obj.handle_block_input(preserve_indents=True, preserve_empty_lines=True, end_phrase="[/file_content]") + for filepath in file_paths: + 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() + 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() + 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) + else: + 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() + 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) + while obj.goto_next_line(): + 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() + 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) + elif p[0]=="[/include_file]": + 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 phrases[0]==end_phrase: + obj.check_extra_args(phrases, 1, use_exact_count=True) + obj.handle_end_section("manpage") + break + else: obj.handle_invalid_phrase(phrases[0]) diff --git a/src/clitheme/_generator/_substrules_parser.py b/src/clitheme/_generator/_substrules_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..c5ce821c45b1734b0ed28c61892ae03149e3f85d --- /dev/null +++ b/src/clitheme/_generator/_substrules_parser.py @@ -0,0 +1,97 @@ +# 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 . + +""" +substrules_section parser function (internal module) +""" +import os +import copy +from typing import Optional +from .. import _globalvar +from . import _dataclass + +# 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): + obj.handle_begin_section("substrules") + end_phrase=r"{/substrules_section}" + command_filters: Optional[list]=None + command_filter_strictness=0 + command_filter_foreground_only=False + # 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\"")) + 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 + while obj.goto_next_line(): + 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) + # 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) + 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) + for this_option in got_options: + if this_option=="strictcmdmatch" and got_options['strictcmdmatch']==True: + strictness=1 + elif this_option=="exactcmdmatch" and got_options['exactcmdmatch']==True: + 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 + 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) + content=_globalvar.splitarray_to_string(phrases[1:]) + content=obj.subst_variable_content(content) + strictness=0 + foreground_only=False + for this_option in obj.global_options: + if this_option=="strictcmdmatch" and obj.global_options['strictcmdmatch']==True: + strictness=1 + elif this_option=="exactcmdmatch" and obj.global_options['exactcmdmatch']==True: + 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) + command_filters=None + elif phrases[0]=="[substitute_string]" or phrases[0]=="[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} + 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]) + elif phrases[0]==end_phrase: + obj.check_extra_args(phrases, 1, use_exact_count=True) + obj.handle_end_section("substrules") + break + else: obj.handle_invalid_phrase(phrases[0]) \ No newline at end of file diff --git a/src/clitheme/_generator/db_interface.py b/src/clitheme/_generator/db_interface.py new file mode 100644 index 0000000000000000000000000000000000000000..630626207fa2d9f182a25bc920df97916bcdd216 --- /dev/null +++ b/src/clitheme/_generator/db_interface.py @@ -0,0 +1,228 @@ +# 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 . + +""" +Interface for adding and matching substitution entries in database (internal module) +""" + +import sys +import os +import sqlite3 +import re +import copy +import uuid +from typing import Optional +from .. import _globalvar, frontend + +# spell-checker:ignore matchoption cmdlist exactmatch rowid pids tcpgrp + +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 + +def _handle_warning(message: str): + if debug_mode: print(fd.feof("warning-str", "Warning: {msg}", msg=message)) +def init_db(file_path: str): + global connection, db_path + db_path=file_path + connection=sqlite3.connect(file_path) + # create the table + # command_match_strictness: 0: default match options, 1: must start with pattern, 2: must exactly equal pattern + # stdout_stderr_only: 0: no limiter, 1: match stdout only, 2: match stderr only + connection.execute(f"CREATE TABLE {_globalvar.db_data_tablename} ( \ + match_pattern TEXT NOT NULL, \ + substitute_pattern TEXT NOT NULL, \ + is_regex INTEGER NOT NULL, \ + unique_id TEXT NOT NULL, \ + effective_command TEXT, \ + effective_locale TEXT, \ + command_match_strictness INTEGER NOT NULL, \ + foreground_only INTEGER NOT NULL, \ + end_match_here INTEGER NOT NULL, \ + stdout_stderr_only INTEGER NOT NULL \ + );") + connection.execute(f"CREATE TABLE {_globalvar.db_data_tablename}_version (value INTEGER NOT NULL);") + connection.execute(f"INSERT INTO {_globalvar.db_data_tablename}_version (value) VALUES (?)", (_globalvar.db_version,)) + connection.commit() +def connect_db(path: str=f"{_globalvar.clitheme_root_data_path}/{_globalvar.db_filename}"): + global db_path + db_path=path + if not os.path.exists(path): + raise FileNotFoundError("No theme set or theme does not contain substrules") + global connection + connection=sqlite3.connect(db_path) + # check db version + version=int(connection.execute(f"SELECT value FROM {_globalvar.db_data_tablename}_version").fetchone()[0]) + 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"): + if unique_id==uuid.UUID(int=0): unique_id=uuid.uuid4() + cmdlist: list=[] + 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"] + if effective_commands!=None and len(effective_commands)>0: + for cmd in effective_commands: + # remove extra spaces in the command + cmdlist.append(re.sub(r" {2,}", " ", cmd).strip()) + else: + # remove any existing values with the same match_pattern + match_condition=f"match_pattern=? AND typeof(effective_command)=typeof(null) {locale_condition} AND stdout_stderr_only=? AND is_regex=?" + match_params=(match_pattern, effective_locale, stdout_stderr_matchoption, is_regex) + if len(connection.execute(f"SELECT * FROM {_globalvar.db_data_tablename} WHERE {match_condition};", match_params).fetchall())>0: + _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)) + for cmd in cmdlist: + # remove any existing values with the same match_pattern and effective_command + strictness_condition="" + # if command_match_strictness==2: strictness_condition="AND command_match_strictness=2" + match_condition=f"match_pattern=? AND effective_command=? {strictness_condition} {locale_condition} AND stdout_stderr_only=? AND is_regex=?" + match_params=(match_pattern, cmd, effective_locale, stdout_stderr_matchoption, is_regex) + if len(connection.execute(f"SELECT * FROM {_globalvar.db_data_tablename} WHERE {match_condition};", match_params).fetchall())>0: + _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.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 + +def match_content(content: bytes, command: Optional[str]=None, is_stderr: bool=False, pids: tuple=(-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 + if not os.path.exists(db_path): raise sqlite3.OperationalError("file at db_path does not exist") + _connection=sqlite3.connect(db_path) + final_cmdlist=[] + final_cmdlist_exactmatch=[] + if command!=None and len(command.split())>0: + # command without paths (e.g. /usr/bin/bash -> bash) + stripped_command=os.path.basename(command.split()[0])+(" "+_globalvar.splitarray_to_string(command.split()[1:]) if len(command.split())>1 else '') + cmdlist_items=["effective_command", "command_match_strictness"] + # obtain a list of effective_command with the same first term + cmdlist=_connection.execute(f"SELECT DISTINCT {','.join(cmdlist_items)} FROM {_globalvar.db_data_tablename} WHERE effective_command LIKE ? or effective_command LIKE ?;", (command.split()[0].strip()+" %", stripped_command.split()[0].strip()+" %")).fetchall() + # also include one-phrase commands + cmdlist+=_connection.execute(f"SELECT DISTINCT {','.join(cmdlist_items)} FROM {_globalvar.db_data_tablename} WHERE effective_command=? or effective_command=?;", (command.split()[0].strip(),stripped_command.split()[0].strip())).fetchall() + # sort by number of phrases (greatest to least) + def split_len(obj: tuple) -> int: return len(obj[0].split()) + cmdlist.sort(key=split_len, reverse=True) + # prioritize effective_command with exact match requirement + cmdlist=_connection.execute(f"SELECT DISTINCT {','.join(cmdlist_items)} FROM {_globalvar.db_data_tablename} WHERE (effective_command=? OR effective_command=?) AND command_match_strictness=2", (re.sub(r" {2,}", " ", command).strip(),re.sub(r" {2,}", " ", stripped_command).strip())).fetchall()+cmdlist + # attempt to find matching command + for target_command in [command, stripped_command]: + for tp in cmdlist: + match_cmd=tp[0].strip() + strictness=tp[1] + if _check_strictness(match_cmd, strictness, target_command)==True: + # if found matching target_command + 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"] + # get locales + locales=_globalvar.get_locale() + nonlocal matches + # try the ones with locale defined + for this_locale in locales: + fetch_data=_connection.execute(f"SELECT DISTINCT {','.join(fetch_items)} FROM {_globalvar.db_data_tablename} WHERE {filter_condition} AND effective_locale=? ORDER BY rowid;", filter_data+(this_locale,)).fetchall() + if len(fetch_data)>0: + matches+=fetch_data + # else, fetches the ones without locale defined + matches+=_connection.execute(f"SELECT DISTINCT {','.join(fetch_items)} FROM {_globalvar.db_data_tablename} WHERE {filter_condition} AND typeof(effective_locale)=typeof(null) ORDER BY rowid;", filter_data).fetchall() + if len(final_cmdlist)>0: + for x in range(len(final_cmdlist)): + cmd=final_cmdlist[x] + # prioritize exact match + if final_cmdlist_exactmatch[x]==True: fetch_matches_by_locale("effective_command=? AND command_match_strictness=2", (cmd,)) + # 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)") + 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]): + content_str=copy.copy(content) + encountered_ids=set() + 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 strictness + if target_command!=None and match_data[7]!=None and \ + _check_strictness(match_data[7], match_data[8], \ + os.path.basename(target_command.split()[0])+(" "+_globalvar.splitarray_to_string(target_command.split()[1:]) if len(target_command.split())>1 else ''))==False: continue + if match_data[6]==True: # Foreground only + if pids[0]!=pids[1]: continue + matched=False + if match_data[2]==True: # is regex + try: + ret_val: tuple=re.subn(match_data[0], match_data[1], content_str.decode('utf-8')) + matched=ret_val[1]>0 + content_str=bytes(ret_val[0], 'utf-8') + except UnicodeDecodeError: + ret_val: tuple=re.subn(bytes(match_data[0],'utf-8'), bytes(match_data[1], 'utf-8'), content_str) + matched=ret_val[1]>0 + content_str=ret_val[0] + else: # is string + try: + matched=match_data[0] in content_str.decode('utf-8') + content_str=bytes(content_str.decode('utf-8').replace(match_data[0], match_data[1]), 'utf-8') + except UnicodeDecodeError: + 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 + return content_str diff --git a/src/clitheme/_get_resource.py b/src/clitheme/_get_resource.py new file mode 100644 index 0000000000000000000000000000000000000000..794197065e0ee9fad4e151e734355e352e530506 --- /dev/null +++ b/src/clitheme/_get_resource.py @@ -0,0 +1,11 @@ +""" +Script to get contents of file inside the module +""" +import os +l=__file__.split(os.sep) +l.pop() +final_str="" # directory where the script files are in +for part in l: + final_str+=part+os.sep +def read_file(path: str) -> str: + return open(final_str+os.sep+path, encoding="utf-8").read() \ No newline at end of file diff --git a/src/clitheme/_globalvar.py b/src/clitheme/_globalvar.py index 8bcc48e63226f38ba991bedc317dc59427303e6a..b99c5e5a7dcaf01e1988e5c090e4c16a1e9cbd66 100644 --- a/src/clitheme/_globalvar.py +++ b/src/clitheme/_globalvar.py @@ -1,22 +1,55 @@ +# 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 . + """ -Global variable definitions for clitheme +Global variable definitions and initialization operations for clitheme """ +import io import os -try: from . import _version -except ImportError: import _version +import sys +import re +import string +from copy import copy +from . import _version -clitheme_root_data_path="" -if os.name=="posix": # Linux/macOS only +# 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: - clitheme_root_data_path=os.environ["XDG_DATA_HOME"]+"/clitheme" - except KeyError: pass + 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. Try restarting your terminal session to fix this issue.""" +clitheme_version=_version.__version__ + +## Core data paths +clitheme_root_data_path="" +if os.name=="posix": # Linux/macOS only: Try to get XDG_DATA_HOME if possible + try: + clitheme_root_data_path=os.environ["XDG_DATA_HOME"]+"/clitheme" + except KeyError: pass + if clitheme_root_data_path=="": # prev did not succeed try: if os.name=="nt": # Windows @@ -31,44 +64,149 @@ if clitheme_root_data_path=="": # prev did not succeed var=r"%USERPROFILE%" print(error_msg_str.format(var=var)) exit(1) -clitheme_temp_root="/tmp" -if os.name=="nt": - clitheme_temp_root=os.environ['TEMP'] -clitheme_version=_version.__version__ +clitheme_temp_root="/tmp" if os.name!="nt" else os.environ['TEMP'] + +## _generator file and folder names generator_info_pathname="theme-info" # e.g. ~/.local/share/clitheme/theme-info generator_data_pathname="theme-data" # e.g. ~/.local/share/clitheme/theme-data -generator_index_filename="current_theme_index" +generator_manpage_pathname="manpages" # e.g. ~/.local/share/clitheme/manpages +generator_index_filename="current_theme_index" # e.g. [...]/theme-info/current_theme_index +generator_info_filename="clithemeinfo_{info}" # e.g. [...]/theme-info/1/clithemeinfo_name +generator_info_v2filename=generator_info_filename+"_v2" # e.g. [...]/theme-info/1/clithemeinfo_description_v2 + +## _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 + +## clitheme-exec timeout value for each output substitution operation +output_subst_timeout=0.4 + +## Sanity check function entry_banphrases=['/','\\'] startswith_banphrases=['.'] banphrase_error_message="cannot contain '{char}'" +banphrase_error_message_orig=copy(banphrase_error_message) startswith_error_message="cannot start with '{char}'" +startswith_error_message_orig=copy(startswith_error_message) # function to check whether the pathname contains invalid phrases # - cannot start with . # - cannot contain banphrases sanity_check_error_message="" - # retrieve the entry only once to avoid dead loop in frontend.FetchDescriptor callbacks msg_retrieved=False -def sanity_check(path): - # retrieve the entry (only for the first time) - try: from . import frontend - except ImportError: import frontend - global msg_retrieved - if not msg_retrieved: - msg_retrieved=True - f=frontend.FetchDescriptor(domain_name="swiftycode", app_name="clitheme", subsections="generator") - global banphrase_error_message - banphrase_error_message=f.feof("sanity-check-msg-banphrase-err", banphrase_error_message, char="{char}") - global startswith_error_message - startswith_error_message=f.feof("sanity-check-msg-startswith-err", startswith_error_message, char="{char}") +from . import frontend, _get_resource +def sanity_check(path: str, use_orig: bool=False) -> bool: + def retrieve_entry(): + # retrieve the entry (only for the first time) + 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}") + startswith_error_message=f.feof("sanity-check-msg-startswith-err", startswith_error_message, char="{char}") global sanity_check_error_message for p in path.split(): for b in startswith_banphrases: if p.startswith(b): - sanity_check_error_message=startswith_error_message.format(char=b) + if not use_orig: retrieve_entry() + sanity_check_error_message=startswith_error_message.format(char=b) if not use_orig else startswith_error_message_orig.format(char=b) return False for b in entry_banphrases: if p.find(b)!=-1: - sanity_check_error_message=banphrase_error_message.format(char=b) + if not use_orig: retrieve_entry() + sanity_check_error_message=banphrase_error_message.format(char=b) if not use_orig else banphrase_error_message_orig.format(char=b) return False return True + +## Convenience functions + +def splitarray_to_string(split_content) -> 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()) + if results==None: raise ValueError("Match content failed (no matches)") + else: return results.groupdict()['content'] +def make_printable(content: str) -> str: + final_str="" + for character in content: + if character.isprintable() or character in string.whitespace: final_str+=character + else: + exp=repr(character) + # Remove quotes in repr(character) + 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: + lang=[] + def add_language(target_lang: str): + nonlocal lang + if not sanity_check(target_lang, use_orig=True)==False: + no_encoding=re.sub(r"^(?P.+)[\.].+$", r"\g", target_lang) + lang.append(target_lang) + if no_encoding!=target_lang: lang.append(no_encoding) + else: + if debug_mode: print("[Debug] Locale \"{0}\": sanity check failed ({1})".format(target_lang, sanity_check_error_message)) + + # Skip $LANGUAGE if both $LANG and $LC_ALL is set to C (treat empty as C also) + LANG_value=os.environ["LANG"] if "LANG" in os.environ and os.environ["LANG"].strip()!='' else "C" + LC_ALL_value=os.environ["LC_ALL"] if "LC_ALL" in os.environ and os.environ["LC_ALL"].strip()!='' else "C" + skip_LANGUAGE=(LANG_value=="C" or LANG_value.startswith("C.")) and (LC_ALL_value=="C" or LC_ALL_value.startswith("C.")) + # $LANGUAGE (list of languages separated by colons) + if "LANGUAGE" in os.environ and not skip_LANGUAGE: + if debug_mode: print("[Debug] Using LANGUAGE variable") + target_str=os.environ['LANGUAGE'] + for language in target_str.split(":"): + each_language=language.strip() + if each_language=="": continue + # Ignore en and en_US (See https://wiki.archlinux.org/title/Locale#LANGUAGE:_fallback_locales) + if each_language!="en" and each_language!="en_US": + # Treat C as en_US also + if re.sub(r"^(?P.+)[\.].+$", r"\g", each_language)=="C": + for item in ["en_US", "en"]: + with_encoding=re.subn(r"^.+[\.]", f"{item}.", each_language) # (content, num_of_substitutions) + if with_encoding[1]>0: add_language(with_encoding[0]) + else: add_language(item) + add_language(each_language) + # $LC_ALL + elif "LC_ALL" in os.environ and os.environ["LC_ALL"].strip()!="": + if debug_mode: print("[Debug] Using LC_ALL variable") + target_str=os.environ["LC_ALL"].strip() + add_language(target_str) + # $LANG + elif "LANG" in os.environ and os.environ["LANG"].strip()!="": + if debug_mode: print("[Debug] Using LANG variable") + target_str=os.environ["LANG"].strip() + add_language(target_str) + return lang + +def handle_exception(): + env_var="CLITHEME_SHOW_TRACEBACK" + if env_var in os.environ and os.environ[env_var]=="1": + raise + +def handle_set_themedef(fr, debug_name: str): + 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 + 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 diff --git a/src/clitheme/_version.py b/src/clitheme/_version.py index 2c71cedefb192b6c5c77a3eda7baa8cedff8d35a..80fc14dd20a98d6aaf13f316fa2a9e1a9c1fe192 100644 --- a/src/clitheme/_version.py +++ b/src/clitheme/_version.py @@ -1,10 +1,15 @@ +""" +Version information definition file +""" +# spell-checker:ignore buildnumber + # Version definition file; define the package version here # The __version__ variable must be a literal string; DO NOT use variables -__version__="1.1-r1" -major=1 -minor=1 -release=1 # 0 stands for "dev" +__version__="2.0-beta2" +major=2 +minor=0 +release=-1 # -1 stands for "dev" # For PKGBUILD # version_main CANNOT contain hyphens (-); use underscores (_) instead -version_main="1.1_r1" +version_main="2.0_beta2" version_buildnumber=1 \ No newline at end of file diff --git a/src/clitheme/cli.py b/src/clitheme/cli.py old mode 100755 new mode 100644 index b9abdc5714d651b33d0461c6c4280d812d2513e3..3a7fe8beb141e5da2bdd61c1f2c86a668f2ed277 --- a/src/clitheme/cli.py +++ b/src/clitheme/cli.py @@ -1,43 +1,64 @@ -#!/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 . """ -clitheme command line utility interface +Module used for the clitheme command line interface + +- You can access 'clitheme' by invoking this module directly: 'python3 -m clitheme' +- You can invoke individual commands in scripts using the functions in this module """ import os import sys import shutil import re -try: - from . import _globalvar - from . import _generator - from . import frontend -except ImportError: - import _globalvar - import _generator - import frontend +import io +from . import _globalvar, _generator, frontend +from ._globalvar import make_printable as fmt # A shorter alias of the function -usage_description=\ -"""Usage: {0} apply-theme [themedef-file] [--overlay] [--preserve-temp] - {0} get-current-theme-info - {0} unset-current-theme - {0} generate-data [themedef-file] [--overlay] - {0} --help - {0} --version""" +# spell-checker:ignore pathnames lsdir inpstr frontend.global_domain="swiftycode" frontend.global_appname="clitheme" frontend.global_subsections="cli" -def apply_theme(file_contents: list[str], overlay: bool, preserve_temp=False, generate_only=False): +_globalvar.handle_set_themedef(frontend, "cli") + +last_data_path="" +def apply_theme(file_contents: list, filenames: list, overlay: bool, preserve_temp=False, generate_only=False): """ - Apply the theme using the provided definition file contents in a list[str] object. + Apply the theme using the provided definition file contents and file pathnames in a list object. + + (Invokes 'clitheme apply-theme') - 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 (and not apply the theme) + - Set generate_only=True to generate the data hierarchy only (invokes 'clitheme generate-data' instead) """ + 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.")) + 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...")) index=1 @@ -53,57 +74,89 @@ def apply_theme(file_contents: list[str], overlay: bool, preserve_temp=False, ge except ValueError: print(f.reof("overlay-data-error", \ "Error: the current data is corrupt\nRemove the current theme, set the theme, and try again")) + _globalvar.handle_exception() return 1 # copy the current data into the temp directory _generator.generate_custom_path() 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 + orig_stdout=sys.stdout # Prevent interference with other code piping stdout for i in range(len(file_contents)): - if len(file_contents)>1: - print(" "+f.feof("processing-file", "> Processing file {filename}...", filename=str(i+1))) + if print_progress: + print(line_prefix+f.feof("processing-file", "> Processing file {filename}...", filename=f"({i+1}/{len(file_contents)})"), end='') file_content=file_contents[i] # Generate data hierarchy, erase current data, copy it to data path + generator_msgs=io.StringIO() try: - _generator.generate_data_hierarchy(file_content, custom_path_gen=generate_path,custom_infofile_name=str(index)) + _generator.silence_warn=False + # Output the warning messages correctly (make sure that they start on new line if any exists) + sys.stdout=generator_msgs + final_path=_generator.generate_data_hierarchy(file_content, custom_path_gen=generate_path,custom_infofile_name=str(index), filename=filenames[i] if len(filenames)>0 else "") generate_path=False # Don't generate another temp folder after first one index+=1 - except SyntaxError: + except Exception as exc: + sys.stdout=orig_stdout + print(("\n" if print_progress else ""), 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}", \ - index=str(i+1), message=str(sys.exc_info()[1]) )) + 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 return 1 - if len(file_contents)>1: - print(" "+f.reof("all-finished", "> All finished")) + else: + sys.stdout=orig_stdout # restore standard output + if generator_msgs.getvalue()!='': + print(("\n" if print_progress else "")+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")) + global last_data_path; last_data_path=final_path if preserve_temp or generate_only: if os.name=="nt": - print(f.feof("view-temp-dir", "View at {path}", path=re.sub(r"/", r"\\", _generator.path))) # make the output look pretty + print(f.feof("view-temp-dir", "View at {path}", path=fmt(re.sub(r"/", r"\\", final_path)))) # make the output look pretty else: - print(f.feof("view-temp-dir", "View at {path}", path=_generator.path)) + print(f.feof("view-temp-dir", "View at {path}", path=fmt(final_path))) if generate_only: return 0 # ---Stop here if generate_only is set--- print(f.reof("applying-theme", "==> Applying theme...")) # remove the current data, ignoring directory not found error - try: shutil.rmtree(_globalvar.clitheme_root_data_path) + try: + try: shutil.rmtree(_globalvar.clitheme_root_data_path) + except OSError as exc: + # Prevent errors when executing "clitheme apply-theme" in clitheme-exec + if exc.errno==66: pass # Directory not empty error when rmtree executes os.rmdir after removing files + else: raise except FileNotFoundError: pass except Exception: - print(f.feof("apply-theme-error", "An error occurred while applying the theme:\n{message}", message=str(sys.exc_info()[1]))) + print(f.feof("apply-theme-error", "An error occurred while applying the theme:\n{message}", message=fmt(str(sys.exc_info()[1])))) + _globalvar.handle_exception() return 1 try: - shutil.copytree(_generator.path, _globalvar.clitheme_root_data_path) + shutil.copytree(final_path, _globalvar.clitheme_root_data_path, dirs_exist_ok=True) except Exception: - print(f.feof("apply-theme-error", "An error occurred while applying the theme:\n{message}", message=str(sys.exc_info()[1]))) + print(f.feof("apply-theme-error", "An error occurred while applying the theme:\n{message}", message=fmt(str(sys.exc_info()[1])))) + _globalvar.handle_exception() return 1 print(f.reof("apply-theme-success", "Theme applied successfully")) if not preserve_temp: - try: shutil.rmtree(_generator.path) - except Exception: pass + try: shutil.rmtree(final_path) + except: pass return 0 def unset_current_theme(): """ Delete the current theme data hierarchy from the data path + + (Invokes 'clitheme unset-current-theme') """ f=frontend.FetchDescriptor(subsections="cli unset-current-theme") try: shutil.rmtree(_globalvar.clitheme_root_data_path) @@ -111,7 +164,8 @@ def unset_current_theme(): print(f.reof("no-data-found", "Error: No theme data present (no theme was set)")) return 1 except Exception: - print(f.feof("remove-data-error", "An error occurred while removing the data:\n{message}", message=str(sys.exc_info()[1]))) + print(f.feof("remove-data-error", "An error occurred while removing the data:\n{message}", message=fmt(str(sys.exc_info()[1])))) + _globalvar.handle_exception() return 1 print(f.reof("remove-data-success", "Successfully removed the current theme data")) return 0 @@ -119,6 +173,8 @@ def unset_current_theme(): def get_current_theme_info(): """ Get the current theme info + + (Invokes 'clitheme get-current-theme-info') """ f=frontend.FetchDescriptor(subsections="cli get-current-theme-info") search_path=_globalvar.clitheme_root_data_path+"/"+_globalvar.generator_info_pathname @@ -136,132 +192,206 @@ def get_current_theme_info(): else: print(f.reof("overlay-history-msg", "Overlay history (sorted by latest installed):")) for theme_pathname in lsdir_result: - target_path=search_path+"/"+theme_pathname - if not os.path.isdir(target_path): continue # skip current_theme_index file + 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+"/"+"clithemeinfo_name"): - name=open(target_path+"/"+"clithemeinfo_name", 'r', encoding="utf-8").read().strip() + 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)) # version version="(Unknown)" - if os.path.isfile(target_path+"/"+"clithemeinfo_version"): - version=open(target_path+"/"+"clithemeinfo_version", 'r', encoding="utf-8").read().strip() - print(f.feof("version-str", "Version: {ver}", ver=version)) + if os.path.isfile(target_path+"/"+_globalvar.generator_info_filename.format(info="version")): + version=open(target_path+"/"+_globalvar.generator_info_filename.format(info="version"), 'r', encoding="utf-8").read().strip() + print(f.feof("version-str", "Version: {ver}", ver=fmt(version))) # description description="(Unknown)" - if os.path.isfile(target_path+"/"+"clithemeinfo_description"): - description=open(target_path+"/"+"clithemeinfo_description", 'r', encoding="utf-8").read() + if os.path.isfile(target_path+"/"+_globalvar.generator_info_filename.format(info="description")): + description=open(target_path+"/"+_globalvar.generator_info_filename.format(info="description"), 'r', encoding="utf-8").read() print(f.reof("description-str", "Description:")) - print(description) + print(re.sub(r"\n\Z", "", description)+"\x1b[0m") # remove the extra newline added by _generator # locales locales="(Unknown)" # version 2: items are separated by newlines instead of spaces - if os.path.isfile(target_path+"/"+"clithemeinfo_locales_v2"): - locales=open(target_path+"/"+"clithemeinfo_locales_v2", 'r', encoding="utf-8").read().strip() + if os.path.isfile(target_path+"/"+_globalvar.generator_info_v2filename.format(info="locales")): + locales=open(target_path+"/"+_globalvar.generator_info_v2filename.format(info="locales"), 'r', encoding="utf-8").read().strip() print(f.reof("locales-str", "Supported locales:")) for locale in locales.splitlines(): if locale.strip()!="": - print(f.feof("list-item", "• {content}", content=locale.strip())) - elif os.path.isfile(target_path+"/"+"clithemeinfo_locales"): - locales=open(target_path+"/"+"clithemeinfo_locales", 'r', encoding="utf-8").read().strip() + print(f.feof("list-item", "• {content}", content=fmt(locale.strip()))) + elif os.path.isfile(target_path+"/"+_globalvar.generator_info_filename.format(info="locales")): + locales=open(target_path+"/"+_globalvar.generator_info_filename.format(info="locales"), 'r', encoding="utf-8").read().strip() print(f.reof("locales-str", "Supported locales:")) for locale in locales.split(): - print(f.feof("list-item", "• {content}", content=locale.strip())) + print(f.feof("list-item", "• {content}", content=fmt(locale.strip()))) # supported_apps supported_apps="(Unknown)" - if os.path.isfile(target_path+"/"+"clithemeinfo_supported_apps_v2"): - supported_apps=open(target_path+"/"+"clithemeinfo_supported_apps_v2", 'r', encoding="utf-8").read().strip() + if os.path.isfile(target_path+"/"+_globalvar.generator_info_v2filename.format(info="supported_apps")): + supported_apps=open(target_path+"/"+_globalvar.generator_info_v2filename.format(info="supported_apps"), 'r', encoding="utf-8").read().strip() print(f.reof("supported-apps-str", "Supported apps:")) for app in supported_apps.splitlines(): if app.strip()!="": - print(f.feof("list-item", "• {content}", content=app.strip())) - elif os.path.isfile(target_path+"/"+"clithemeinfo_supported_apps"): - supported_apps=open(target_path+"/"+"clithemeinfo_supported_apps", 'r', encoding="utf-8").read().strip() + print(f.feof("list-item", "• {content}", content=fmt(app.strip()))) + elif os.path.isfile(target_path+"/"+_globalvar.generator_info_filename.format(info="supported_apps")): + supported_apps=open(target_path+"/"+_globalvar.generator_info_filename.format(info="supported_apps"), 'r', encoding="utf-8").read().strip() print(f.reof("supported-apps-str", "Supported apps:")) for app in supported_apps.split(): - print(f.feof("list-item", "• {content}", content=app.strip())) + print(f.feof("list-item", "• {content}", content=fmt(app.strip()))) return 0 -def is_option(arg): +def update_theme(): + """ + Re-applies theme files from file paths specified in the previous apply-theme command (including all related apply-theme commands if --overlay is used) + + (Invokes 'clitheme update-theme') + """ + class invalid_theme(Exception): pass + file_contents: list + file_paths: list + 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_num=0 + for x in lsdir_result: + if os.path.isdir(search_path+"/"+x): lsdir_num+=1 + if lsdir_num<1: raise invalid_theme("empty directory") + + # Get file paths from clithemeinfo_filepath files + file_paths=[] + for pathname in lsdir_result: + target_path=search_path+"/"+pathname + if (not os.path.isdir(target_path)) or re.search(r"^\d+$", pathname.strip())==None: continue # skip current_theme_index file + got_path: str + try: + got_path=open(target_path+"/"+_globalvar.generator_info_filename.format(info="filepath"), encoding="utf-8").readline().strip() + 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() + return 1 + except: + 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) + +def _is_option(arg): return arg.strip()[0:1]=="-" -def handle_usage_error(message, cli_args_first): +def _handle_usage_error(message, cli_args_first): f=frontend.FetchDescriptor() print(message) - print(f.feof("help-usage-prompt", "Run {clitheme} --help for usage information", clitheme=cli_args_first)) + print(f.feof("help-usage-prompt", "Run \"{clitheme} --help\" for usage information", clitheme=cli_args_first)) return 1 -def main(cli_args): +arg_first="clitheme" # controls what appears as the command name in messages +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} unset-current-theme +\t{0} update-theme +\t{0} generate-data [themedef-file] [--overlay] +\t{0} --version +\t{0} --help""".format(arg_first) + ) + 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")) + 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")) + +def _get_file_contents(file_paths: list) -> list: + fi=frontend.FetchDescriptor(subsections="cli apply-theme") + content_list=[] + for i in range(len(file_paths)): + path=file_paths[i] + try: + content_list.append(open(path, 'r', encoding="utf-8").read()) + except: + print(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 + return content_list + +def main(cli_args: list): """ - Use this function for indirect invocation of the interface (e.g. from another function) + Use this function invoke 'clitheme' with command line arguments - Provide a list of command line arguments to this function through cli_args. + Note: the first item in the argument list must be a program name (e.g. ['clitheme', ]) """ f=frontend.FetchDescriptor() - arg_first="clitheme" # controls what appears as the command name in messages if len(cli_args)<=1: # no arguments passed - print(usage_description.format(arg_first)) - print(f.reof("no-command", "Error: no command or option specified")) + _handle_help_message() + _handle_usage_error(f.reof("no-command", "Error: no command or option specified"), arg_first) return 1 + def check_enough_args(count: int, exclude_options: bool=True): + c=0 + 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)) + if cli_args[1]=="apply-theme" or cli_args[1]=="generate-data" or cli_args[1]=="generate-data-hierarchy": - if len(cli_args)<3: - return handle_usage_error(f.reof("not-enough-arguments", "Error: not enough arguments"), arg_first) + 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 _is_option(arg): if arg.strip()=="--overlay": overlay=True elif arg.strip()=="--preserve-temp" and not generate_only: preserve_temp=True - else: return handle_usage_error(f.feof("unknown-option", "Error: unknown option \"{option}\"", option=arg), arg_first) + 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") - if len(paths)>1 or True: # currently set to True for now - if generate_only: - print(fi.reof("generate-data-msg", "The theme data will be generated from the following definition files in the following order:")) - else: - print(fi.reof("apply-theme-msg", "The following definition files will be applied in the following order: ")) - for i in range(len(paths)): - path=paths[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(fi.reof("overwrite-notice", "The existing theme data will be overwritten if you continue.")) - if overlay==True: - print(fi.reof("overlay-notice", "The definition files will be appended on top of the existing theme data.")) - inpstr=fi.reof("confirm-prompt", "Do you want to continue? [y/n]") - inp=input(inpstr+" ").strip().lower() - if not (inp=="y" or inp=="yes"): - return 1 - content_list=[] - for i in range(len(paths)): - path=paths[i] - try: - content_list.append(open(path, 'r', encoding="utf-8").read()) - except Exception: - print(fi.feof("read-file-error", "[File {index}] An error occurred while reading the file: \n{message}", \ - index=str(i+1), message=str(sys.exc_info()[1]))) - return 1 - return apply_theme(content_list, overlay=overlay, preserve_temp=preserve_temp, generate_only=generate_only) + 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": - if len(cli_args)>2: # disabled additional options - return handle_usage_error(f.reof("too-many-arguments", "Error: too many arguments"), arg_first) + check_extra_args(2) # disabled additional options return get_current_theme_info() elif cli_args[1]=="unset-current-theme": - if len(cli_args)>2: - return handle_usage_error(f.reof("too-many-arguments", "Error: too many arguments"), arg_first) + 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": - print(usage_description.format(arg_first)) + check_extra_args(2) + _handle_help_message(full_help=True) else: - return handle_usage_error(f.feof("unknown-command", "Error: unknown command \"{cmd}\"", cmd=cli_args[1]), arg_first) + return _handle_usage_error(f.feof("unknown-command", "Error: unknown command \"{cmd}\"", cmd=fmt(cli_args[1])), arg_first) return 0 -def script_main(): # for script - exit(main(sys.argv)) +def _script_main(): # for script + return main(sys.argv) if __name__=="__main__": exit(main(sys.argv)) diff --git a/src/clitheme/exec/__init__.py b/src/clitheme/exec/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0da7ec36c6f1fa0f71775e24f974b47f874d0318 --- /dev/null +++ b/src/clitheme/exec/__init__.py @@ -0,0 +1,159 @@ +# 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 . + +""" +Module used for clitheme-exec + +- You can access clitheme-exec by invoking this module directly: 'python3 -m clitheme.exec' +- You can also invoke clitheme-exec in scripts using the 'main' function +""" +import sys +import os +import re +import io +import shutil +def _labeled_print(msg: str): + print("[clitheme-exec] "+msg) + +from .. import _globalvar, cli, frontend +from .._generator import db_interface + +# spell-checker:ignore lsdir showhelp argcount nosubst + +_globalvar.handle_set_themedef(frontend, "clitheme-exec") +frontend.global_domain="swiftycode" +frontend.global_appname="clitheme" +fd=frontend.FetchDescriptor(subsections="exec") + +# Prevent recursion dead loops and accurately simulate that regeneration is only triggered once +db_already_regenerated=False + +def _check_regenerate_db(dest_root_path: str=_globalvar.clitheme_root_data_path) -> bool: + global db_already_regenerated + try: + # Support environment variable flag to force db regeneration (debug purposes) + if os.environ.get("CLITHEME_REGENERATE_DB")=="1" and not db_already_regenerated: + db_already_regenerated=True + 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...")) + 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_num=0 + for x in lsdir_result: + if os.path.isdir(search_path+"/"+x): lsdir_num+=1 + if lsdir_num<1: raise Exception("empty directory") + + file_contents=[] + paths=[] + for pathname in lsdir_result: + target_path=search_path+"/"+pathname + if (not os.path.isdir(target_path)) or re.search(r"^\d+$", pathname.strip())==None: continue # skip current_theme_index file + content=open(target_path+"/file_content", encoding="utf-8").read() + file_contents.append(content) + paths.append(target_path+"/manpage_data/file_content") # small hack/workaround + 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") + 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")) + 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]))) + _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]))) + _globalvar.handle_exception() + return False + return True + +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]") + 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"+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")) + +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): + """ + Invoke clitheme-exec using the given command line arguments + + Note: the first item in the argument list must be the program name + (e.g. ['clitheme-exec', ] or ['example-app', ]) + """ + # process debug mode arguments + debug_mode=[] + argcount=0 + showhelp=False + subst=True + for arg in arguments[1:]: + if not arg.startswith('-'): break + argcount+=1 + if arg=="--debug": + debug_mode.append("normal") + elif arg=="--debug-color": + debug_mode.append("color") + elif arg=="--debug-newlines": + debug_mode.append("newlines") + elif arg=="--debug-showchars": + debug_mode.append("showchars") + elif arg=="--debug-foreground": + debug_mode.append("foreground") + elif arg=="--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 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 + # check database + if subst: + if not os.path.exists(f"{_globalvar.clitheme_root_data_path}/{_globalvar.db_filename}"): + _labeled_print(fd.reof("no-theme-warn", "Warning: no theme set or theme does not have substrules")) + else: + if not _check_regenerate_db(): return 1 + # determine platform + if os.name=="posix": + from . import output_handler_posix + return output_handler_posix.handler_main(arguments[1+argcount:], debug_mode, subst) + elif os.name=="nt": + _labeled_print("Error: Windows platform is not currently supported") + return 1 + else: + _labeled_print("Error: Unsupported platform") + return 1 + return 0 +def _script_main(): # for script + return main(sys.argv) \ No newline at end of file diff --git a/src/clitheme/exec/__main__.py b/src/clitheme/exec/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..d5057f0f4c45c0944cb9f33b62d8879cf8b39c63 --- /dev/null +++ b/src/clitheme/exec/__main__.py @@ -0,0 +1,3 @@ +from . import main +import sys +exit(main(sys.argv)) \ No newline at end of file diff --git a/src/clitheme/exec/output_handler_posix.py b/src/clitheme/exec/output_handler_posix.py new file mode 100644 index 0000000000000000000000000000000000000000..08fe8cc3d758ce6899a8db1850e668dc45557ce1 --- /dev/null +++ b/src/clitheme/exec/output_handler_posix.py @@ -0,0 +1,259 @@ +# 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 . + +""" +Main output processing handler for Unix/Linux systems (internal module) +""" + +import subprocess +import sys +import os +import io +import pty +import select +import termios +import fcntl +import signal +import struct +import copy +import re +import sqlite3 +import time +import threading +from .._generator import db_interface +from .. import _globalvar, frontend +from . import _labeled_print + +# spell-checker:ignore cbreak ICANON readsize splitarray ttyname RDWR preexec pgrp + +_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: + final_lines=[] + for x in range(len(lines)): + line=lines[x] + if "showchars" in debug_mode: + wrapper=b"\x1b[4;32m{}\x1b[0m" + if "color" in debug_mode: wrapper+=bytes(f"\x1b[{'31' if is_stderr else '33'}m", 'utf-8') + line=line.replace(b'\x1b', wrapper.replace(b'{}', b'{{ESC}}')) # this must come before anything else + line=line.replace(b'\r', wrapper.replace(b'{}',b'\\r')) + line=line.replace(b'\n', wrapper.replace(b'{}',b'\\n')+b'\n') + line=line.replace(b'\b', wrapper.replace(b'{}',b'\\x08')) + line=line.replace(b'\a', wrapper.replace(b'{}',b'\\x07')) + if "newlines" in debug_mode: + if not line.endswith(b'\n'): + line+=b"\n" + if "color" in debug_mode: + match_pattern=r"(^|\x1b\[[\d;]*?m)" + sub_pattern=f"\\g<0>\x1b[{'31' if is_stderr else '33'}m" + try: line=bytes(re.sub(match_pattern, sub_pattern, line.decode('utf-8')), 'utf-8') + 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): + 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() + + 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) + except termios.error: pass + main_pid=os.getpid() + process: subprocess.Popen + # Redirect stderr to stdout for now (BETA) + # need to find a method to preserve exact order when using separated stdout and stderr pipes + + # Since a new session is started with os.setsid() in child process: + # - Suspend and continue signals must be manually relayed to the child process + # [Not implemented yet] - Suspend signal from child process must be manually relayed to the parent process + def signal_handler(sig, frame): + if sig==signal.SIGCONT: # continue signal + process.send_signal(sig) + 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 + 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 + try: + def child_init(): + # Must start new session or some programs might not work properly + os.setsid() + + # Make controlling terminal so programs can access TTY properly + # [Explicitly open the tty to make it become a controlling tty.] + # --This code and above description are from the source code of pty.fork()-- + 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) + 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)) + 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 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 + + 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 + + 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)) + else: + output_lines.append(unfinished_output) + output_lines.append((line,is_stderr,do_subst_operation, foreground_pid)) + unfinished_output=None + output_handled=True + # 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 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 process.poll()!=None: break + + thread=threading.Thread(target=output_read_loop, daemon=True) + thread.start() + 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 + + # Process outputs + def process_line(line: bytes, line_data): + nonlocal last_tcgetpgrp + # subst operation + 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 + try: + 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 + 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) + operation() + # remove the interval timer to prevent exception when function finishes before timeout + 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: + 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 + exit_code=process.poll() + try: + if exit_code!=None and exit_code<0: # Terminated by signal + os.kill(os.getpid(), abs(exit_code)) + except: pass + return exit_code diff --git a/src/clitheme/frontend.py b/src/clitheme/frontend.py index d71d1764fa316301619365cfaaf41d120cfe8756..5255afb2c9f4cc21efa1dcd0359d13b67b0abf21 100644 --- a/src/clitheme/frontend.py +++ b/src/clitheme/frontend.py @@ -1,5 +1,15 @@ +# 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 . + """ -clitheme front-end interface for accessing entries +clitheme frontend interface for accessing entries + +- Create a FetchDescriptor instance and optionally pass information such as domain&app name and subsections +- Use the 'retrieve_entry_or_fallback' or 'reof' function in the instance to retrieve content of an entry definition +- Use the 'format_entry_or_fallback' or 'feof' function in the instance to retrieve and format content of entry definition using str.format """ import os,sys @@ -7,11 +17,12 @@ import random import string import re import hashlib +import shutil from typing import Optional -try: - from . import _globalvar -except ImportError: # for test program - import _globalvar +from . import _globalvar + +# spell-checker:ignore newhash numorig numcur + data_path=_globalvar.clitheme_root_data_path+"/"+_globalvar.generator_data_pathname global_domain="" @@ -21,8 +32,9 @@ global_debugmode=False global_lang="" # Override locale global_disablelang=False -alt_path=None -alt_path_dirname=None +_alt_path=None +_alt_path_dirname=None +_alt_path_hash=None # 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 @@ -38,35 +50,73 @@ def set_local_themedef(file_content: str, overlay: bool=False) -> bool: This function returns True if successful, otherwise returns False. """ - try: from . import _generator - except ImportError: import _generator + from . import _generator # Determine directory name h=hashlib.shake_256(bytes(file_content, "utf-8")) - global alt_path_dirname - dir_name=f"clitheme-data-{h.hexdigest(6)}" # length of 12 (6*2) - if alt_path_dirname!=None and overlay==True: # overlay - dir_name=alt_path_dirname + d=h.hexdigest(6) # length of 12 (6*2) + global _alt_path_hash + local_path_hash=_alt_path_hash + # if overlay, update hash with new contents of file + if _alt_path_hash!=None and overlay==True: + newhash="" + for x in range(len(_alt_path_hash)): + chart=string.ascii_uppercase+string.ascii_lowercase+string.digits + numorig=0 + numcur=0 + if d[x]>='A' and d[x]<='Z': #uppercase letters + numorig=ord(d[x])-ord('A') + elif d[x]>='a' and d[x]<='z': #lowercase letters + numorig=(ord(d[x])-ord('a'))+len(string.ascii_uppercase) + elif d[x]>='0' and d[x]<='9': #digit + numorig=ord(d[x])-ord('0')+len(string.ascii_uppercase+string.ascii_lowercase) + if _alt_path_hash[x]>='A' and _alt_path_hash[x]<='Z': #uppercase letters + numcur=ord(_alt_path_hash[x])-ord('A') + elif _alt_path_hash[x]>='a' and _alt_path_hash[x]<='z': #lowercase letters + numcur=(ord(_alt_path_hash[x])-ord('a'))+len(string.ascii_uppercase) + elif _alt_path_hash[x]>='0' and _alt_path_hash[x]<='9': #digit + numcur=ord(_alt_path_hash[x])-ord('0')+len(string.ascii_uppercase+string.ascii_lowercase) + newhash+=chart[(numorig+numcur)%len(chart)] + local_path_hash=newhash + 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 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) # Generate data hierarchy as needed if not os.path.exists(path_name): - _generator.path=path_name + _generator.silence_warn=True + return_val: str + d_copy=global_debugmode try: - _generator.generate_data_hierarchy(file_content, custom_path_gen=False) + # Set this to prevent extra messages from being displayed + global_debugmode=False + return_val=_generator.generate_data_hierarchy(file_content, custom_path_gen=False) except SyntaxError: if global_debugmode: print("[Debug] Generator error: "+str(sys.exc_info()[1])) return False - global alt_path - alt_path=path_name+"/"+_globalvar.generator_data_pathname - alt_path_dirname=dir_name + 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 + if not os.path.exists(path_name): + shutil.copytree(return_val, path_name) + try: shutil.rmtree(return_val) + except: pass + global _alt_path + _alt_path_hash=local_path_hash + _alt_path=path_name+"/"+_globalvar.generator_data_pathname + _alt_path_dirname=dir_name return True def unset_local_themedef(): """ - Unsets the local theme definition file for the current frontend instance. + Unset the local theme definition file for the current frontend instance. After this operation, FetchDescriptor functions will no longer use local definitions. """ - global alt_path; alt_path=None - global alt_path_dirname; alt_path_dirname=None + global _alt_path; _alt_path=None + global _alt_path_dirname; _alt_path_dirname=None + global _alt_path_hash; _alt_path_hash=None class FetchDescriptor(): """ @@ -76,10 +126,10 @@ class FetchDescriptor(): """ Create a new instance of the object. - - Provide domain_name and app_name to automatically append them for retrieval functions. + - Provide domain_name and app_name to automatically append them for retrieval functions - Provide subsections to automatically append them after domain_name+app_name - Provide lang to override the automatically detected system locale information - - Set debug_mode=True to output underlying operations when retrieving entries. + - Set debug_mode=True to output underlying operations when retrieving entries (debug purposes only) - Set disable_lang=True to disable localization detection and use "default" entry for all retrieval operations """ # Leave domain and app names blank for global reference @@ -88,16 +138,21 @@ class FetchDescriptor(): self.domain_name=global_domain.strip() 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() 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() else: self.subsections=subsections.strip() + self.subsections=re.sub(" {2,}", " ", self.subsections) if lang==None: self.lang=global_lang.strip() @@ -115,7 +170,7 @@ class FetchDescriptor(): self.disable_lang=disable_lang # sanity check the domain, app, and subsections - if _globalvar.sanity_check(self.domain_name+" "+self.app_name+" "+self.subsections)==False: + if _globalvar.sanity_check(self.domain_name+" "+self.app_name+" "+self.subsections, use_orig=True)==False: raise SyntaxError("Domain, app, or subsection names {}".format(_globalvar.sanity_check_error_message)) def retrieve_entry_or_fallback(self, entry_path: str, fallback_string: str) -> str: """ @@ -125,78 +180,48 @@ class FetchDescriptor(): # entry_path e.g. "class-a sample_text" # Sanity check the path - if _globalvar.sanity_check(entry_path)==False: - if self.debug_mode: print("[Debug] Error: entry names/subsections {}".format(_globalvar.sanity_check_error_message)) - return fallback_string - lang="" + if entry_path.strip()=="": + raise SyntaxError("Empty entry name") + if _globalvar.sanity_check(entry_path, use_orig=True)==False: + raise SyntaxError("Entry names and subsections {}".format(_globalvar.sanity_check_error_message)) + lang=[] # Language handling: see https://www.gnu.org/software/gettext/manual/gettext.html#Locale-Environment-Variables for more information if not self.disable_lang: if self.lang!="": if self.debug_mode: print("[Debug] Locale: Using defined self.lang") - if not _globalvar.sanity_check(self.lang)==False: - lang=self.lang + if not _globalvar.sanity_check(self.lang, use_orig=True)==False: + lang=[self.lang] else: if self.debug_mode: print("[Debug] Locale: sanity check failed ({})".format(_globalvar.sanity_check_error_message)) else: if self.debug_mode: print("[Debug] Locale: Using environment variables") - # $LANGUAGE (list of languages separated by colons) - if os.environ.__contains__("LANGUAGE"): - target_str=os.environ['LANGUAGE'] - for each_language in target_str.strip().split(":"): - # avoid exploit of accessing top-level folders - if _globalvar.sanity_check(each_language)==False: continue - # Ignore en and en_US (See https://wiki.archlinux.org/title/Locale#LANGUAGE:_fallback_locales) - if each_language!="en" and each_language!="en_US": - # Treat C as en_US also - if re.sub(r"(?P.+)[\.].+", r"\g", each_language)=="C": - lang+=re.sub(r".+[\.]", "en_US.", each_language)+" " - lang+="en_US"+" " - lang+=each_language+" " - # no encoding - lang+=re.sub(r"(?P.+)[\.].+", r"\g", each_language)+" " - lang=lang.strip() - # $LC_ALL - elif os.environ.__contains__("LC_ALL"): - target_str=os.environ["LC_ALL"].strip() - if not _globalvar.sanity_check(target_str)==False: - lang=target_str+" " - lang+=re.sub(r"(?P.+)[\.].+", r"\g", target_str) - else: - if self.debug_mode: print("[Debug] Locale: sanity check failed ({})".format(_globalvar.sanity_check_error_message)) - # $LANG - elif os.environ.__contains__("LANG"): - target_str=os.environ["LANG"].strip() - if not _globalvar.sanity_check(target_str)==False: - lang=target_str+" " - lang+=re.sub(r"(?P.+)[\.].+", r"\g", target_str) - else: - if self.debug_mode: print("[Debug] Locale: sanity check failed ({})".format(_globalvar.sanity_check_error_message)) + lang=_globalvar.get_locale(debug_mode=self.debug_mode) if self.debug_mode: print(f"[Debug] lang: {lang}\n[Debug] entry_path: {entry_path}") # just being lazy here I don't want to check the variables before using ಥ_ಥ (because it doesn't matter) - path=data_path+"/"+self.domain_name+"/"+self.app_name+"/"+self.subsections + path=data_path+"/"+self.domain_name+"/"+self.app_name+"/"+re.sub(" ",r"/", self.subsections) path2=None - if alt_path!=None: path2=alt_path+"/"+self.domain_name+"/"+self.app_name+"/"+self.subsections + if _alt_path!=None: path2=_alt_path+"/"+self.domain_name+"/"+self.app_name+"/"+re.sub(" ",r"/", self.subsections) for section in entry_path.split(): path+="/"+section if path2!=None: path2+="/"+section # path with lang, path with lang but without e.g. .UTF-8, path with no lang possible_paths=[] - for l in lang.split(): + for l in lang: possible_paths.append(path+"__"+l) possible_paths.append(path) if path2!=None: - for l in lang.split(): + for l in lang: possible_paths.append(path2+"__"+l) possible_paths.append(path2) for p in possible_paths: - if self.debug_mode: print("Trying "+p, end="...") + if self.debug_mode: print("Trying "+p, end=" ...") try: f=open(p,'r', encoding="utf-8") - dat=f.read() - if self.debug_mode: print("Success:\n> "+dat) # since the generator adds an extra newline in the entry data, we need to remove it - return re.sub(r"\n\Z", "", dat) + dat=re.sub(r"\n\Z", "", f.read()) + if self.debug_mode: print("Success:\n> "+dat) + return dat except (FileNotFoundError, IsADirectoryError): if self.debug_mode: print("Failed") return fallback_string @@ -205,7 +230,7 @@ class FetchDescriptor(): def format_entry_or_fallback(self, entry_path: str, fallback_string: str, *args, **kwargs) -> str: """ - Attempt to retrieve and format the entry based on given entry path and arguments. + Attempt to retrieve and format the entry using str.format based on given entry path and arguments. If the entry does not exist or an error occurs while formatting the entry string, use the provided fallback string instead. """ # retrieve the entry @@ -230,6 +255,6 @@ class FetchDescriptor(): fallback_string="" for x in range(30): fallback_string+=random.choice(string.ascii_letters) - recieved_content=self.retrieve_entry_or_fallback(entry_path, fallback_string) - if recieved_content.strip()==fallback_string: return False + received_content=self.retrieve_entry_or_fallback(entry_path, fallback_string) + if received_content.strip()==fallback_string: return False else: return True diff --git a/src/clitheme/man.py b/src/clitheme/man.py new file mode 100644 index 0000000000000000000000000000000000000000..2a198927992e1c25ab2ab16f78649731f0ca411f --- /dev/null +++ b/src/clitheme/man.py @@ -0,0 +1,76 @@ +# 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 . + +""" +Module used for clitheme-man + +- You can access clitheme-man by invoking this module directly: 'python3 -m clitheme.man' +- You can also invoke clitheme-man in scripts using the 'main' function +""" +import sys +import os +import subprocess +import shutil +import signal +import time +from . import _globalvar, frontend +def _labeled_print(msg: str): + print("[clitheme-man] "+msg) + +_globalvar.handle_set_themedef(frontend, "clitheme-man") +frontend.global_domain="swiftycode" +frontend.global_appname="clitheme" +fd=frontend.FetchDescriptor(subsections="man") + +def main(args: list): + """ + Invoke clitheme-man using the given command line arguments + + Note: the first item in the argument list must be the program name + (e.g. ['clitheme-man', ] or ['example-app', ]) + """ + if os.name=="nt": + _labeled_print(fd.reof("win32-not-supported", "Error: Windows platform not supported")) + return 1 + # check if "man" exists on system + man_executable: str=shutil.which("man") # type: ignore + if man_executable==None: + _labeled_print(fd.reof("man-not-installed", "Error: \"man\" is not installed on this system")) + return 1 + env=os.environ + prev_manpath=env.get('MANPATH') + # check if theme is set + theme_set=True + if not os.path.exists(f"{_globalvar.clitheme_root_data_path}/{_globalvar.generator_manpage_pathname}"): + _labeled_print(fd.reof("no-theme-warn", "Warning: no theme set or theme does not contain manpages")) + theme_set=False + # set MANPATH + if theme_set: env['MANPATH']=_globalvar.clitheme_root_data_path+"/"+_globalvar.generator_manpage_pathname + # Only try "man" with fallback settings if content arguments are specified + for x in range(1,len(args)): + arg=args[x] + # Specified '--' and contains following content arguments + if arg=='--' and len(args)>x+1: break + if not arg.startswith('-'): break + else: theme_set=False + # invoke man + def run_process(env) -> int: + process=subprocess.Popen([man_executable]+args[1:], env=env) + while process.poll()==None: + try: time.sleep(0.001) + except KeyboardInterrupt: process.send_signal(signal.SIGINT) + return process.poll() # type: ignore + returncode=run_process(env) + 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 + +def _script_main(): # for script + return main(sys.argv) +if __name__=="__main__": + exit(main(sys.argv)) \ No newline at end of file diff --git a/src/clitheme/strings/cli-strings.clithemedef.txt b/src/clitheme/strings/cli-strings.clithemedef.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b91fa57cad0854aa4ab131a9116a5418faabb3f --- /dev/null +++ b/src/clitheme/strings/cli-strings.clithemedef.txt @@ -0,0 +1,266 @@ +# 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 . + +{header_section} + name clitheme message text translations (cli) + version 2.0 + locales zh_CN + supported_apps clitheme +{/header_section} + +{entries_section} +in_domainapp swiftycode clitheme + in_subsection cli + [entry] no-command + # locale:default Error: no command or option specified + locale:zh_CN 错误:没有提供指令或选项 + [/entry] + [entry] not-enough-arguments + # locale:default Error: not enough arguments + locale:zh_CN 错误:参数不够 + [/entry] + [entry] unknown-option + # locale:default Error: unknown option "{option}" + locale:zh_CN 错误:未知选项"{option}" + [/entry] + [entry] unknown-command + # locale:default Error: unknown command "{cmd}" + locale:zh_CN 错误:未知指令"{cmd}" + [/entry] + [entry] too-many-arguments + # locale:default Error: too many arguments + locale:zh_CN 错误:参数太多 + [/entry] + [entry] help-usage-prompt + # locale:default Run "{clitheme} --help" for usage information + locale:zh_CN 使用"{clitheme} --help"以获取使用方法 + [/entry] + [entry] version-str + # locale:default clitheme version {ver} + locale:zh_CN clitheme 版本:{ver} + [/entry] + # apply-theme 和 generate-data 指令 + # apply-theme and generate-data commands + in_subsection cli apply-theme + [entry] generate-data-msg + # locale:default The theme data will be generated from the following definition files in the following order: + locale:zh_CN 主题定义数据将会从以下顺序的主题定义文件生成: + [/entry] + [entry] apply-theme-msg + # locale:default The following definition files will be applied in the following order: + locale:zh_CN 这些主题定义文件将会通过以下顺序被应用: + [/entry] + [entry] overwrite-notice + # locale:default The existing theme data will be overwritten if you continue. + locale:zh_CN 如果继续,当前的主题数据将会被覆盖。 + [/entry] + [entry] overlay-notice + # locale:default The definition files will be appended on top of the existing theme data. + locale:zh_CN 这些主题定义文件会被叠加在当前的数据上。 + [/entry] + [entry] confirm-prompt + # locale:default Do you want to continue? [y/n] + locale:zh_CN 是否继续操作?[y/n] + [/entry] + [entry] overlay-msg + # locale:default Overlay specified + locale:zh_CN 已使用数据叠加模式 + [/entry] + [entry] generating-data + # locale:default ==> Generating data... + locale:zh_CN ==> 正在生成数据... + [/entry] + [entry] processing-file + # locale:default > Processing file {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] + [entry] view-temp-dir + # locale:default View at {path} + locale:zh_CN 生成的数据可以在"{path}"查看 + [/entry] + [entry] applying-theme + # locale:default ==> Applying theme... + locale:zh_CN ==> 正在应用主题... + [/entry] + [entry] apply-theme-success + # locale:default Theme applied successfully + locale:zh_CN 已成功应用主题 + [/entry] + [entry] read-file-error + # [locale] default + # [File {index}] An error occurred while reading the file: + # {message} + # [/locale] + [locale] zh_CN + [文件{index}] 读取文件时出现了错误: + {message} + [/locale] + [/entry] + [entry] overlay-no-data + # [locale] default + # Error: no theme set or the current data is corrupt + # Try setting a theme first + # [/locale] + [locale] zh_CN + 错误:当前没有设定主题或当前数据损坏 + 请尝试设定主题 + [/locale] + [/entry] + [entry] overlay-data-error + # [locale] default + # Error: the current data is corrupt + # Remove the current theme, set the theme, and try again + # [/locale] + [locale] zh_CN + 错误:当前主题数据损坏 + 请移除当前数据和重新设定主题后重试 + [/locale] + [/entry] + [entry] generate-data-error + # [locale] default + # [File {index}] An error occurred while generating the data: + # {message} + # [/locale] + [locale] zh_CN + [文件{index}] 生成数据时发生了错误: + {message} + [/locale] + [/entry] + # unset-current-theme 指令 + # unset-current-theme command + in_subsection cli unset-current-theme + [entry] no-data-found + # locale:default Error: No theme data present (no theme was set) + locale:zh_CN 错误:当前没有设定主题 + [/entry] + [entry] remove-data-error + # [locale] default + # An error occurred while removing the data: + # {message} + # [/locale] + [locale] zh_CN + 移除当前数据时发生了错误: + {message} + [/locale] + [/entry] + [entry] remove-data-success + # locale:default Successfully removed the current theme data + locale:zh_CN 已成功移除当前主题数据 + [/entry] + # get-current-theme-info 指令 + # get-current-theme-info command + in_subsection cli get-current-theme-info + [entry] no-theme + # locale:default No theme currently set + locale:zh_CN 当前没有设定任何主题 + [/entry] + [entry] current-theme-msg + # locale:default Currently installed theme: + 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} + [/entry] + [entry] description-str + # locale:default Description: + locale:zh_CN 详细说明: + [/entry] + [entry] locales-str + # locale:default Supported locales: + locale:zh_CN 支持的语言: + [/entry] + [entry] supported-apps-str + # locale:default Supported apps: + locale:zh_CN 支持的应用程序: + [/entry] + # update-theme 指令 + # update-theme command + in_subsection cli update-theme + [entry] no-theme-err + # locale:default Error: no theme currently set + locale:zh_CN 错误:当前没有设定主题 + [/entry] + [entry] not-available-err + # [locale] default + # update-theme cannot be used with the current theme setting + # Please re-apply the current theme and try again + # [/locale] + [locale] zh_CN + update-theme在当前主题设定上无法使用 + 请重新应用当前主题后重试 + [/locale] + [/entry] + [entry] other-err + # [locale] default + # An error occurred while processing file path information: {msg} + # Please re-apply the current theme and try again + # [/locale] + [locale] zh_CN + 处理文件路径信息时发生错误:{msg} + 请重新应用当前主题后重试 + [/locale] + [/entry] + # 用于clitheme --help的字符串定义 + # String entries used for "clitheme --help" + in_subsection cli help-message + [entry] usage-str + # locale:default Usage: + locale:zh_CN 使用方式: + [/entry] + [entry] options-str + # locale:default Options: + locale:zh_CN 选项: + [/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) + # [/locale] + [locale] zh_CN + 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:输出当前主题设定的详细信息 + [/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: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:zh_CN generate-data:【仅供调试用途】对于指定的主题定义文件在临时目录中生成一个数据结构 + [/entry] + [entry] options-version + # locale:default --version: Outputs 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:输出这个帮助提示 + [/entry] +{/entries_section} diff --git a/src/clitheme/strings/exec-strings.clithemedef.txt b/src/clitheme/strings/exec-strings.clithemedef.txt new file mode 100644 index 0000000000000000000000000000000000000000..cff2092c39f80884845b5ffe01c48bc988d44358 --- /dev/null +++ b/src/clitheme/strings/exec-strings.clithemedef.txt @@ -0,0 +1,100 @@ +# 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 . + +# spell-checker:ignore nosubst + +{header_section} + name clitheme message text translations (clitheme-exec) + version 2.0 + locales zh_CN + supported_apps clitheme +{/header_section} + +{entries_section} +in_domainapp swiftycode clitheme + # 用于clitheme-exec --help的字符串定义 + # String entries used for "clitheme-exec --help" + in_subsection exec help-message + [entry] usage-str + # locale:default Usage: + locale:zh_CN 使用方式: + [/entry] + [entry] options-str + # locale:default Options: + locale:zh_CN 选项: + [/entry] + [entry] options-debug + # locale:default --debug: Display indicator at the beginning of each read output by line + locale:zh_CN --debug:在每一行被读取的输出前显示标记 + [/entry] + [entry] options-debug-color + # locale:default --debug-color: Apply color on output; used to determine stdout or stderr (BETA: stdout/stderr not implemented) + locale:zh_CN --debug-color:为输出设定颜色;用于区分stdout和stderr(BETA:stdout/stderr未实现) + [/entry] + [entry] options-debug-newlines + # 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] + [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] + [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] + in_subsection exec + [entry] help-usage-prompt + # locale:default Run "clitheme-exec --help" for usage information + locale:zh_CN 使用"clitheme-exec --help"以获取使用方式 + [/entry] + [entry] unknown-option-err + # locale:default Error: unknown option "{phrase}" + locale:zh_CN 错误:未知选项"{phrase}" + [/entry] + [entry] no-command-err + # locale:default Error: no command specified + locale:zh_CN 错误:未指定命令 + [/entry] + [entry] no-theme-warn + # 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] + [entry] db-migration-generator-err + # locale:default Failed to generate data (full log below): + locale:zh_CN 无法生成数据(完整日志在此): + [/entry] + [entry] db-migration-err + # [locale] default + # An error occurred while migrating the database: {msg} + # Please re-apply the theme and try again + # [/locale] + [locale] zh_CN + 迁移数据库时发生了错误:{msg} + 请重新应用当前主题,然后重试 + [/locale] + [/entry] + [entry] db-migrate-success-msg + # locale:default Successfully completed migration, proceeding execution + locale:zh_CN 数据库迁移完成,继续执行命令 + [/entry] + [entry] command-fail-err + # locale:default Error: failed to run command: {msg} + locale:zh_CN 错误:无法执行命令:{msg} + [/entry] + [entry] internal-error-err + # locale:default Error: an internal error has occurred while executing the command (execution halted): + 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 new file mode 100644 index 0000000000000000000000000000000000000000..bea4f0a4142d81717b0b20864b58debca66f439b --- /dev/null +++ b/src/clitheme/strings/generator-strings.clithemedef.txt @@ -0,0 +1,173 @@ +# 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 . + +# spell-checker:ignore subdir banphrase startswith +{header_section} + name clitheme message text translations (generator) + version 2.0 + locales zh_CN + supported_apps clitheme +{/header_section} + +{entries_section} +in_domainapp swiftycode clitheme + in_subsection generator + # 错误提示 + # error messages + [entry] error-str + # locale:default Syntax error: {msg} + locale:zh_CN 语法错误:{msg} + [/entry] + + # 以下定义会是上方定义"{msg}"中的内容 + # The following definitions are the contents of "{msg}" in the above definition + [entry] subsection-conflict-err + # locale:default Line {num}: cannot create subsection "{name}" because an entry with the same name already exists + locale:zh_CN 第{num}行:无法创建子路径"{name}",因为拥有相同名称的定义已存在 + [/entry] + [entry] entry-conflict-err + # locale:default Line {num}: cannot create entry "{name}" because a subsection with the same name already exists + locale:zh_CN 第{num}行:无法创建定义"{name}",因为拥有相同名称的子路径已存在 + [/entry] + [entry] extra-arguments-err + # locale:default Extra arguments after "{phrase}" on line {num} + locale:zh_CN 第{num}行:"{phrase}"后的参数太多 + [/entry] + [entry] repeated-section-err + # locale:default Repeated {section} section at line {num} + locale:zh_CN 第{num}行:重复的{phrase}段落 + [/entry] + [entry] invalid-phrase-err + # locale:default Unexpected "{phrase}" on line {num} + locale:zh_CN 第{num}行:无效的"{phrase}"语句 + [/entry] + [entry] not-enough-args-err + # locale:default Not enough arguments for "{phrase}" at line {num} + locale:zh_CN 第{num}行:"{phrase}"后参数不够 + [/entry] + [entry] incomplete-section-err + # locale:default Missing or incomplete header or content sections + locale:zh_CN 文件缺少或包含不完整的header或内容段落 + [/entry] + [entry] db-regenerate-fail-err + # locale:default Failed to migrate existing substrules database; try performing the operation without using "--overlay" + locale:zh_CN 无法升级当前的substrules的数据库;请尝试不使用"--overlay"再次执行此操作 + [/entry] + # 选项错误提示信息 + # Options error messages + [entry] option-not-allowed-err + # locale:default Option "{phrase}" not allowed here at line {num} + locale:zh_CN 第{num}行:选项"{phrase}"不允许在这里指定 + [/entry] + [entry] unknown-option-err + # locale:default Unknown option "{phrase}" on line {num} + locale:zh_CN 第{num}行:未知选项"{phrase}" + [/entry] + [entry] option-without-value-err + # locale:default No value specified for option "{phrase}" on line {num} + locale:zh_CN 第{num}行:选项"{phrase}"未指定数值 + [/entry] + [entry] optional-value-not-int-err + # locale:default The value specified for option "{phrase}" is not an integer on line {num} + locale:zh_CN 第{num}行:选项"{phrase}"指定的数值不是整数 + [/entry] + [entry] option-conflict-err + # locale:default The option "{option1}" can't be set at the same time with "{option2}" on line {num} + locale:zh_CN 第{num}行:选项"{option1}"和"{option2}"不能同时指定 + [/entry] + [entry] bad-match-pattern-err + # locale:default Bad match pattern at line {num} ({error_msg}) + locale:zh_CN 第{num}行:无效的匹配正则表达式({error_msg}) + [/entry] + [entry] bad-subst-pattern-err + # locale:default Bad substitute pattern at line {num} ({error_msg}) + locale:zh_CN 第{num}行:无效的替换正则表达式({error_msg}) + [/entry] + [entry] bad-var-name-err + # locale:default Line {num}: "{name}" is not a valid variable name + locale:zh_CN 第{num}行:"{name}"不是一个有效的变量名称 + [/entry] + [entry] manpage-subdir-file-conflict-err + # locale:default Line {num}: conflicting files and subdirectories; please check previous definitions + locale:zh_CN 第{num}行:子路径和文件有冲突;请检查之前的定义 + [/entry] + [entry] include-file-read-err + # [locale] default + # Line {num}: unable to read file "{filepath}": + # {error_msg} + # [/locale] + [locale] zh_CN + 第{num}行:无法读取文件"{filepath}": + {error_msg} + [/locale] + [/entry] + [entry] include-file-missing-phrase-err + # locale:default Missing "as " phrase on next line of line {num} + locale:zh_CN 第{num}行:在下一行缺少"as <文件名>"语句 + [/entry] + # 警告提示 + # Warning messages + [entry] warning-str + # locale:default Warning: {msg} + locale:zh_CN 警告:{msg} + [/entry] + + # 以下定义会是上方定义"{msg}"中的内容 + # The following definitions are the contents of "{msg}" in the above definition + [entry] repeated-entry-warn + # locale:default Line {num}: repeated entry "{name}", overwriting + locale:zh_CN 第{num}行:重复的定义"{name}";之前的定义内容将会被覆盖 + [/entry] + [entry] repeated-substrules-warn + # locale:default Repeated substrules entry at line {num}, overwriting + locale:zh_CN 第{num}行:重复的substrules定义;之前的定义内容将会被覆盖 + [/entry] + [entry] repeated-header-warn + # locale:default Line {num}: repeated header info "{name}", overwriting + locale:zh_CN 第{num}行:重复的header信息"{name}";之前的定义内容将会被覆盖 + [/entry] + [entry] syntax-phrase-deprecation-warn + # locale:default Line {num}: phrase "{old_phrase}" is deprecated in this version; please use "{new_phrase}" instead + locale:zh_CN 第{num}行:"{old_phrase}"在当前版本中已被弃用;请使用"{new_phrase}" + [/entry] + [entry] unknown-variable-warn + # locale:default Line {num}: unknown variable "{name}", not performing substitution + locale:zh_CN 第{num}行:未知变量名称"{name}",不会进行替换 + [/entry] + [entry] repeated-manpage-warn + # locale:default Line {num}: repeated manpage file, overwriting + locale:zh_CN 第{num}行:重复的manpage文件;之前的文件内容将会被覆盖 + [/entry] + # 路径检查功能提示 + # Sanity check feature messages + [entry] sanity-check-entry-err + # locale:default Line {num}: entry subsections/names {sanitycheck_msg} + locale:zh_CN 第{num}行:定义路径名称{sanitycheck_msg} + [/entry] + [entry] sanity-check-domainapp-err + # locale:default Line {num}: domain and app names {sanitycheck_msg} + locale:zh_CN 第{num}行:开发者和应用程序名称{sanitycheck_msg} + [/entry] + [entry] sanity-check-subsection-err + # locale:default Line {num}: subsection names {sanitycheck_msg} + locale:zh_CN 第{num}行:子路径名称{sanitycheck_msg} + [/entry] + [entry] sanity-check-manpage-err + # locale:default Line {num}: manpage paths {sanitycheck_msg}; use spaces to denote subdirectories + locale:zh_CN 第{num}行:manpage路径{sanitycheck_msg};使用空格以指定子路径 + [/entry] + # 以下提示会是上方定义中"{sanitycheck_msg}"的内容 + # The following messages are the contents of "{sanitycheck_msg}" in the above entries + [entry] sanity-check-msg-banphrase-err + # locale:default cannot contain '{char}' + locale:zh_CN 不能包含'{char}' + [/entry] + [entry] sanity-check-msg-startswith-err + # locale:default cannot start with '{char}' + locale:zh_CN 不能以'{char}'开头 + [/entry] +{/entries_section} + \ No newline at end of file diff --git a/src/clitheme/strings/man-strings.clithemedef.txt b/src/clitheme/strings/man-strings.clithemedef.txt new file mode 100644 index 0000000000000000000000000000000000000000..7d447ff3cbaddfd4ff782054a6b104de506fa7df --- /dev/null +++ b/src/clitheme/strings/man-strings.clithemedef.txt @@ -0,0 +1,33 @@ +# 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 . + +{header_section} + name clitheme message text translations (man) + version 2.0 + locales zh_CN + supported_apps clitheme +{/header_section} + +{entries_section} +in_domainapp swiftycode clitheme + in_subsection man + [entry] man-not-installed + # locale:default Error: "man" is not installed on this system + locale:zh_CN 错误:"man"未安装在此系统中 + [/entry] + [entry] no-theme-warn + # locale:default Warning: no theme set or theme does not contain manpages + locale:zh_CN 警告:没有设定主题或当前主题没有manpages定义 + [/entry] + [entry] win32-not-supported + # locale:default Error: Windows platform not supported + locale:zh_CN 错误:不支持Windows平台 + [/entry] + [entry] prev-command-fail + locale:zh_CN 设定自定义路径执行"man"时发生错误,正在尝试以正常设定执行 + [/entry] +{/entries_section} + diff --git a/clithemedef-test_testprogram.py b/src/clithemedef-test_testprogram.py similarity index 65% rename from clithemedef-test_testprogram.py rename to src/clithemedef-test_testprogram.py index fb03f2aa17d4b7fd123dff514902dbd9f7944001..4271d75f0284b111f1610f6befde666d2e3f84da 100644 --- a/clithemedef-test_testprogram.py +++ b/src/clithemedef-test_testprogram.py @@ -1,16 +1,24 @@ import shutil -from src.clitheme import _generator -from src.clitheme import _globalvar +from clitheme import _generator +from clitheme import _globalvar import random import string +import os +# spell-checker:ignore rootpath errorcount mainfile + +l=__file__.split(os.sep) +l.pop() +root_directory="" # directory where the script files are in +for part in l: + root_directory+=part+os.sep print("Testing generator function...") -mainfile_data=open("tests/clithemedef-test_mainfile.clithemedef.txt",'r', encoding="utf-8").read() -expected_data=open("tests/clithemedef-test_expected.txt",'r', encoding="utf-8").read() -funcresult=_generator.generate_data_hierarchy(mainfile_data) +mainfile_data=open(root_directory+"/testprogram-data/clithemedef-test_mainfile.clithemedef.txt",'r', encoding="utf-8").read() +expected_data=open(root_directory+"/testprogram-data/clithemedef-test_expected.txt",'r', encoding="utf-8").read() +generator_path=_generator.generate_data_hierarchy(mainfile_data) errorcount=0 -rootpath=_generator.path+"/"+_globalvar.generator_data_pathname +rootpath=generator_path+"/"+_globalvar.generator_data_pathname current_path="" for line in expected_data.splitlines(): if line.strip()=='' or line.strip()[0]=='#': @@ -26,6 +34,7 @@ for line in expected_data.splitlines(): except FileNotFoundError: print("[File] file "+rootpath+"/"+current_path+" does not exist") errorcount+=1 + current_path="" if contents=="": continue if contents.strip()!=line.strip(): print("[Content] Content mismatch on file "+rootpath+"/"+current_path) @@ -34,11 +43,11 @@ for line in expected_data.splitlines(): # Test frontend print("Testing frontend...") -from src.clitheme import frontend +from clitheme import frontend frontend.global_lang="en_US.UTF-8" frontend.global_debugmode=True -frontend.data_path=_generator.path+"/"+_globalvar.generator_data_pathname -expected_data_frontend=open("tests/clithemedef-test_expected-frontend.txt", 'r', encoding="utf-8").read() +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="" errorcount_frontend=0 for line in expected_data_frontend.splitlines(): @@ -52,7 +61,7 @@ for line in expected_data_frontend.splitlines(): entry_path=None if len(phrases)>2: descriptor=frontend.FetchDescriptor(domain_name=phrases[0],app_name=phrases[1]) - entry_path=_generator.splitarray_to_string(phrases[2:]) # just being lazy here + entry_path=_globalvar.splitarray_to_string(phrases[2:]) # just being lazy here else: descriptor=frontend.FetchDescriptor() entry_path=current_path_frontend @@ -60,9 +69,9 @@ for line in expected_data_frontend.splitlines(): fallback_string="" for x in range(30): # reduce inaccuracies fallback_string+=random.choice(string.ascii_letters) - recieved_content=descriptor.retrieve_entry_or_fallback(entry_path, fallback_string) - if expected_content.strip()!=recieved_content.strip(): - if recieved_content.strip()==fallback_string: + received_content=descriptor.retrieve_entry_or_fallback(entry_path, fallback_string) + if expected_content.strip()!=received_content.strip(): + if received_content.strip()==fallback_string: print("[Error] Failed to retrieve entry for \""+current_path_frontend+"\"") else: print("[Content] Content mismatch on path \""+current_path_frontend+"\"") @@ -72,11 +81,11 @@ print("\n\nTest results:") print("==> ",end='') if errorcount>0: print("Generator test error: "+str(errorcount)+" errors found") - print("See "+_generator.path+" for more details") + print("See "+generator_path+" for more details") exit(1) else: print("Generator test OK") - shutil.rmtree(_generator.path) # remove the temp directory + shutil.rmtree(generator_path) # remove the temp directory print("==> ",end='') if errorcount_frontend>0: print("Frontend test error: "+str(errorcount_frontend)+" errors found") diff --git a/src/db_interface_tests.py b/src/db_interface_tests.py new file mode 100644 index 0000000000000000000000000000000000000000..abdf9894cb947bfe4f94b3a3c73c09edfcb72a3c --- /dev/null +++ b/src/db_interface_tests.py @@ -0,0 +1,147 @@ +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"), + ("type rm --help for more information", "rm"), + ("rm: /etc/folder: Permission denied", "rm /etc/folder -rf"), + ("rm: /etc/file: Permission denied", "rm /etc/folder"), # test multiple phrase detection (substitution should not happen) + ("cat: /dev/mem: Permission denied","cat /dev/mem"), + ("bash: /etc/secret: Permission denied","cd /etc/secret"), + ("ls: /etc/secret: Permission denied","ls /etc/secret"), + ("ls: /etc/secret: Permission denied","wef ls /etc/secret"), # test first phrase detection (substitution should not happen) + ("ls: unrecognized option '--help'", "ls --help"), + ("Warning: invalid input", "input anything"), + ("Error: invalid input ","input anything"), # test extra spaces + ("Error: sample message", "example_app --this install-stuff"), # test strictcmdmatch (substitution should not happen) + ("Error: sample message", "example_app install-stuff --this"), # test strictcmdmatch and endmatchhere options + ("Error: sample message", "example_app install-stuff"), # test strictcmdmatch with SAME command as defined in filter + ("rm: : Operation not permitted", "rm file.ban"), # test exactcmdmatch + ("example_app: using recursive directories", "example_app -rlc"), # test smartcmdmatch + ("example_app: using list options", "/usr/bin/example_app -rlc"), # test smartcmdmatch and command basename handling +] +expected_outputs=[ + ("rm says: missing arguments and options (>﹏<)", "rm 说:缺少参数和选项 (>﹏<)"), + ("For more information, use rm --help (。ì _ í。)", "关于更多信息,请使用rm --help (。ì _ í。)"), + ("rm says: Access denied to /etc/folder! ಥ_ಥ", "rm 说:文件\"/etc/folder\"拒绝访问!ಥ_ಥ"), + ("rm: /etc/file: Permission denied",), + ("cat says: Access denied to /dev/mem! ಥ_ಥ", "cat 说:文件\"/dev/mem\"拒绝访问!ಥ_ಥ"), + ("bash says: Access denied to /etc/secret! ಥ_ಥ", "bash 说:文件\"/etc/secret\"拒绝访问!ಥ_ಥ"), + ("ls says: Access denied to /etc/secret! ಥ_ಥ", "ls 说:文件\"/etc/secret\"拒绝访问!ಥ_ಥ"), + ("ls: /etc/secret: Permission denied",), + ("ls says: option \"--help\" not known! (ToT)/~~~", "ls 说:未知选项\"--help\"!(ToT)/~~~"), + ("o(≧v≦)o Note: input is invalid! ಥ_ಥ", "o(≧v≦)o 提示: 无效输入!ಥ_ಥ"), + ("(ToT)/~~~ Error: input is invalid! ಥ_ಥ", "(ToT)/~~~ 错误:无效输入!ಥ_ಥ"), + ("(ToT)/~~~ Error: sample message", "(ToT)/~~~ 错误:sample message"), + ("Error: sample message! (>﹏<)", "错误:样例提示!(>﹏<)"), + ("Error: sample message! (>﹏<)", "错误:样例提示!(>﹏<)"), + ("rm says: Operation not permitted! ಥ_ಥ", "rm 说:不允许的操作!ಥ_ಥ"), + ("o(≧v≦)o example_app says: using recursive directories! (。ì _ í。)", "o(≧v≦)o example_app 说: 正在使用子路径!(。ì _ í。)"), + ("o(≧v≦)o example_app says: using list options! (⊙ω⊙)", "o(≧v≦)o example_app 说: 正在使用列表选项!(⊙ω⊙)"), +] +# substitute patterns +substrules_file=r""" +{header_section} + name test +{/header_section} +{substrules_section} + filter_command rm + [substitute_string] rm: missing operand + locale:default rm says: missing arguments and options (>﹏<) + locale:zh_CN rm 说:缺少参数和选项 (>﹏<) + [/substitute_string] + [substitute_string] type rm --help for more information + locale:default For more information, use rm --help (。ì _ í。) + locale:zh_CN 关于更多信息,请使用rm --help (。ì _ í。) + [/substitute_string] + + [filter_commands] + rm -rf + cat + cd + ls + [/filter_commands] + [substitute_regex] (?P.+): (?P.+): Permission denied + locale:default \g says: Access denied to \g! ಥ_ಥ + locale:zh_CN \g 说:文件"\g"拒绝访问!ಥ_ಥ + [/substitute_regex] + + filter_command ls + # testing repeated entry detection + [substitute_regex] (?P.+): unrecognized option '(?P.+)' + locale:default wef + [/substitute_regex] + [substitute_regex] (?P.+): unrecognized option '(?P.+)' + locale:default \g says: option "\g" not known! (ToT)/~~~ + locale:zh_CN \g 说:未知选项"\g"!(ToT)/~~~ + [/substitute_regex] + unset_filter_command + + # global substitutions + [substitute_regex] ^Warning:( ) + locale:default o(≧v≦)o Note:\g<1> + locale:zh_CN o(≧v≦)o 提示:\g<1> + [/substitute_regex] + [substitute_regex] ^Error:( ) + locale:default (ToT)/~~~ Error:\g<1> + locale:zh_CN (ToT)/~~~ 错误: + [/substitute_regex] + [substitute_regex] invalid input( )*$ + locale:default input is invalid! ಥ_ಥ + locale:zh_CN 无效输入!ಥ_ಥ + [/substitute_regex] + + set_options strictcmdmatch + filter_command example_app install-stuff + [substitute_string] Error: sample message + locale:default Error: sample message! (>﹏<) + locale:zh_CN 错误:样例提示!(>﹏<) + [/substitute_string] endmatchhere + + set_options exactcmdmatch + filter_command rm file.ban + [substitute_regex] (?P.+): (?P.+): Operation not permitted + locale:default \g says: Operation not permitted! ಥ_ಥ + locale:zh_CN \g 说:不允许的操作!ಥ_ಥ + [/substitute_regex] + + set_options normalcmdmatch + filter_command example_app + [substitute_string] example_app: + locale:default o(≧v≦)o example_app says: + locale:zh_CN o(≧v≦)o example_app 说: + [/substitute_string] + set_options smartcmdmatch + filter_command example_app -r + [substitute_string] using recursive directories + locale:default using recursive directories! (。ì _ í。) + locale:zh_CN 正在使用子路径!(。ì _ í。) + [/substitute_string] + filter_command example_app -l + [substitute_string] using list options + locale:default using list options! (⊙ω⊙) + locale:zh_CN 正在使用列表选项!(⊙ω⊙) + [/substitute_string] + set_options normalcmdmatch +{/substrules_section} +""" + +db_interface.debug_mode=True +generator_path=_generator.generate_data_hierarchy(substrules_file) +db_interface.connect_db(generator_path+"/"+_globalvar.db_filename) + +print("Successfully recorded data\nTesting sample outputs: ") +for x in range(len(sample_inputs)): + inp=sample_inputs[x] + expected=expected_outputs[x] + content=db_interface.match_content(bytes(inp[0],'utf-8'),command=inp[1]).decode('utf-8') + if content in expected: + print("\x1b[1;32mOK\x1b[0;1m:\x1b[0m "+content) + else: + print("\x1b[1;31mMismatch\x1b[0;1m:\x1b[0m "+content) + +try: shutil.rmtree(generator_path) +except: pass \ No newline at end of file diff --git a/tests/clithemedef-test_expected-frontend.txt b/src/testprogram-data/clithemedef-test_expected-frontend.txt similarity index 100% rename from tests/clithemedef-test_expected-frontend.txt rename to src/testprogram-data/clithemedef-test_expected-frontend.txt diff --git a/tests/clithemedef-test_expected.txt b/src/testprogram-data/clithemedef-test_expected.txt similarity index 100% rename from tests/clithemedef-test_expected.txt rename to src/testprogram-data/clithemedef-test_expected.txt diff --git a/src/testprogram-data/clithemedef-test_mainfile.clithemedef.txt b/src/testprogram-data/clithemedef-test_mainfile.clithemedef.txt new file mode 100644 index 0000000000000000000000000000000000000000..bf847c4e7ecd8ab51ae51e46adece8e960b6f41f --- /dev/null +++ b/src/testprogram-data/clithemedef-test_mainfile.clithemedef.txt @@ -0,0 +1,114 @@ +# Sample theme definition file for parser/generator testing + +# Header information +{header_section} + name Example theem + version 0.1 + locales en_US + + # Testing repeated handling of entries + locales en_US zh_CN + version 1.0 + name Example theme + supported_apps example-app example-app-two another-example shound_unset-app +{/header_section} + +# Main block +{entries_section} + [entry] com.example example-app text-one + locale:default Some example text one + locale:en_US Some example text one + locale:zh_CN 一些样例文字(一) + [/entry] + [entry] com.example example-app text-two + locale:default Some example text two + locale:en_US Some example text two + locale:zh_CN 一些样例文字(二) + [/entry] + + # Testing in_domainapp + in_domainapp com.example example-app-two + [entry] text_one + locale:default Some text + locale:en_US Some text + locale:zh_CN 一些文本 + [/entry] + + # Testing subsections + [entry] subsection-one text_one + locale:default Some text + locale:en_US Some text + locale:zh_CN 一些文本 + [/entry] + [entry] subsection-one text_two + locale:default Some text two + locale:en_US Some text two + locale:zh_CN 一些文本(二) + [/entry] + + # Testing in_subsection + in_subsection subsection-two + [entry] text_one + locale:default Some text + locale:en_US Some text + locale:zh_CN 一些文本 + [/entry] + [entry] text_two + locale:default Some text two + locale:en_US Some text two + locale:zh_CN 一些文本(二) + [/entry] + # Testing unset_subsection + unset_subsection + [entry] text_two + locale:default Some text two + locale:en_US Some text two + locale:zh_CN 一些文本(二) + [/entry] + + in_domainapp com.example-two another-example + # Ignore entries in here + [entry] repeat_test_text_one + locale:default Some text + locale:en_US Some text + locale:zh_CN 一些文本 + [/entry] + [entry] repeat_test_text_two + locale:default Some text two + locale:en_US Some text two + locale:zh_CN 一些文本(二) + [/entry] + + # Testing handling of repeated entries + [entry] repeat_test_text_one + locale:default Some other text + locale:en_US Some other text + locale:zh_CN 一些其他文本 + [/entry] + [entry] repeat_test_text_two + locale:default Some other text two + locale:en_US Some other text two + locale:zh_CN 一些其他文本(二) + [/entry] + + # Testing unset_domainapp + unset_domainapp + [entry] should_unset.example should_unset-app text + locale:default Should have reset + locale:en_US Should have reset + locale:zh_CN 应该已经重置 + [/entry] + + # Testing global entries (without domain or/and app) + [entry] sample_global_entry + locale:default Some text + locale:en_US Some text + locale:zh_CN 一些文本 + [/entry] + in_subsection global.example + [entry] global_entry + locale:default Global entry in app + locale:en_US Global entry in app + locale:zh_CN app内的通用实例 + [/entry] +{/entries_section} \ No newline at end of file diff --git a/tests/clithemedef-test_mainfile.clithemedef.txt b/tests/clithemedef-test_mainfile.clithemedef.txt deleted file mode 100644 index 32be3857db59a7e34c47ba170cfca97bef84eb9c..0000000000000000000000000000000000000000 --- a/tests/clithemedef-test_mainfile.clithemedef.txt +++ /dev/null @@ -1,113 +0,0 @@ -# Sample theme definition file for parser/generator testing - -# Header information -begin_header - name Example theem - version 0.1 - locales en_US - - # Testing repeated handling of entries - locales en_US zh_CN - version 1.0 - name Example theme - supported_apps example-app example-app-two another-example shound_unset-app -end_header - -# Main block -begin_main - entry com.example example-app text-one - locale default Some example text one - locale en_US Some example text one - locale zh_CN 一些样例文字(一) - end_entry - entry com.example example-app text-two - locale default Some example text two - locale en_US Some example text two - locale zh_CN 一些样例文字(二) - end_entry - - # Testing in_domainapp - in_domainapp com.example example-app-two - entry text_one - locale default Some text - locale en_US Some text - locale zh_CN 一些文本 - end_entry - - # Testing subsections - entry subsection-one text_one - locale default Some text - locale en_US Some text - locale zh_CN 一些文本 - end_entry - entry subsection-one text_two - locale default Some text two - locale en_US Some text two - locale zh_CN 一些文本(二) - end_entry - - # Testing in_subsection - in_subsection subsection-two - entry text_one - locale default Some text - locale en_US Some text - locale zh_CN 一些文本 - end_entry - entry text_two - locale default Some text two - locale en_US Some text two - locale zh_CN 一些文本(二) - end_entry - # Testing unset_subsection - unset_subsection - entry text_two - locale default Some text two - locale en_US Some text two - locale zh_CN 一些文本(二) - end_entry - - in_domainapp com.example-two another-example - # Ignore entries in here - entry repeat_test_text_one - locale default Some text - locale en_US Some text - locale zh_CN 一些文本 - end_entry - entry repeat_test_text_two - locale default Some text two - locale en_US Some text two - locale zh_CN 一些文本(二) - end_entry - - # Testing handling of repeated entries - entry repeat_test_text_one - locale default Some other text - locale en_US Some other text - locale zh_CN 一些其他文本 - end_entry - entry repeat_test_text_two - locale default Some other text two - locale en_US Some other text two - locale zh_CN 一些其他文本(二) - end_entry - - # Testing unset_domainapp - unset_domainapp - entry should_unset.example should_unset-app text - locale default Should have reset - locale en_US Should have reset - locale zh_CN 应该已经重置 - end_entry - - # Testing global entries (without domain or/and app) - entry sample_global_entry - locale default Some text - locale en_US Some text - locale zh_CN 一些文本 - end_entry - entry global.example global_entry - locale default Global entry in app - locale en_US Global entry in app - locale zh_CN app内的通用实例 - end_entry -end_main \ No newline at end of file