diff --git a/README.md b/README.md index 962786b..06b17e8 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ [![codecov](https://codecov.io/gh/smjc-org/py-submit/graph/badge.svg?token=MNWAUJ35HT)](https://codecov.io/gh/smjc-org/py-submit) [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/smjc-org/py-submit/main.svg)](https://results.pre-commit.ci/latest/github/smjc-org/py-submit/main) -本程序用于从 `.sas` 文件中提取需要递交至监管机构的代码,并另存为 `.txt` 格式的文件。 +本程序用于从 `.sas` 文件中裁剪需要递交的代码,并以 `.txt` 格式存储,支持单文件和多文件处理。 ## 安装 -首先安装 [Python](https://www.python.org/downloads/) 和 [Git](https://git-scm.com/downloads) +首先安装 [Python](https://www.python.org/downloads/) 和 [Git](https://git-scm.com) 然后使用 `pip` 命令安装指定版本,例如: @@ -28,37 +28,37 @@ pip install git+https://github.com/smjc-org/py-submit.git@0.5.6 pip install git+https://github.com/smjc-org/py-submit.git@5ed1b3d545c5670f110fe32139860e8e5a9f446b ``` -上述命令会将本程序安装到环境变量中指定的目录下,后续可直接通过 `submit` 命令调用。 +上述命令会将本程序安装到环境变量中指定的目录下,并向系统注册 `submit` 命令以供后续调用。 -> [!NOTE] -> -> 对于 Windows 用户,你可以在 `%LOCALAPPDATA%/Programs/Python/Python313/Scripts` 中看到 `submit.exe`,你在终端执行 `submit` 命令实际上调用的是这个程序。 +## 使用方法 + +`submit` 包含两个子命令: + +- `copyfile`: 处理单个 sas 文件,保存处理后的代码到 txt 文件中 +- `copydir`: 处理指定目录下的所有 sas 文件,保存处理后的代码到指定目录中 + +程序会识别 `.sas` 文件中的特殊注释,根据这些注释裁剪代码片段,以满足递交需求。 -## 如何使用 +可识别的注释分为两类:_positive_ 模式和 _negative_ 模式。 -`submit` 命令会识别 `.sas` 文件中的特殊注释,根据这些注释,删除多余的代码片段,保留需要递交的代码片段。 +***positive*** 模式的注释格式为: -`submit` 命令可以识别的特殊注释如下: +- /* _symbols_ `SUBMIT BEGIN` _symbols_ */ +- /* _symbols_ `SUBMIT END` _symbols_ */ -| 注释 | 含义 | -| ---------------------------------------------- | ------------------------------------ | -| /\* _symbols_ `SUBMIT BEGIN` _symbols_ \*/ | **需要**提交的代码片段的**开始**位置 | -| /\* _symbols_ `SUBMIT END` _symbols_ \*/ | **需要**提交的代码片段的**结束**位置 | -| /\* _symbols_ `NOT SUBMIT BEGIN` _symbols_ \*/ | **无需**提交的代码片段的**开始**位置 | -| /\* _symbols_ `NOT SUBMIT BEGIN` _symbols_ \*/ | **无需**提交的代码片段的**结束**位置 | +***negative*** 模式的注释格式为: + +- /* _symbols_ `NOT SUBMIT BEGIN` _symbols_ */ +- /* _symbols_ `NOT SUBMIT END` _symbols_ */ > [!NOTE] > > - _`symbols`_ 可以是符号 `*`, `-`, `=`, ` `(空格) 的任意组合 > - 注释不区分大小写 -举例: +举例,假设文件 `code.sas` 的内容如下: ```sas -/* -Top-Level Comment -*/ - proc datasets library = work memtype = data kill noprint; quit; @@ -69,326 +69,40 @@ proc sql noprint; create table work.adsl as select * from rawdata.adsl; quit; +/*NOT SUBMIT BEGIN*/ proc sql noprint; create table work.t_6_1_1 as select * from adsl; quit; -/*SUBMIT END*/ - -%LOG; -%ERROR; -``` - -经 `submit` 命令处理之后将会变成: - -```sas -proc sql noprint; - create table work.adsl as select * from rawdata.adsl; -quit; +/*NOT SUBMIT END*/ -proc sql noprint; - create table work.t_6_1_1 as select * from adsl; -quit; -``` - -### 处理单个 SAS 文件 +proc means data = adsl; +run; -子命令 `copyfile` 用于处理单个 `.sas` 文件。 +/*SUBMIT END*/ -```bash -submit copyfile "adae.sas" "adae.txt" -submit cpf "adae.sas" "adae.txt" +%log; +%error; ``` -其中,`adae.sas` 是需要处理的 `.sas` 文件路径,`adae.txt` 是处理后保存的 `.txt` 文件路径。 - -> [!TIP] -> -> - `cpf` 是 `copyfile` 的别名(_alias_),大多数选项都具有别名,可通过 `--help` 命令查看。 -> - 可以使用相对路径和绝对路径,使用相对路径时,以执行 `submit` 命令的终端的当前目录为根。 -> 例如:在 `/code` 目录下处理子目录 `/code/adam` 中的 `adae.sas` 文件,应该执行 `submit copyfile "adam/adae.sas" "submit/adae.txt"`,此时 `adae.txt` 文件将保存在 `/code/submit` 目录下。 - -#### --convert-mode - -`--convert-mode` 选项用于指定处理模式,可选值为:`positive`, `negative`, `both`,默认为 `both`。 - -- `positive`: 仅处理 `/* SUBMIT BEGIN */`, `/* SUBMIT END */` -- `negative`: 仅处理 `/* NOT SUBMIT BEGIN*/`, `/* NOT SUBMIT END */` -- `both`: 同时处理所有四个特殊注释 - -> [!IMPORTANT] -> -> `/* NOT SUBMIT BEGIN*/`, `/* NOT SUBMIT END */` 的处理优先级高于 `/* SUBMIT BEGIN */`, `/* SUBMIT END */`。 +使用以下命令处理 `code.sas` 文件: ```bash -submit copyfile --convert-mode negative -``` - -上述命令将会把: - -```sas -%macro BAplot(indata, var, outdata); - data _tmp1; - set &indata; - run; - - proc sql noprint; - create table _tmp2 as select * from _tmp1; - quit; - - data &outdata; - set _tmp2; - run; - - /*NOT SUBMIT BEGIN*/ - proc template; - define statgraph BAplot; - begingraph; - entrytitle "BA Plot"; - layout overlay; - scatterplot x=Period y=BA / group=Subject; - endlayout; - endgraph; - end; - run; - - proc sgrender data=&outdata template=BAplot; - run; - /*NOT SUBMIT END*/ -%mend BAplot; +submit copyfile -s code.sas -t code.txt ``` -处理为: +处理后的代码保存在 `code.txt` 中: ```sas -%macro BAplot(indata, var, outdata); - data _tmp1; - set &indata; - run; - - proc sql noprint; - create table _tmp2 as select * from _tmp1; - quit; - - data &outdata; - set _tmp2; - run; - - -%mend BAplot; -``` - -#### --macro-subs - -`--macro-subs` 选项用于替换 `.sas` 文件中宏变量,它应当是一个字典,形式为 `{key=value,...}`,其键 `key` 为宏变量名称,值 `value` 为替换字符串。 +proc sql noprint; + create table work.adsl as select * from rawdata.adsl; +quit; -例如,如果想将下面的代码块中的宏变量 `&id` 替换为 `01`: -```sas -/*submit begin*/ -data adeff; - set adeff.adeff&id; +proc means data = adsl; run; -/*submit end*/ ``` -你需要指定 `--macro-subs "{id=01}"`。 - -> [!TIP] -> -> `value` 可以为空,例如 `--macro-subs "{id=}"`,此时程序将会删除宏变量 `&id`。 - -> [!WARNING] -> -> `--macro-subs` 不支持嵌套的宏变量,例如:`&&id`,`&&&id` 等。 - -#### --encoding - -`--encoding` 选项指定 `.sas` 文件的编码格式。若未指定该选项,将尝试猜测最有可能的编码格式,并用于后续处理。 - -```bash -submit copyfile --convert-mode negative --encoding gbk -``` - -> [!NOTE] -> -> 本程序使用 [chardet](https://github.com/chardet/chardet) 进行编码格式的自动识别,但 `chardet` 会将 `gbk` 编码的文件错误地识别为 `gb2312` 编码。https://github.com/chardet/chardet/issues/168 -> -> 如果出现类似 `UnicodeDecodeError:'gb2312'codec can't decode byte xfb in position 6436: illegal multibyte sequence` 的错误提示,请尝试手动指定 `--encoding gbk`。 - -### 处理多个 SAS 文件 - -子命令 `copydir` 用于处理包含 `.sas` 文件的目录,该命令将以递归的方式自动查找扩展名为 `.sas` 的文件并进行处理,非 `.sas` 文件将被忽略。 - -```bash -submit copydir "/source" "/dest" -``` - -其中,`/source` 是需要处理的 `.sas` 文件所在目录,`/dest` 是处理后保存的 `.txt` 文件所在目录。 - -#### --convert-mode - -同 [`--convert-mode`](#--convert-mode) - -#### --macro-subs - -同 [`--macro-subs`](#--macro-subs) - -#### --encoding - -同 [`--encoding`](#--encoding) - -#### --merge - -`--merge` 选项指定将所有 `.sas` 文件进行转换后合并到单个 `.txt` 文件。 - -例如: - -```bash -submit copydir "/source" "/dest" --merge "code.txt" -``` - -上述代码会将 `/source` 目录中的所有 `.sas` 文件转换成 `.txt` 文件,并将转换后的 `.txt` 文件合并到 `/dest/code.txt` 中。 - -> [!NOTE] -> -> 合并后的 `.txt` 文件包含源目录中所有需要递交的 sas 代码,使用注释 `/*======`_`filename`_`.txt======*/` 分隔来自不同 `.sas` 文件的代码。 -> 其中 _`filename`_ 是源目录中 `.sas` 文件名称(不含扩展名)。 - -> [!IMPORTANT] -> -> 某些地方医疗器械监督管理局不接收压缩包作为递交文件,且递交文件数量存在限制,因此必须将所有 `.sas` 文件合并成一个单独的 `.txt` 文件。 - -#### --exclude-dirs - -`--exclude-dirs` 选项指定排除的目录列表,这些目录中的文件将会被跳过处理。该选项支持 `glob` 模式,详见 [glob 模式介绍](#glob-模式介绍)。 - -```bash -submit copydir "/source" "/dest" --exclude-dirs macro -``` - -可同时指定多个目录: - -```bash -submit copydir "/source" "/dest" --exclude-dirs macro qc initial -``` - -上述命令将在目录名称匹配 `macro`, `qc` 或 `initial` 时跳过处理其中的文件。 - -#### --exclude-files - -`--exclude-files` 选项指定排除的文件列表,这些文件将会被跳过处理。该选项支持 `glob` 模式,详见 [glob 模式介绍](#glob-模式介绍)。 - -```bash -submit copydir "/source" "/dest" --exclude-dirs macro --exclude-files fcmp.sas format.sas -``` - -上述命令将在目录名称匹配 `macro` 时跳过处理其中的文件,并在文件名称匹配 `fcmp.sas` 或 `format.sas` (无论是否在 `macro` 目录中)时跳过处理。 - -### glob 模式介绍 - -`glob` 是一种使用通配符指定文件(目录)名称集合的模式,查看 [wiki]()。 - -你可以在路径中使用以下特殊字符作为通配符: - -- `*`: 匹配任意数量的非分隔符型字符,包括零个。例如,`f*.sas` 匹配 `f1.sas`、`f2.sas`、`f3.sas` 等等。 -- `**`: 匹配任意数量的文件或目录分段,包括零个。例如,`**/f*.sas` 匹配 `figure/f1.sas`、`figure/f2.sas`、`figure/draft/f1.sas` 等等。 -- `?`: 匹配一个不是分隔符的字符。例如,`t1?.sas` 匹配 `t1.sas`、`t10.sas`、`t11.sas` 等等。 -- `[seq]`: 匹配在 seq 中的一个字符。例如,`[tfl]1.sas` 匹配 `t1.sas`、`f1.sas`、`l1.sas`。 - -更多语法请查看 [模式语言](https://docs.python.org/zh-cn/3/library/pathlib.html#pattern-language)。 - -假设有这样一个文件目录结构: - -``` -D:. -├─source -│ ├─f1.sas -│ ├─f2.sas -│ ├─f2-deprecated.sas -│ ├─f3.sas -│ ├─f3-deprecated.sas -│ ├─t1.sas -│ ├─t2.sas -│ ├─t2-deprecated.sas -│ ├─t2-deprecated-20241221.sas -│ ├─t3.sas -│ ├─t4.sas -│ ├─t5.sas -│ ├─t5-deprecated.sas -│ ├─t6.sas -│ ├─t7.sas -│ └─t7-deprecated.sas -└─dest -``` - -现在需要将 `source` 目录中的 `.sas` 文件转换为 `.txt` 文件,但忽略名称包含 `deprecated` 的文件。 - -如果不使用 `glob` 模式,命令应该是这样的: - -```bash -submit copydir source dest --exclude-files "f2-deprecated.sas" "f3-deprecated.sas" "t2-deprecated.sas" "t2-deprecated-20241221.sas" "t5-deprecated.sas" "t7-deprecated.sas" -``` - -使用 `glob` 模式,命令得到简化: - -```bash -submit copydir source dest --exclude-files "*deprecated*.sas" -``` - -### 命令行选项参考 - -#### submit copyfile - -```bash -usage: submit [options] copyfile [-h] [-c {positive,negative,both}] [--macro-subs MACRO_SUBS] [--encoding ENCODING] sas_file txt_file - -positional arguments: - sas_file SAS 文件路径 - txt_file TXT 文件路径 - -options: - -h, --help show this help message and exit - -c, --convert-mode {positive,negative,both} - 转换模式(默认 both) - --macro-subs MACRO_SUBS - 宏变量替换,格式为 {key=value,...}(默认无) - --encoding ENCODING 编码格式(默认自动检测) -``` - -#### submit copydir - -```bash -usage: submit [options] copydir [-h] [-c {positive,negative,both}] [--macro-subs MACRO_SUBS] [--encoding ENCODING] [-mrg MERGE] [-exf [EXCLUDE_FILES ...]] [-exd [EXCLUDE_DIRS ...]] sas_dir txt_dir - -positional arguments: - sas_dir SAS 文件目录 - txt_dir TXT 文件目录 - -options: - -h, --help show this help message and exit - -c, --convert-mode {positive,negative,both} - 转换模式(默认 both) - --macro-subs MACRO_SUBS - 宏变量替换,格式为 {key=value,...}(默认无) - --encoding ENCODING 编码格式(默认自动检测) - -mrg, --merge MERGE 合并到一个文件(默认无) - -exf, --exclude-files [EXCLUDE_FILES ...] - 排除文件列表(默认无) - -exd, --exclude-dirs [EXCLUDE_DIRS ...] - 排除目录列表(默认无) -``` - -### bat 脚本编写示例 - -`.bat` 文件是一种[批处理文件](https://en.wikipedia.org/wiki/Batch_file),你可以将多条 `submit` 命令保存在单个 `.bat` 文件中,这样只需双击这个文件即可批量处理 `.sas` 文件。 - -例如: - -```bash -submit copydir "D:/project/code/adam" "D:/project/submit/adam" -submit copydir "D:/project/code/tfl" "D:/project/submit/tfl" --exclude-files merge.sas -submit copydir "D:/project/code/macro" "D:/project/submit/macro" --convert-mode negative -``` +更多选项及其用法请参考 [选项参考](./docs/usage.md) ## 如何贡献 @@ -407,29 +121,18 @@ submit copydir "D:/project/code/macro" "D:/project/submit/macro" --convert-mode 2. 安装依赖 ```bash - uv sync - ``` - -3. 安装 pre-commit - - ```bash - pre-commit install - pre-commit install --hook-type commit-msg + uv sync --all-groups ``` -4. 修改代码 +3. 修改代码 -5. 测试代码 +4. 测试代码 ```bash - pytest + uv run pytest ``` -> [!NOTE] -> -> 执行 `pytest` 命令前需要先激活虚拟环境。 - -6. 发起 pull request +5. 发起 pull request > [!NOTE] > diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..37e1f68 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,160 @@ +# 选项参考 + +## 子命令 `copyfile` + +子命令 `copyfile` 用于处理单个 `.sas` 文件,可用的选项如下: + +### `-s, -sas-file` + +指定需裁剪的 `.sas` 文件路径,可以使用相对路径和绝对路径,使用相对路径时,以执行 `submit` 命令的终端的当前目录为根。 + +### `-t, -txt-file` + +指定处理后的代码保存的 `.txt` 文件路径,可以使用相对路径和绝对路径,使用相对路径时,以执行 `submit` 命令的终端的当前目录为根。 + +### `--positive` + +指定处理 _positive_ 模式的注释。 + +### `--no-positive` + +指定不要处理 _positive_ 模式的注释。 + +### `--negative` + +指定处理 _negative_ 模式的注释,优先级高于 `--positive`。 + +### `--no-negative` + +指定不要处理 _negative_ 模式的注释。 + +### `-e, --encoding` + +指定 `.sas` 文件的字符编码,默认值为 `gbk`。 + +### `--help` + +显示帮助信息并退出。 + +### 示例 + +```bash +submit copyfile -s "./adae.sas" "./output/adae.txt" +submit copyfile -s "./adae.sas" "./output/adae.txt" --no-negative +submit copyfile -s "./adae.sas" "./output/adae.txt" --no-negative --encoding utf-8 +``` + +## 子命令 `copydir` + +子命令 `copydir` 用于处理包含 `.sas` 文件的目录,该命令将以递归的方式自动查找扩展名为 `.sas` 的文件并进行处理,非 `.sas` 文件将被忽略。 + +可用的选项如下: + +### `-s, --sas-dir` + +指定包含需裁剪的 sas 代码的目录路径,可以使用相对路径和绝对路径,使用相对路径时,以执行 `submit` 命令的终端的当前目录为根。 + +### `-t, --txt-dir` + +指定裁剪后的 sas 代码保存的目录路径,可以使用相对路径和绝对路径,使用相对路径时,以执行 `submit` 命令的终端的当前目录为根。 + +### `--positive` + +同 [--positive](#--positive) + +### `--no-positive` + +同 [--no-positive](#--no-positive) + +### `--negative` + +同 [--negative](#--negative) + +### `--no-negative` + +同 [--no-negative](#--no-negative) + +### `-e, --encoding` + +同 [`--encoding`](#--encoding) + +### `--merge` + +指定是否将所有处理后的代码合并到一个文件中。 + +> [!NOTE] +> +> 合并后的 `.txt` 文件包含源目录中所有需要递交的 sas 代码,使用注释 `/*====================`_`filename`_`.sas====================*/` 分隔来自不同 `.sas` 文件的代码。 +> 其中 _`filename`_ 是源目录中 `.sas` 文件名称。 + +> [!IMPORTANT] +> +> 某些地方医疗器械监督管理局不接收压缩包作为递交文件,且递交文件数量存在限制,因此必须将所有 `.sas` 文件合并成一个单独的 `.txt` 文件。 + +### `--merge-name` + +指定合并后的文件名,默认值为 `merged.txt`,仅当指定了 `--merge` 选项时有效。 + +### `-exd, --exclude-dir` + +指定需排除的目录的 [glob 路径模式](#glob-模式介绍),所有匹配该模式的目录中的 `.sas` 文件将被忽略。 + +该选项可以多次使用,表示多个排除目录。 + +### `-exf, --exclude-file` + +指定需排除的文件的 [glob 路径模式](#glob-模式介绍),所有匹配该模式的文件将被忽略。 + +该选项可以多次使用,表示多个排除文件。 + +### 示例 + +```bash +submit copydir "./adam" "./adam/output" +submit copydir "./adam" "./adam/output" --exclude-file "**/deprecated*.sas" +submit copydir "./adam" "./adam/output" --exclude-file "**/deprecated*.sas" --exclude-dir "sponser-only" --exclude-dir "test-only" +submit copydir "./adam" "./adam/output" --merge --merge-name "all.txt" +``` + +## glob 模式介绍 + +`glob` 是一种使用通配符指定文件(目录)名称集合的模式,查看 [wiki]()。 + +你可以在路径中使用以下特殊字符作为通配符: + +- `*`: 匹配任意数量的非分隔符型字符,包括零个。例如,`f*.sas` 匹配 `f1.sas`、`f2.sas`、`f3.sas` 等等。 +- `**`: 匹配任意数量的文件或目录分段,包括零个。例如,`**/f*.sas` 匹配 `figure/f1.sas`、`figure/f2.sas`、`figure/draft/f1.sas` 等等。 +- `?`: 匹配一个不是分隔符的字符。例如,`t1?.sas` 匹配 `t1.sas`、`t10.sas`、`t11.sas` 等等。 +- `[seq]`: 匹配在 seq 中的一个字符。例如,`[tfl]1.sas` 匹配 `t1.sas`、`f1.sas`、`l1.sas`。 + +更多语法请查看 [模式语言](https://docs.python.org/zh-cn/3/library/pathlib.html#pattern-language)。 + +假设有这样一个文件目录结构: + +``` +D:. +├─source +│ ├─f1.sas +│ ├─f2.sas +│ ├─f2-deprecated.sas +│ ├─f3.sas +│ ├─f3-deprecated.sas +│ ├─t1.sas +│ ├─t2.sas +│ ├─t2-deprecated.sas +│ ├─t2-deprecated-20241221.sas +│ ├─t3.sas +│ ├─t4.sas +│ ├─t5.sas +│ ├─t5-deprecated.sas +│ ├─t6.sas +│ ├─t7.sas +│ └─t7-deprecated.sas +└─dest +``` + +现在需要将 `source` 目录中的 `.sas` 文件转换为 `.txt` 文件,但忽略名称包含 `deprecated` 的文件,可以使用以下命令: + +```bash +submit copydir --s "~/source" -t "~/dest" --exclude-file "*deprecated*.sas" +``` diff --git a/src/submit/__init__.py b/src/submit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/submit/submit.py b/src/submit/submit.py new file mode 100644 index 0000000..a1fd429 --- /dev/null +++ b/src/submit/submit.py @@ -0,0 +1,240 @@ +# author: @Snoopy1866 + +import re + +from dataclasses import dataclass +from pathlib import Path + +import click + +from natsort import index_natsorted + +SYLBOMS = r"[\s\*\-\=]" + +# 需要递交的代码片段的开始和结束标记 (positive mode) +# /*SUBMIT BEGIN*/ 和 /*SUBMIT END*/ 之间的代码将被保留 +POSITIVE_COMMENT_BEGIN = rf"\/\*{SYLBOMS}*SUBMIT\s*BEGIN{SYLBOMS}*\*\/" +POSITIVE_COMMENT_END = rf"\/\*{SYLBOMS}*SUBMIT\s*END{SYLBOMS}*\*\/" + + +# 不要递交的代码片段的开始和结束标记 (negative mode) +# /*NOT SUBMIT BEGIN*/ 和 /*NOT SUBMIT END*/ 之间的代码将移除 +NEGATIVE_COMMENT_BEGIN = rf"\/\*{SYLBOMS}*NOT\s*SUBMIT\s*BEGIN{SYLBOMS}*\*\/" +NEGATIVE_COMMENT_END = rf"\/\*{SYLBOMS}*NOT\s*SUBMIT\s*END{SYLBOMS}*\*\/" + +# 宏变量 +# 仅支持一层宏变量引用(例如:&id),不支持嵌套宏变量引用(例如:&&id、&&&id) +MACRO_VAR = r"(? None: + """裁剪代码。 + + Args: + file (Path): 代码文件路径。 + positive (bool): 是否处理 positive 模式的注释。 + negative (bool): 是否处理 negative 模式的注释,优先级高于 `positive`。 + encoding (str, optional): 字符编码。 + """ + + re_flags = re.I | re.S + + code = file.read_text(encoding=encoding) + + # 处理特殊注释,NEGATIVE 模式处理优先级高于 POSITIVE 模式 + if negative: + start_match = re.search(rf"{NEGATIVE_COMMENT_BEGIN}", code, flags=re_flags) + end_match = re.search(rf"{NEGATIVE_COMMENT_END}", code, flags=re_flags) + if start_match and end_match: + code = re.sub(rf"{NEGATIVE_COMMENT_BEGIN}.*?{NEGATIVE_COMMENT_END}", "", code, flags=re_flags) + elif start_match is not None and end_match is None: + click.secho(f"源文件 {file.name} 中存在 NEGATIVE 模式的起始注释,但未找到对应的终止注释!", fg="red") + elif start_match is None and end_match is not None: + click.secho(f"源文件 {file.name} 中存在 NEGATIVE 模式的终止注释,但未找到对应的起始注释!", fg="red") + else: + pass + + if positive: + start_match = re.search(rf"{POSITIVE_COMMENT_BEGIN}", code, flags=re_flags) + end_match = re.search(rf"{POSITIVE_COMMENT_END}", code, flags=re_flags) + if start_match and end_match: + code_segaments = re.findall(rf"{POSITIVE_COMMENT_BEGIN}(.*?){POSITIVE_COMMENT_END}", code, re_flags) + code = "\n\n".join(code.strip() for code in code_segaments) + elif start_match is not None and end_match is None: + click.secho(f"源文件 {file.name} 中存在 POSITIVE 模式的起始注释,但未找到对应的终止注释!", fg="red") + elif start_match is None and end_match is not None: + click.secho(f"源文件 {file.name} 中存在 POSITIVE 模式的终止注释,但未找到对应的起始注释!", fg="red") + else: + pass + + # 替换宏变量 + # --TODO-- + + return code.strip() + + +def _copy_file(sas_file: Path, txt_file: Path, positive: bool, negative: bool, encoding: str) -> None: + """处理单个 sas 文件,保存处理后的代码到 txt 文件中。""" + + code = _cut_code(sas_file, positive, negative, encoding) + + txt_file_dir = txt_file.parent + if not txt_file_dir.exists(): + txt_file_dir.mkdir(parents=True) + txt_file.write_text(code, encoding=encoding) + + +@click.group() +def cli() -> None: + """sas 代码裁剪工具""" + pass + + +@cli.command(name="copyfile", help="处理单个 sas 文件,保存处理后的代码到 txt 文件中。") +@click.option( + "-s", + "--sas-file", + required=True, + type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True, path_type=Path), + help="包含需裁剪的 sas 代码的文件路径", +) +@click.option( + "-t", + "--txt-file", + required=True, + type=click.Path(resolve_path=True, writable=True, path_type=Path), + help="裁剪后的 sas 代码保存的文件路径", +) +@click.option("--positive/--no-positive", is_flag=True, default=True, help="是否处理 positive 模式的注释") +@click.option("--negative/--no-negative", is_flag=True, default=True, help="是否处理 negative 模式的注释,优先级高于 --positive") +@click.option("-e", "--encoding", default="gbk", type=str, help="字符编码,默认值为 gbk") +def copy_file(sas_file: Path, txt_file: Path, positive: bool, negative: bool, encoding: str) -> None: + """处理单个 sas 文件,保存处理后的代码到 txt 文件中。""" + + _copy_file(sas_file, txt_file, positive, negative, encoding) + + +@cli.command(name="copydir", help="处理指定目录下的所有 sas 文件,保存处理后的代码到指定目录中。") +@click.option( + "-s", + "--sas-dir", + required=True, + type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True, resolve_path=True, path_type=Path), + help="包含需裁剪的 sas 代码的目录路径", +) +@click.option( + "-t", + "--txt-dir", + required=True, + type=click.Path(resolve_path=True, path_type=Path), + help="裁剪后的 sas 代码保存的目录路径", +) +@click.option("--positive/--no-positive", is_flag=True, default=True, help="是否处理 positive 模式的注释") +@click.option("--negative/--no-negative", is_flag=True, default=True, help="是否处理 negative 模式的注释,优先级高于 --positive") +@click.option("-e", "--encoding", default="gbk", type=str, help="字符编码,默认值为 gbk") +@click.option("--merge/--no-merge", is_flag=True, help="是否将所有处理后的代码合并到一个文件中") +@click.option("--merge-name", default="merged.txt", type=str, help="合并后的文件名,默认值为 merged.txt,仅当 --merge 选项为 True 时有效") +@click.option("-exd", "--exclude-dir", multiple=True, type=str, help="排除的目录路径模式") +@click.option("-exf", "--exclude-file", multiple=True, type=str, help="排除的文件路径模式") +def copy_directory( + sas_dir: Path, + txt_dir: Path, + positive: bool = True, + negative: bool = True, + encoding: str = "gbk", + merge: bool = False, + merge_name: str = "merged.txt", + exclude_dir: tuple[str, ...] = (), + exclude_file: tuple[str, ...] = (), +) -> None: + """处理指定目录下的所有 sas 文件,保存处理后的代码到指定目录中。 + + Args: + sas_dir (Path): 包含 sas 文件的目录路径。 + txt_dir (Path): 存储 txt 文件的目录路径。 + positive (bool): 是否处理 positive 模式的注释。 + negative (bool): 是否处理 negative 模式的注释,优先级高于 `positive`。 + encoding (str): 字符编码。 + merge (bool): 是否将所有处理后的代码合并到一个文件中。 + merge_name (str): 合并后的文件名,默认值为 `'merged.txt'`,仅当 `merge` 选项为 True 时有效。 + exclude_file (tuple[Path, ...]): 排除的文件路径。 + exclude_dir (tuple[Path, ...]): 排除的目录路径。 + """ + + resolved_exclude_dirs: set[Path] = set() + resolved_exclude_files: set[Path] = set() + + if not txt_dir.exists(): + txt_dir.mkdir(parents=True) + + if exclude_dir: + for pattern in exclude_dir: + resolved_exclude_dirs.update(sas_dir.glob(pattern)) + + if exclude_file: + for pattern in exclude_file: + resolved_exclude_files.update(sas_dir.glob(pattern)) + + # 记录需要处理的 sas 文件 + copy_file_tasks: list[CopyFileTask] = [] + + # 处理 SAS 文件 + for dirpath, _, filenames in sas_dir.walk(): + if resolved_exclude_dirs and dirpath in resolved_exclude_dirs: + click.secho(f"已排除目录:{dirpath.absolute()}", fg="magenta") + continue + if txt_dir in dirpath.parents or dirpath == txt_dir: # 如果当前目录是目标目录或其子目录,则跳过 + continue + for file in filenames: + fileabspath = dirpath / file + if resolved_exclude_files and fileabspath in resolved_exclude_files: + click.secho(f"已排除文件:{fileabspath.absolute()}", fg="yellow") + continue + if file.endswith(".sas"): + dirrelpath = dirpath.relative_to(sas_dir) + txt_file = txt_dir / dirrelpath / file.replace(".sas", ".txt") + copy_file_tasks.append( + CopyFileTask(sas_file=fileabspath, txt_file=txt_file, positive=positive, negative=negative, encoding=encoding) + ) + + if not copy_file_tasks: + click.secho("未找到需要处理的 .sas 文件") + return + + if merge: # 合并文件 + merge_file = txt_dir / merge_name + + # 对任务按照 txt_file.name 进行自然排序 + txt_file_names = [task.txt_file.name for task in copy_file_tasks] + index = index_natsorted(txt_file_names) + sorted_copy_file_tasks = [copy_file_tasks[i] for i in index] + + # 写入 merge_file + with open(merge_file, "w", encoding=encoding) as f: + for task in sorted_copy_file_tasks: + f.write(f"/*===================={task.sas_file.name}====================*/\n\n") + + code_content = _cut_code(task.sas_file, task.positive, task.negative, task.encoding) + f.write(code_content) + + f.write("\n\n\n") + click.secho(f"已转换文件:{task.sas_file.absolute()}", fg="green") + click.secho(f"已生成文件:{merge_file.absolute()}", fg="green") + else: # 不合并文件 + for task in copy_file_tasks: + _copy_file(task.sas_file, task.txt_file, task.positive, task.negative, task.encoding) + click.secho(f"已转换文件:{task.sas_file.absolute()}", fg="green") diff --git a/submit.py b/submit.py deleted file mode 100644 index a80824d..0000000 --- a/submit.py +++ /dev/null @@ -1,294 +0,0 @@ -# author: @Snoopy1866 - -from __future__ import annotations -import argparse -import re -from enum import IntFlag, auto -from pathlib import Path - -from chardet import detect -from natsort import natsorted - -SYLBOMS = r"[\s\*\-\=]" - -# 需要递交的代码片段的开始和结束标记 -# /*SUBMIT BEGIN*/ 和 /*SUBMIT END*/ 之间的代码将被递交 -COMMENT_SUBMIT_BEGIN = rf"\/\*{SYLBOMS}*SUBMIT\s*BEGIN{SYLBOMS}*\*\/" -COMMENT_SUBMIT_END = rf"\/\*{SYLBOMS}*SUBMIT\s*END{SYLBOMS}*\*\/" - - -# 不要递交的代码片段的开始和结束标记 -# /*NOT SUBMIT BEGIN*/ 和 /*NOT SUBMIT END*/ 之间的代码将不会被递交 -# /*NOT SUBMIT BEGIN*/ 和 /*NOT SUBMIT END*/ 的优先级高于 /*SUBMIT BEGIN*/ 和 /*SUBMIT END*/ -COMMENT_NOT_SUBMIT_NEGIN: str = rf"\/\*{SYLBOMS}*NOT\s*SUBMIT\s*BEGIN{SYLBOMS}*\*\/" -COMMENT_NOT_SUBMIT_END: str = rf"\/\*{SYLBOMS}*NOT\s*SUBMIT\s*END{SYLBOMS}*\*\/" - -# 宏变量 -# 仅支持一层宏变量引用(例如:&id),不支持嵌套宏变量引用(例如:&&id、&&&id) -MACRO_VAR = r"(? str: - return self.name.lower() - - @classmethod - def get_from_str(cls, value: str) -> ConvertMode: - try: - return cls[value.upper()] - except KeyError: - raise argparse.ArgumentTypeError(f"无效的转换模式:{value}") - - @classmethod - def get_available_values(cls) -> list[ConvertMode]: - modes = [mode for mode in cls] - modes.append(ConvertMode.BOTH) - return modes - - -def expand_glob_patterns(patterns: list[Path], root_dir: Path) -> list[Path]: - """扩展 glob 模式。 - - Args: - patterns (list[Path]): glob 模式列表。 - root_dir (Path): 根目录。 - - Returns: - list[Path]: 文件路径列表。 - """ - - path: list[Path] = [] - for pattern in patterns: - path.extend(root_dir.glob(pattern)) - return path - - -def copy_file( - sas_file: Path, - txt_file: Path, - convert_mode: ConvertMode = ConvertMode.BOTH, - macro_subs: dict[str, str] | None = None, - encoding: str | None = None, -) -> None: - """将单个 SAS 文件复制到 txt 文件,并移除指定标记之间的内容。 - - Args: - sas_file (Path): SAS 文件路径。 - txt_file (Path): TXT 文件路径。 - convert_mode (ConvertMode, optional): 转换模式,默认值为 ConvertMode.BOTH。 - macro_subs (dict[str, str] | None, optional): 一个字典,其键为 SAS 代码中的宏变量名称,值为替代的字符串,默认值为 None。 - encoding (str | None, optional): 字符编码,默认值为 None,将自动检测编码。 - """ - - if encoding is None: - encoding = detect(sas_file.read_bytes())["encoding"] - - code = sas_file.read_text(encoding=encoding) - - # 处理特殊注释,NEGATIVE 模式优先级高于 POSITIVE 模式 - - # NEGATIVE 模式,移除不需要递交的代码片段 - if convert_mode & ConvertMode.NEGATIVE: - start_match = re.search(rf"{COMMENT_NOT_SUBMIT_NEGIN}", code, flags=re.I | re.S) - end_match = re.search(rf"{COMMENT_NOT_SUBMIT_END}", code, flags=re.I | re.S) - if start_match and end_match: - code = re.sub(rf"{COMMENT_NOT_SUBMIT_NEGIN}.*?{COMMENT_NOT_SUBMIT_END}", "", code, flags=re.I | re.S) - elif (start_match is None) != (end_match is None): - print(f"源文件 {sas_file.name} 中出现了不匹配的特殊注释,模式:NEGATIVE。") - else: - pass - - # POSITIVE 模式,仅提取需要递交的代码片段 - if convert_mode & ConvertMode.POSITIVE: - start_match = re.search(rf"{COMMENT_SUBMIT_BEGIN}", code, flags=re.I | re.S) - end_match = re.search(rf"{COMMENT_SUBMIT_END}", code, flags=re.I | re.S) - if start_match and end_match: - code = re.findall(rf"{COMMENT_SUBMIT_BEGIN}(.*?){COMMENT_SUBMIT_END}", code, re.I | re.S) - code = "".join(code) - elif (start_match is None) != (end_match is None): - print(f"源文件 {sas_file.name} 中出现了不匹配的特殊注释,模式:POSITIVE。") - else: - pass - - # 替换宏变量 - if macro_subs is not None: - for key, value in macro_subs.items(): - regex_macro = re.compile(rf"(? None: - """将目录中的所有 SAS 文件复制到 txt 文件,并移除指定标记之间的内容。 - - Args: - sas_dir (Path): SAS 文件夹路径。 - txt_dir (Path): TXT 文件夹路径。 - merge (str | None, optional): 合并到一个文件,默认值为 None。 - convert_mode (ConvertMode, optional): 转换模式,默认值为 ConvertMode.BOTH。 - macro_subs (dict[str, str] | None, optional): 一个字典,其键为 SAS 代码中的宏变量名称,值为替代的字符串,默认值为 None。 - exclude_files (list[Path], optional): 排除文件列表,默认值为 None。 - exclude_dirs (list[Path], optional): 排除目录列表,默认值为 None。 - encoding (str | None, optional): 字符编码,默认值为 None,将自动检测编码。 - """ - - if not sas_dir.exists(): - print(f'源文件夹 "{sas_dir.absolute()}" 不存在。') - return - if not txt_dir.exists(): - txt_dir.mkdir(parents=True) - - if exclude_dirs: - exclude_dirs = expand_glob_patterns(exclude_dirs, root_dir=sas_dir) - - if exclude_files: - exclude_files = expand_glob_patterns(exclude_files, root_dir=sas_dir) - - # 转换 SAS 文件 - for dirpath, _, filenames in sas_dir.walk(): - if exclude_dirs is not None and dirpath in exclude_dirs: - print(f"已排除 {dirpath.absolute()}") - continue - if txt_dir in dirpath.parents: # 如果当前目录是目标目录或其子目录,则跳过 - continue - for file in filenames: - dirrelpath = dirpath.relative_to(sas_dir) - fileabspath = dirpath / file - if exclude_files is not None and fileabspath in exclude_files: - print(f"已排除 {fileabspath.absolute()}") - continue - if file.endswith(".sas"): - print(f"已转换 {fileabspath.absolute()}") - sas_file = dirpath / file - txt_file = txt_dir / dirrelpath / file.replace(".sas", ".txt") - copy_file(sas_file, txt_file, convert_mode=convert_mode, macro_subs=macro_subs, encoding=encoding) - - # 合并文件 - if merge is not None: - merge_file = txt_dir / merge - with open(merge_file, "w", encoding=encoding) as f: - for dirpath, _, filenames in txt_dir.walk(): - filenames = natsorted(filenames) - for file in filenames: - if file.endswith(".txt") and file != merge: - txt_file = dirpath / file - with open(txt_file, "r", encoding=encoding) as txt: - f.write(f"/*======{file}======*/\n") - f.write(txt.read()) - f.write("\n") - - for dirpath, _, filenames in txt_dir.walk(): - filenames = natsorted(filenames) - for file in filenames: - if file.endswith(".txt") and file != merge: - txt_file = dirpath / file - txt_file.unlink() - - -def parse_dict(arg: str) -> dict[str, str]: - """解析字典字符串。 - - Args: - arg (str): 字典字符串。 - - Returns: - dict[str, str]: 字典。 - """ - - arg = arg.strip("{}") - try: - return dict([ele.strip("\"'") for ele in item.split("=")] for item in arg.split(", ")) - except ValueError: - raise argparse.ArgumentTypeError("无效的字典字符串") - - -def main(argv: list[str] | None = None) -> None: # pragma: no cover - parser = argparse.ArgumentParser( - prog="submit", - usage="%(prog)s [options]", - description="本工具用于在代码递交之前进行简单的转换。", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - add_help=False, - ) - parser.add_argument( - "-h", "--help", action="help", default=argparse.SUPPRESS, help="显示帮助信息,例如: submit copyfile --help" - ) - subparsers = parser.add_subparsers(dest="command") - - # 公共参数 - parent_parser = argparse.ArgumentParser(add_help=False) - parent_parser.add_argument( - "-c", - "--convert-mode", - type=ConvertMode.get_from_str, - choices=ConvertMode.get_available_values(), - default="both", - help="转换模式(默认 both)", - ) - parent_parser.add_argument("--macro-subs", type=parse_dict, help="宏变量替换,格式为 {key=value,...}(默认无)") - parent_parser.add_argument("--encoding", default=None, help="编码格式(默认自动检测)") - - # 子命令 copyfile - parser_file = subparsers.add_parser("copyfile", aliases=["cpf"], parents=[parent_parser], help="单个 SAS 文件转换") - parser_file.add_argument("sas_file", help="SAS 文件路径") - parser_file.add_argument("txt_file", help="TXT 文件路径") - - # 子命令 copydir - parser_dir = subparsers.add_parser("copydir", aliases=["cpd"], parents=[parent_parser], help="多个 SAS 文件转换") - parser_dir.add_argument("sas_dir", help="SAS 文件目录") - parser_dir.add_argument("txt_dir", help="TXT 文件目录") - parser_dir.add_argument("-mrg", "--merge", default=None, help="合并到一个文件(默认无)") - parser_dir.add_argument("-exf", "--exclude-files", nargs="*", default=None, help="排除文件列表(默认无)") - parser_dir.add_argument("-exd", "--exclude-dirs", nargs="*", default=None, help="排除目录列表(默认无)") - - args = parser.parse_args(argv) - - if args.command == "copyfile": - copy_file( - sas_file=Path(args.sas_file), - txt_file=Path(args.txt_file), - convert_mode=args.convert_mode, - macro_subs=args.macro_subs, - encoding=args.encoding, - ) - elif args.command == "copydir": - copy_directory( - sas_dir=Path(args.sas_dir), - txt_dir=Path(args.txt_dir), - merge=args.merge, - convert_mode=args.convert_mode, - macro_subs=args.macro_subs, - exclude_files=args.exclude_files, - exclude_dirs=args.exclude_dirs, - encoding=args.encoding, - ) - else: - parser.print_help() - - -if __name__ == "__main__": # pragma: no cover - main() # pragma: no cover diff --git a/tests/conftest.py b/tests/conftest.py index ccce6ac..843696e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,92 +2,30 @@ import pytest -from tests.contents_source import ( - content_source_adsl, - content_source_addv, - content_source_t1, - content_source_t2, - content_source_t3, - content_source_t4, - content_source_macro1, - content_source_macro2, - content_source_macro3, - content_source_q1, - content_source_q2, - content_source_fcmp, - content_source_other, -) -from tests.contents_validate import ( - content_validate_adsl, - content_validate_addv, - content_validate_t1, - content_validate_t2, - content_validate_t3, - content_validate_macro1, - content_validate_macro2, -) +@pytest.fixture +def dummy_sas_dir(tmp_path: Path) -> Path: + """动态创建一个包含 sas 文件的目录。""" + src_dir = tmp_path / "sas_src" + src_dir.mkdir() -@pytest.fixture(scope="session") -def source_directory(tmp_path_factory: pytest.TempPathFactory) -> Path: - dir = tmp_path_factory.mktemp("code") - dir_adam = dir / "adam" - dir_tfl = dir / "tfl" - dir_macro = dir / "macro" - dir_other = dir / "other" + # 1. 创建一个包含 POSITIVE 标记的文件 + file_1 = src_dir / "t_6_1.sas" + file_1.write_text("data _null_;\n/*SUBMIT BEGIN*/\nproc print data=sashelp.class;\nrun;\n/*SUBMIT END*/\nrun;", encoding="gbk") - # adam sas files - dir_adam.mkdir() - (dir_adam / "adsl.sas").write_text(content_source_adsl) - (dir_adam / "adae.sas").write_text(content_source_addv) + # 2. 创建一个包含 NEGATIVE 标记的文件 + file2 = src_dir / "t_6_2.sas" + file2.write_text("/*NOT SUBMIT BEGIN*/\noptions nodate;\n/*NOT SUBMIT END*/\nproc means data=test; run;", encoding="gbk") - # tfl sas files - dir_tfl.mkdir() - (dir_tfl / "t1.sas").write_text(content_source_t1) - (dir_tfl / "t2.sas").write_text(content_source_t2) - (dir_tfl / "t3.sas").write_text(content_source_t3) - (dir_tfl / "t4.sas").write_text(content_source_t4) + # 3. 创建一个在子目录里的文件(用于测试 --exclude-dir 参数) + sub_dir = src_dir / "sponser_only" + sub_dir.mkdir() + file3 = sub_dir / "t_7_1.sas" + file3.write_text("proc gplot; run;", encoding="gbk") - # macro sas files - dir_macro.mkdir() - (dir_macro / "macro1.sas").write_text(content_source_macro1) - (dir_macro / "macro2.sas").write_text(content_source_macro2) - (dir_macro / "macro3.sas").write_text(content_source_macro3) + # 4. 创建一个不需要转换的文件(用于测试 --exclude-file 参数) + file3 = src_dir / "deprecated_t_8_1.sas" + file3.write_text("proc means; run;", encoding="gbk") - # other directories which supposed to be excluded - dir_other.mkdir() - (dir_other / "q1.sas").write_text(content_source_q1) - (dir_other / "q2.sas").write_text(content_source_q2) - - # other sas files which supposed to be excluded - (dir / "fcmp.sas").write_text(content_source_fcmp) - - # other files whose suffix is not sas - (dir / "other.txt").write_text(content_source_other) - return dir - - -@pytest.fixture(scope="session") -def validate_directory(tmp_path_factory: pytest.TempPathFactory) -> Path: - dir = tmp_path_factory.mktemp("validate") - dir_adam = dir / "adam" - dir_tfl = dir / "tfl" - dir_macro = dir / "macro" - - # adam txt files - dir_adam.mkdir() - (dir_adam / "adsl.txt").write_text(content_validate_adsl) - (dir_adam / "adae.txt").write_text(content_validate_addv) - - # tfl txt files - dir_tfl.mkdir() - (dir_tfl / "t1.txt").write_text(content_validate_t1) - (dir_tfl / "t2.txt").write_text(content_validate_t2) - (dir_tfl / "t3.txt").write_text(content_validate_t3) - - # macro txt files - dir_macro.mkdir() - (dir_macro / "macro1.txt").write_text(content_validate_macro1) - (dir_macro / "macro2.txt").write_text(content_validate_macro2) - return dir + return src_dir diff --git a/tests/contents_source.py b/tests/contents_source.py deleted file mode 100644 index e1d3064..0000000 --- a/tests/contents_source.py +++ /dev/null @@ -1,160 +0,0 @@ -# 定义测试用例的源文件内容 - -content_source_adsl = """ -proc datasets library = work memtype = data kill noprint; -quit; - -/*SUBMIT BEGIN*/ -proc sql; - create table adal as select * from rawdata.fp; -quit; -/*SUBMIT END*/ - -proc report; -quit; -""" - -content_source_addv = """ -proc datasets library = work memtype = data kill noprint; -quit; - -/*====SUBMIT BEGIN====*/ -proc sql; - create table adae as select * from rawdata.ae; -quit; -/*====SUBMIT END====*/ - -proc report; -quit; -""" - -content_source_t1 = """ -proc datasets library = work memtype = data kill noprint; -quit; - -/*=- *SUBMIT BEGIN=- **/ -proc sql; - create table t1 as select * from adam.adsl; -quit; -/*=- *SUBMIT END=- **/ - -proc report; -quit; -""" - -content_source_t2 = """ -proc datasets library = work memtype = data kill noprint; -quit; - -%let id = %str(); - -/*====SUBMIT BEGIN====*/ -proc sql; - create table t2 as select * from adam.adeff&id; -quit; -/*====SUBMIT END====*/ - -proc report; -quit; -""" - -content_source_t3 = """ -proc datasets library = work memtype = data kill noprint; -quit; - -/*SUBMIT BEGIN*/ -%macro inner_macro; - proc sql; - select * from adsl; - quit; - - /*NOT SUBMIT BEGIN*/ - proc report; - quit; - /*NOT SUBMIT END*/ -%mend inner_macro; -%inner_macro; -proc sql noprint; - select * from adae; -quit; -/*SUBMIT END*/ - -proc report; -quit; -""" - -content_source_t4 = """ -proc datasets library = work memtype = data kill noprint; -quit; - -%let id = %str(); - -/*====SUBMIT BEGIN====*/ -proc sql; - create table t4 as select * from adam.adeff&id; -quit; - -proc report; -quit; -""" - -content_source_macro1 = """ -%macro macro1; - proc sql; - select * from adsl; - quit; -%mend macro1; -""" - -content_source_macro2 = """ -%macro macro2; - proc sql; - select * from adae; - quit; - - /*====NOT SUBMIT BEGIN====*/ - proc report; - quit; - /*NOT SUBMIT END*/ -%mend macro2; -""" - -content_source_macro3 = """ -%macro macro2; - proc sql; - select * from adae; - quit; - - proc report; - quit; - /*NOT SUBMIT END*/ -%mend macro2; -""" - -content_source_q1 = """ -proc datasets library = work memtype = data kill noprint; -quit; -""" - -content_source_q2 = """ -proc datasets library = work memtype = data kill noprint; -quit; -""" - -content_source_fcmp = """ -proc datasets library = work memtype = data kill noprint; -quit; -/*SUBMIT BEGIN*/ -proc fcmp outlib = work.funcs.funcs; - function add(x, y); - return (x + y); - endsub; -quit; -/*SUBMIT END*/ -proc report; -quit; -""" - -content_source_other = """ -I'm not a SAS file. -""" diff --git a/tests/contents_validate.py b/tests/contents_validate.py deleted file mode 100644 index 7af4cad..0000000 --- a/tests/contents_validate.py +++ /dev/null @@ -1,80 +0,0 @@ -# 定义测试用例的验证文件内容 - -content_validate_adsl = """ -proc sql; - create table adal as select * from rawdata.fp; -quit; -""" - -content_validate_addv = """ -proc sql; - create table adae as select * from rawdata.ae; -quit; -""" - -content_validate_t1 = """ -proc sql; - create table t1 as select * from adam.adsl; -quit; -""" - -content_validate_t2 = """ -proc sql; - create table t2 as select * from adam.adeff; -quit; -""" - -content_validate_t3 = """ -%macro inner_macro; - proc sql; - select * from adsl; - quit; -%mend inner_macro; -%inner_macro; -proc sql noprint; - select * from adae; -quit; -""" - -content_validate_t4 = """ -proc datasets library = work memtype = data kill noprint; -quit; - -%let id = %str(); - -/*====SUBMIT BEGIN====*/ -proc sql; - create table t4 as select * from adam.adeff&id; -quit; - -proc report; -quit; -""" - -content_validate_macro1 = """ -%macro macro1; - proc sql; - select * from adsl; - quit; -%mend macro1; -""" - -content_validate_macro2 = """ -%macro macro2; - proc sql; - select * from adae; - quit; -%mend macro2; -""" - -content_validate_macro3 = """ -%macro macro2; - proc sql; - select * from adae; - quit; - - proc report; - quit; - /*NOT SUBMIT END*/ -%mend macro2; -""" diff --git a/tests/test_submit.py b/tests/test_submit.py index 588a544..6fa9cea 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -1,54 +1,126 @@ -import argparse -import re from pathlib import Path -import pytest +from click.testing import CliRunner -from submit import ConvertMode, copy_file, copy_directory, parse_dict +from submit.submit import cli -class TestSubmit: - def test_convert_mode(self): - assert str(ConvertMode.POSITIVE) == "positive" - assert str(ConvertMode.NEGATIVE) == "negative" - assert str(ConvertMode.BOTH) == "both" +def test_copyfile(dummy_sas_dir: Path, tmp_path: Path) -> None: + """测试 copyfile 命令""" - assert ConvertMode.get_from_str("positive") == ConvertMode.POSITIVE - assert ConvertMode.get_from_str("negative") == ConvertMode.NEGATIVE - assert ConvertMode.get_from_str("both") == ConvertMode.BOTH - with pytest.raises(argparse.ArgumentTypeError): - ConvertMode.get_from_str("invalid") + runner = CliRunner() - assert ConvertMode.get_available_values() == [ConvertMode.POSITIVE, ConvertMode.NEGATIVE, ConvertMode.BOTH] + sas_file = dummy_sas_dir / "t_6_1.sas" + txt_file = tmp_path / "t_6_1.txt" - def test_copy_file(self, source_directory: Path, validate_directory: Path, tmp_path: Path): - test_adsl = source_directory / "adam" / "adsl.sas" - validate_adsl = validate_directory / "adam" / "adsl.txt" + # 使用 runner.invoke 触发命令 + result = runner.invoke( + cli, + [ + "copyfile", + "-s", + str(sas_file), + "-t", + str(txt_file), + ], + ) - tmp_adsl = tmp_path / "adam" / "adsl.txt" - copy_file(test_adsl, tmp_adsl) + assert result.exit_code == 0 - with open(tmp_adsl, "r", encoding="utf-8") as f: - tmp_code = f.read() - with open(validate_adsl, "r", encoding="utf-8") as f: - validate_code = f.read() + assert txt_file.exists() + content = txt_file.read_text(encoding="gbk") + assert "proc print data=sashelp.class;" in content + assert "data _null_;" not in content - assert re.sub(r"\s*", "", tmp_code) == re.sub(r"\s*", "", validate_code) - def test_copy_directory(self, source_directory: Path, validate_directory: Path, tmp_path: Path): - copy_directory( - source_directory, tmp_path, exclude_dirs=["other"], exclude_files=["fcmp.sas"], macro_subs={"id": ""} - ) - copy_directory(source_directory / "macro", tmp_path / "macro", convert_mode=ConvertMode.NEGATIVE) +def test_copydir_with_exclude_file(dummy_sas_dir: Path, tmp_path: Path) -> None: + """测试 copydir 命令,带上 --exclude-file 参数""" - for validate_file in validate_directory.rglob("*.txt"): - validate_code = validate_file.read_text() - tmp_code = (tmp_path / validate_file.relative_to(validate_directory)).read_text() + runner = CliRunner() - assert re.sub(r"\s*", "", tmp_code) == re.sub(r"\s*", "", validate_code) + txt_dir = tmp_path / "txt_out" - def test_parse_dict(self): - assert parse_dict("{a=1}") == {"a": "1"} - assert parse_dict("{a=1, b=2}") == {"a": "1", "b": "2"} - with pytest.raises(argparse.ArgumentTypeError): - parse_dict("{a=1{,} b=2}") + # 运行 copydir 命令,排除以 deprecated 开头的文件 + result = runner.invoke( + cli, + [ + "copydir", + "-s", + str(dummy_sas_dir), + "-t", + str(txt_dir), + "--exclude-file", + "deprecated*.sas", + ], + ) + + assert result.exit_code == 0 + + # t_6_1.sas 没有被排除 -> 应该生成对应的 txt + assert (txt_dir / "t_6_1.txt").exists() + + # deprecated_t_8_1.sas 被排除了 -> 不应该生成对应的 txt + assert not (txt_dir / "deprecated_t_8_1.txt").exists() + + +def test_copydir_with_exclude_dirs(dummy_sas_dir: Path, tmp_path: Path) -> None: + """测试 copydir 命令,带上 --exclude-dir 参数""" + + runner = CliRunner() + + txt_dir = tmp_path / "txt_out" + + # 运行 copydir 命令,排除以 deprecated 开头的文件 + result = runner.invoke( + cli, + [ + "copydir", + "-s", + str(dummy_sas_dir), + "-t", + str(txt_dir), + "--exclude-dir", + "sponser_only", + ], + ) + + assert result.exit_code == 0 + + # t_7_1.sas 被排除了 -> 不应该生成对应的 txt + assert not (txt_dir / "t_7_1.txt").exists() + + +def test_copydir_with_merge(tmp_path: Path, dummy_sas_dir: Path) -> None: + """测试 copydir 命令,带上 --merge 参数""" + + runner = CliRunner() + + txt_dir = tmp_path / "txt_out" + + # 运行 copydir 命令,排除以 deprecated 开头的文件 + result = runner.invoke( + cli, + [ + "copydir", + "-s", + str(dummy_sas_dir), + "-t", + str(txt_dir), + "--exclude-dir", + "sponser_only", + "--exclude-file", + "deprecated*.sas", + "--merge", + "--merge-name", + "合并代码.txt", + ], + ) + + assert result.exit_code == 0 + + merge_file = txt_dir / "合并代码.txt" + assert merge_file.exists() + + # 验证合并代码中的内容是否包含特定字符串 + merge_content = merge_file.read_text(encoding="gbk") + assert "/*====================t_6_1.sas====================*/" in merge_content