diff --git a/.claude/rules/abap-coding-rules.md b/.claude/rules/abap-coding-rules.md new file mode 100644 index 0000000..c7787d4 --- /dev/null +++ b/.claude/rules/abap-coding-rules.md @@ -0,0 +1,120 @@ +# ABAP 编码规则(质检标准) + +> 维护者:Rangyu | 版本:v1.0 | 2026-08-05 +> +> 本规则文档由 **Rangyu(老板)维护**,质检员在代码审计时以此文档为唯一标准。 +> 质检结果分为:✅合规 / ❌违规(必须修复) / ⚠️建议(推荐修复) + +--- + +## 1. 安全规则(Security) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| S01 | 生产代码中**禁止硬断点**(BREAK-POINT) | ❌违反 | `BREAK-POINT.` → 删除 | +| S02 | 关键数据访问前**必须 AUTHORITY-CHECK** | ❌违反 | 聊天记录读取、配置维护、表维护 | +| S03 | SQL 注入防护:**所有变量必须用 @ 转义** | ✅合规 | `WHERE field = @lv_var` | +| S04 | 敏感数据(聊天内容、用户信息)需脱敏或审计 | ⚠️建议 | `zidt_chatlog` message_body 字段 | +| S05 | | | | +| S06 | | | | +| S07 | | | | + +*(Rangyu 补充更多安全规则...)* + +--- + +## 2. 性能规则(Performance) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| P01 | 禁止 `SELECT *`,仅查询所需字段 | ⚠️建议 | `SELECT field1, field2 FROM ...` | +| P02 | 禁止无 WHERE 条件的全表扫描(小配置表除外) | ⚠️建议 | `SELECT ... FROM ztable WHERE ...` | +| P03 | 大表查询必须有索引支撑 | ⚠️建议 | SE11 检查索引 | +| P04 | 循环内禁止 SQL 查询 | ❌违反 | LOOP 内 SELECT | +| P05 | 循环字符串拼接用 `CONCATENATE` 单次操作 | ⚠️建议 | 避免 LOOP 内 `&&` | +| P06 | | | | +| P07 | | | | + +*(Rangyu 补充更多性能规则...)* + +--- + +## 3. 可维护性规则(Maintainability) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| M01 | 重复代码超过 80% 相似 → 提取公共方法/FORM | ❌违反 | 3处 AI 调用重复约 90% | +| M02 | 每个 FORM/方法不超过 200 行 | ⚠️建议 | | +| M03 | 禁止**裸硬编码字符串**(模型名、GUI状态、事务码) | ⚠️建议 | 用常量 `CONSTANTS c_model TYPE ... VALUE 'qwq-32b'` | +| M04 | 关键逻辑必须有注释 | ⚠️建议 | | +| M05 | 无用变量/死代码必须清理 | ⚠️建议 | `gs_tool-END` 赋值未消费 | +| M06 | | | | +| M07 | | | | + +*(Rangyu 补充更多可维护性规则...)* + +--- + +## 4. 错误处理规则(Error Handling) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| E01 | `sy-subrc <> 0` 后**不能静默处理** | ❌违反 | `IF sy-subrc <> 0. " 错误处理 ENDIF.` → 必须 MESSAGE/EXIT | +| E02 | API 调用必须有异常保护(CATCH) | ⚠️建议 | HTTP 调用、RFC 调用 | +| E03 | 数据库操作必须有错误处理 | ⚠️建议 | COMMIT/ROLLBACK | +| E04 | **CALL FUNCTION 前必须核实被调对象真实存在**:ADT 通道无法校验被调函数与引用结构是否存在(syntax-check 只覆盖当前对象自身),凡调用 `CALL FUNCTION 'Z...'` 或引用 `Z...` 结构/表类型,必须先用 sap-cli(`info`/`download`/`read-table DD02L` 任一)确认对象存在且名称逐字符一致,再落代码 | ❌违反 | 凭记忆写 `CALL FUNCTION 'ZMM_BIP_CREATE'`(实际对象是 `ZMM_BIP_VENDOR_CREATE`)→ 编译期不报错、运行期 CALL_FUNCTION_NOT_FOUND 转储。正确:先 `sap-cli info --name 组名/函数名 --type function` 查实,再写调用 | +| E05 | **CALL FUNCTION 的参数结构必须与被调函数真实签名一致**:IMPORTING/EXPORTING/CHANGING/TABLES 各参数名、类型、顺序以核实到的函数签名为准(info 输出或 download 函数源码的 Interface 节),不得按猜测拼参数;引用的 DDIC 结构(如 `TYPE zmm_bip_001_in`)须同批核实结构存在且组件名/类型一致 | ❌违反 | `CALL FUNCTION 'X' EXPORTING iv_a = lv` 但实际签名参数是 `IS_IMPORT` → 运行期 PARAMETER_NOT_FOUND/类型不匹配转储。正确:以 `download` 拿到函数头 `"*" Local Interface: 注释节为准逐参数比对,结构组件以 DD02L/DD03L 实查为准 | + +*(Rangyu 补充更多错误处理规则...)* + +> **E04/E05 背景(2026-09-10 老板指令)**:远程语法检查不校验被调函数的存在性与签名匹配——本地语法全绿 ≠ 运行期不转储。开发报表/程序中所有 `CALL FUNCTION`(尤其调用本组外/客户化 Z 对象)一律先核实后调用,核实记录(对象存在性 + 签名比对)纳入交付物。 + +--- + +## 5. NW 7.40 兼容规则(7.40 Compatibility) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| C01 | **禁止 `DATA(...)` inline 声明** | ❌违反 | `DATA(lv_x) = ...` → `DATA lv_x TYPE ...` | +| C02 | Open SQL `@` 转义**全局一致** | ❌违反 | 不能混用 `@lv` 和 `lv` | +| C03 | 禁止字符串模板 `\|...\|` | ❌违反 | 用 `CONCATENATE` 代替 | +| C04 | 禁止 `WITH EMPTY KEY` | ❌违反 | 用 `WITH DEFAULT KEY` | +| C05 | LOOP AT SCREEN 逻辑避免重复 | ⚠️建议 | 提取公共 FORM | +| C06 | | | | +| C07 | | | | + +*(Rangyu 补充更多 7.40 兼容规则...)* + +--- + +## 6. 代码风格规则(Style) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| T01 | 命名遵循 SAP 规范(Z开头自定义) | ✅合规 | `ZIDTR_AI_ASSISTANT` | +| T02 | 变量命名语义化 | ⚠️建议 | `lv_date` 优于 `lv_d` | +| T03 | 注释语言统一(中/英) | ⚠️建议 | | +| T04 | | | | +| T05 | | | | + +*(Rangyu 补充更多风格规则...)* + +--- + +## 附录:今天质检发现的典型违规 + +| 程序 | 问题 | 规则ID | 行号 | +|------|------|--------|------| +| ZIDTR_AI_ASSISTANT | 生产代码 BREAK-POINT | S01 | L44 | +| ZIDTR_AI_ASSISTANT | 权限检查缺失 | S02 | 全文 | +| ZIDTR_AI_ASSISTANT | 3处代码重复~90% | M01 | L464-667 | +| ZIDTR_AI_ASSISTANT | 硬编码模型名 | M03 | L207 | +| ZIDTR_AI_ASSISTANT | F4错误静默处理 | E01 | L170-172 | +| ZIDTR_AI_ASSISTANT | DATA(...) inline | C01 | L152等6处 | +| ZIDTR_AI_ASSISTANT | @转义混用 | C02 | L154/259 | + +--- + +> **使用方式**:质检员读取本文件 → 逐条对照代码 → 输出 `✅/❌/⚠️` 审计报告。 +> +> **维护方式**:Rangyu 随时增删规则,更新后通知小迅同步到质检员的审查标准。 diff --git a/.claude/rules/error-handling.md b/.claude/rules/error-handling.md new file mode 100644 index 0000000..f2a71c0 --- /dev/null +++ b/.claude/rules/error-handling.md @@ -0,0 +1,121 @@ +# 错误处理流程 + +sap-cli 命令失败时的标准处理流程。严格按顺序执行。 + +## 通用流程 + +``` +命令失败 + ↓ +1. 诊断根因(使用方式错误 or 代码/环境问题?) + ↓ +2. 使用方式错误 → 修正参数后重试 + ↓ +3. sap-cli 限制 → 报告失败,不绕过 + ↓ +4. SAP 系统限制 → 穷尽自动化方案 + ↓ +5. 确认无法自动化 → 请求用户 SAP GUI 操作 +``` + +## 步骤 1:诊断根因 + +检查以下项目: + +| 检查项 | 方法 | +|--------|------| +| 命令参数是否正确 | 对照 `python main.py --help` 和 SKILL.md 验证参数 | +| 文件是否存在、内容是否为空 | `cat` 或 `ls` 检查 | +| 传输请求号是否正确 | `python main.py transport info --corr_nr DEVK901XXX` | +| 网络连接是否正常 | `python main.py config show` 验证配置 | +| 对象是否存在于 SAP | `python main.py info --name XXX --type class` | + +## 步骤 2:使用方式错误 + +修正后重试。常见修正: + +- 缺少 `--corr_nr` → 加上传输请求号 +- 对象名大小写错误 → sap-cli 会自动处理,但路径要注意 +- 文件路径错误 → 检查相对/绝对路径 +- 对象类型不匹配 → 用 `python main.py --help` 确认支持的类型 + +## 步骤 3:sap-cli 限制 + +如果确认不是使用方式问题,**如实报告错误信息**,不要尝试绕过 sap-cli: + +``` +❌ 错误做法:写 Python requests 脚本绕过 +❌ 错误做法:用 curl 直接调 ADT 端点 +❌ 错误做法:导入 ADTClient 调内部方法 +✅ 正确做法:报告失败,说明原因 +``` + +## 步骤 4:穷尽自动化方案(SAP 系统限制时) + +遇到 SAP 系统限制时,按以下顺序穷尽自动化方案: + +### 方案 A:调整 sap-cli 参数重试 + +- 尝试不同的 `--corr_nr` +- 尝试 `--dry-run` 预览后执行 +- 检查是否有残留锁(`show-table` 查看 SM12 相关信息) + +### 方案 B:ABAP 报表 workaround + +通过 `run-program` 执行辅助报表: + +```bash +# 清除残留锁 +python main.py sync --type report --name ZSAPILOT_CLEAR_LOCKS --path --corr_nr DEVK901XXX +python main.py run-program --name ZSAPILOT_CLEAR_LOCKS +``` + +### 方案 C:delete + recreate + +如果对象需要完全重写: + +```bash +python main.py delete --name ZMY_CLASS --type class +python main.py sync --name ZMY_CLASS --type class --path --corr_nr DEVK901XXX +``` + +注意:delete 可能因传输请求绑定而失败(HTTP 423),此时需要用户在 SE09 操作。 + +### 方案 D:activate 单独重试 + +```bash +python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX +``` + +NW 7.40 的 double-activate 已内置在 sap-cli 中。如果 activate 仍然失败,**不要再尝试其他激活策略**,直接报告给用户。 + +## 步骤 5:请求用户 SAP GUI 操作 + +**只在确认所有自动化方案穷尽后**才请求用户操作。 + +### 必须一次性给出完整操作清单 + +当需要用户在 SAP GUI 操作时,**绝不能渐进式发现问题**: + +- ❌ 错误:先让用户加字段 A,测试后发现缺字段 B,又让用户加字段 B +- ✅ 正确:先做全面对比分析(`show-table` vs 代码中的字段引用),一次性给出完整变更清单 + +### 系统表操作的 4 步授权流程 + +如果需要操作系统表(唯一的例外情况): + +1. **解释原因** — 为什么需要操作系统表 +2. **列出具体操作** — 表名 + 操作类型(如 `DELETE FROM SEOCLASS WHERE ...`) +3. **说明风险和替代方案** — 可能的副作用、有没有不操作系统表的替代方案 +4. **等待用户确认** — 得到明确授权后才执行 + +## 常见失败场景速查 + +| 错误 | 常见原因 | 处理方式 | +|------|----------|----------| +| HTTP 406 (Lock DDIC) | NW 7.40 不支持 DDIC 对象 ADT Lock | 报告用户,SE09 处理 | +| HTTP 403 (Locked) | 残留 enqueue lock | 先尝试 `run-program` 清锁报表,不行才 SM12 | +| HTTP 400 (SaveFailure) | 类 DEFINITION 与 SAP 不一致 | 检查本地 vs SAP 的 DEFINITION 差异 | +| HTTP 423 (Transport lock) | 对象绑定在传输请求中 | 报告用户,SE09 处理 | +| 激活失败仍 inactive | NW 7.40 ADT 限制 | sap-cli 已内置 double-activate,仍失败则报告用户去 SE09 | +| `WITH EMPTY KEY` 运行 dump | NW 7.40 不支持 | 修改为 `WITH NON-UNIQUE KEY` 或 `WITH DEFAULT KEY` | diff --git a/.claude/rules/sap-tool-constraints.md b/.claude/rules/sap-tool-constraints.md new file mode 100644 index 0000000..53e5255 --- /dev/null +++ b/.claude/rules/sap-tool-constraints.md @@ -0,0 +1,135 @@ +# sap-cli 使用约束 + +本文件定义使用 sap-cli 操作 SAP 系统时的 **硬性约束**。违反任何一条都会被 Hooks 拦截。 + +## 🚫 规则 1:只使用 sap-cli 命令操作 SAP 系统 + +操作 SAP 系统时,**必须且只能**使用 `python main.py <命令>` 的形式。 + +**严禁以下行为**: + +| 禁止操作 | 原因 | +|----------|------| +| 用 Python `requests` / `urllib` 直接调 ADT REST 端点 | 必须通过 sap-cli 命令 | +| 用 curl 直接调 ADT 端点(`/sap/bc/adt/*`) | 必须通过 sap-cli 命令 | +| 用 curl 调 SOAP RFC(`/sap/bc/soap/rfc`) | 必须通过 sap-cli 的 `read-table` 命令 | +| 编写 Python 脚本导入 `sapcli.client.ADTClient` 后直接调内部方法 | sap-cli 的内部 API 不是公开接口 | +| 在 Python 脚本中手动构造 ADT XML body | 必须通过 sap-cli 命令处理 | +| 用 `python -c` 内联调用 requests 访问 SAP | 同上 | + +**唯一合法的 SAP 交互方式**: + +```bash +cd D:/Gitee/sap-cli-skill/assets && python main.py [options] +``` + +> 本机唯一源目录为 `D:/Gitee/sap-cli-skill`,工具在其 `assets/` 下(原 `D:/Gitee/sap-cli` 已归档,勿再用)。通过 `--profile <名>` 切换目标系统,连接配置在 `assets/config.ini`。 + +### 例外(仅在以下情况下允许) + +- `run-program` 执行自定义 ABAP 报表 — 这是 sap-cli 的内置功能 +- 用户**明确要求**使用其他方式(如"请用 curl 测试这个端点") +- **开发 sap-cli 本身**(作为项目开发而非使用工具时) + +## 🚫 规则 2:禁止修改 SAP 标准开发对象 + +SAP 系统中的标准交付对象(`CL_*`、`CX_*`、`IF_*`、`SAPL*` 等)**严禁修改**。 + +| 禁止 | 说明 | +|------|------| +| `download` 标准对象后修改再 `sync` 回去 | 破坏 SAP 系统一致性 | +| 对标准对象执行 `delete` | 不可逆操作 | +| `run-program` 修改标准对象源码 | 同上 | + +**允许**(只读): + +- ✅ `download` / `info` / `list` / `search` / `whereused` +- ✅ `show-table` / `read-table` — 查看结构和数据 +- ✅ `diff` — 对比分析 + +**判定标准**:对象 original system 不是用户自己的开发系统,或对象名以 SAP 标准命名空间开头。**不确定时先问用户**。 + +## 🚫 规则 3:禁止操作系统表数据 + +SAP 系统表(存储元数据、运行时状态、内部配置的表)**严禁直接操作**。 + +| 禁止 | 典型表 | +|------|--------| +| INSERT / UPDATE / DELETE / MODIFY 系统表 | TADIR, E071, SEOCLASS, REPOSRC, D010SINF, T000, TDEVC, TSTC | + +**允许**(只读): + +- ✅ `read-table` / `show-table` 查看(SELECT)系统表数据用于诊断 +- ✅ `run-program` 报表中 SELECT 读取系统表 + +**唯一例外**:用户**明确授权**后才能操作系统表,且必须遵循 4 步授权流程(见 error-handling.md)。 + +## 🚫 规则 4:sync 命令必须带 --corr_nr + +```bash +# ✅ 正确 +python main.py sync --name ZMY_CLASS --type class --path ./src/zmy.abap --corr_nr DEVK901362 + +# ❌ 错误(缺少 --corr_nr,会触发交互式传输请求选择) +python main.py sync --name ZMY_CLASS --type class --path ./src/zmy.abap +``` + +## 🚫 规则 5:DDIC 对象的 NW 7.40 限制 + +在 NW 7.40 上,DDIC 对象(domain、dataelement、table)的 ADT Lock 返回 HTTP 406。 + +- 这是 **SAP 系统限制**,不是 sap-cli 的 bug +- sync DDIC 失败时,不要反复重试 — 报告给用户,让用户通过 SAP GUI SE09 处理 +- 新建的 DDIC 对象(不存在于 SAP)可通过 `create --definition` 创建 + +## 🚫 规则 6:查表数据用 read-table,查表结构用 show-table + +sap-cli 已内置这些命令,不要自己写 HTTP 调用。 + +```bash +# 查看表结构 +python main.py show-table --name ZSAPILOT_OBJ + +# 查看表数据 +python main.py read-table --name ZSAPILOT_OBJ --max-rows 50 +``` + +## 🚫 规则 7:DDIC 未完成禁止开发引用它的代码(依赖顺序) + +代码引用的每个 DDIC 对象(域/数据元素/表/结构/表类型)必须先完成「上传 → 语法检查通过 → 激活成功」三关,才能开始写引用它的代码。 + +- 落地顺序:`domain → dataelement → table/structure → tabletype → interface → class → function → report` +- 是否完成**以实测为准**:结构查 `DD02L`(`AS4LOCAL = 'A'`)、表类型查 `DD40L`、其余查 `info` / `TADIR`;**不采信会话自述** +- 违反后果:后续每个引用对象语法检查报「类型/结构 XXX 未知」,整批返工 + +## 🚫 规则 8:强制开发闭环(不得跳步) + +``` +1. 本地编写/修改源码 → 2. 同步 SAP(upload/sync) → 3. 语法检查(syntax-check) + ↑ │ + └──────────────── 有错则回到第 1 步 ←───────────────────┘ +4. 语法检查 0 错误 → 激活(activate) → 5. 激活成功后才允许执行测试(run-program/unit-test) + └── 测试不过 → 回到第 1 步 +``` + +禁止: + +- ❌ 跳过语法检查直接 activate +- ❌ 未激活就 run-program / unit-test(跑旧版本或报对象不存在) +- ❌ 把「语法检查通过」当「功能正常」—— syntax-check 不校验被调对象存在性与签名(见 `abap-coding-rules.md` E04/E05) +- ❌ 把「S4T 演示机通过」表述为「CEM/生产验证通过」 +- ❌ 多会话并发操作同一批对象(派下一会话前先确认前一个已退出) + +## 自检清单 + +每次执行 SAP 操作前,确认: + +1. ✅ 我在用 `python main.py <命令>` 吗? +2. ✅ 我没有直接调 ADT/SOAP/RFC 端点吗? +3. ✅ 我操作的是 Z* 开头的自定义对象吗? +4. ✅ 我没有 INSERT/UPDATE/DELETE 系统表吗? +5. ✅ sync 命令带了 `--corr_nr` 吗? +6. ✅ 我引用的 DDIC 对象都已激活(实测确认)了吗? +7. ✅ 我按「本地改 → 同步 → 语法检查 → 循环至 0 错误 → 激活 → 测试」执行了吗? +8. ✅ 我核实过被调函数/结构的存在性与签名了吗(E04/E05)? +9. ✅ 同一任务只有一个开发者会话在跑吗? diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2184ee9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,57 @@ +{ + "permissions": { + "deny": [ + "Bash(curl *sap/bc/adt*)", + "Bash(curl *sap/bc/soap*)", + "Bash(curl *sap/bc/webdynpro*)", + "Bash(python -c *requests*sap/bc*)", + "Bash(python -c *urllib*sap/bc*)" + ], + "allow": [ + "Bash(python main.py *)", + "Bash(python -m sapcli *)", + "Bash(python -m unittest *)", + "Bash(python -m pytest *)", + "Bash(python -m json.tool)", + "Bash(git *)", + "Bash(cat *)", + "Bash(ls *)", + "Bash(python tests/test_e2e.py*)", + "Read", + "Edit", + "Write" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qiE 'curl.*(sap/bc/adt|sap/bc/soap|sap/bc/webdynpro)'; then echo '⛔ BLOCKED: Direct ADT/SOAP/WebDynpro access is prohibited. Use sap-cli commands (python main.py ).' && exit 2; fi" + }, + { + "type": "command", + "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qiE 'python.*-c.*(requests|urllib).*sap.*/bc/'; then echo '⛔ BLOCKED: Direct HTTP requests to SAP are prohibited. Use sap-cli commands (python main.py ).' && exit 2; fi" + }, + { + "type": "command", + "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qiE 'from sapcli.client import|from sapcli.client._'; then echo '⛔ BLOCKED: Importing sap-cli internal modules is prohibited for SAP operations. Use CLI commands (python main.py ).' && exit 2; fi" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash(python main.py sync*)", + "hooks": [ + { + "type": "command", + "command": "echo '📋 Sync completed. Check output for activation status. If activation failed, try: python main.py activate --name --type --corr_nr '" + } + ] + } + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e55cf87 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig: https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,toml,cfg,ini}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d999740 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +# 注意:本工作流只在 GitHub Actions 上执行。 +# 本仓托管在 Gitee,此文件不会自动运行——真正的提交门禁是本地 pre-commit +# (见 .pre-commit-config.yaml 的 sapcli-unit-tests)。若将来迁到 GitHub, +# 此文件即可直接生效。 +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + # 工具源码与 pyproject 位于 assets/(本仓同时是技能包与工具源码仓) + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e "./assets[dev]" + + - name: Run tests + run: python -m unittest discover -s tests -p "test_*.py" -v + + - name: Coverage check + run: | + pip install coverage + coverage run -m unittest discover -s tests -p "test_*.py" + coverage report --include="assets/sapcli/*" --fail-under=70 diff --git a/.gitignore b/.gitignore index 85450c5..587b3bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,41 @@ -assets/**/__pycache__/ -assets/log/ +# ── Python ── +__pycache__/ +*.py[cod] +*$py.class + +# ── 打包/安装产物 ── +dist/ +build/ +*.egg-info/ +*.egg + +# ── 测试/覆盖率 ── +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +coverage_html/ +coverage.xml +test_output/ + +# ── SAP 连接配置(含凭据,绝不入库)── +config.ini assets/config.ini -*.pyc + +# ── 运行期产物(log/ 下的 md 知识库要入库,只有运行日志忽略)── +assets/log/ +log/adt_tools.log + +# ── 编辑器/系统 ── +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# ── 评审产物 ── +REVIEW_REPORT.md + +# ── Claude Code 本地设置(含机器相关权限,不入库)── +.claude/settings.local.json diff --git a/.hermes/include-uri-fix-spec.md b/.hermes/include-uri-fix-spec.md new file mode 100644 index 0000000..e9a7497 --- /dev/null +++ b/.hermes/include-uri-fix-spec.md @@ -0,0 +1,89 @@ +# Spec: 修复 include 类型的 ADT URI 端点 + +## 问题描述 +`download --type include` 在 NW 7.40 上失败,返回"对象不存在"。 + +## 根因分析 +`types.py` 中 INCLUDE 的 URI 模板复用了 REPORT 的端点: +- 当前(错误):`/sap/bc/adt/programs/programs/{name}` +- 正确端点:`/sap/bc/adt/programs/includes/{name}` + +NW 7.40 ADT 对 include 和 report 使用不同的 REST 端点: +- report → `/sap/bc/adt/programs/programs/{name}` +- include → `/sap/bc/adt/programs/includes/{name}` + +## 已验证的正确端点 +| 用途 | URI | +|------|-----| +| metadata (object_exists) | `GET /sap/bc/adt/programs/includes/{name}` Accept: `application/vnd.sap.adt.programs.includes.v2+xml` | +| source (get_source) | `GET /sap/bc/adt/programs/includes/{name}/source/main` Accept: `text/plain` | + +## 修改范围 + +### 文件 1: `sapcli/types.py` (第 130-137 行) + +修改前: +```python +INCLUDE = _register(ObjectTypeConfig( + key="include", + label="Include程序(INCLUDE)", + obj_uri_template="/sap/bc/adt/programs/programs/{name}", + src_uri_template="/sap/bc/adt/programs/programs/{name}/source/main", + collection_uri="/sap/bc/adt/programs/programs", + create_content_type="application/vnd.sap.adt.programs.v2+xml", +)) +``` + +修改后: +```python +INCLUDE = _register(ObjectTypeConfig( + key="include", + label="Include程序(INCLUDE)", + obj_uri_template="/sap/bc/adt/programs/includes/{name}", + src_uri_template="/sap/bc/adt/programs/includes/{name}/source/main", + collection_uri="/sap/bc/adt/programs/includes", + create_content_type="application/vnd.sap.adt.programs.includes.v2+xml", +)) +``` + +### 文件 2: `sapcli/client/_base.py` (object_exists 方法) + +当前 `object_exists` 使用 `Accept: */*`,对于 include 端点可能需要特定的 Accept header。但根据测试,`*/*` 也能返回 200,所以这个方法可能不需要修改。如果 object_exists 对 include 返回 406,需要改为使用 `application/vnd.sap.adt.programs.includes.v2+xml`。 + +### 文件 3: `sapcli/commands/crud.py` + +`cmd_download` 和 `cmd_sync` 中有硬编码检查: +```python +if parsed.src_uri is None: + print(f"\n ✗ {type_label}没有源代码,不支持 download 操作") + print(f" functiongroup 是函数模块的容器,不包含可编辑的源代码文件") +``` +第二行错误提示只适用于 functiongroup,include 走不到这个分支(因为 src_uri 不是 None),所以不需要修改。 + +## 验证标准 + +修改后,以下命令必须能正常工作: + +```bash +# 1. 下载 include 源代码 +python main.py download --name LZIDT_MCP_TOOLU04 --type include --path /tmp/test + +# 2. 下载各种 include +python main.py download --name LZIDT_MCP_TOOLTOP --type include --path /tmp/test +python main.py download --name LZIDT_MCP_TOOLF01 --type include --path /tmp/test + +# 3. 确认原有 report 类型不受影响 +python main.py download --name ZIDT_MCP_LOG --type report --path /tmp/test +``` + +## 测试 + +运行现有测试确保无回归: +```bash +python -m pytest tests/ -v +``` + +## 约束 +- 只修改 `sapcli/types.py` 中的 INCLUDE 配置(4 个 URI 字段) +- 不修改 `_base.py`、`_source.py` 或其他文件的通用逻辑 +- 不引入新的依赖 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a91fdde --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,45 @@ +# Pre-commit hooks configuration +# See https://pre-commit.com for more information +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + args: ['--maxkb=1024'] # docs/assets/architecture.drawio.png 约 512KB + - id: check-merge-conflict + - id: no-commit-to-branch + args: ['--branch', 'main'] + + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: ['--profile', 'black'] + + # 本仓托管在 Gitee,.github/workflows 不会执行——测试门禁落在本地 pre-commit。 + # 安装:pip install pre-commit && pre-commit install + - repo: local + hooks: + - id: sapcli-unit-tests + name: sap-cli 全量测试(698 例) + entry: python -m unittest discover -s tests -p "test_*.py" + language: system + pass_filenames: false + files: '\.py$' + + - repo: https://github.com/pycqa/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + args: ['--max-line-length=120', '--extend-ignore=E501,W503'] + additional_dependencies: [flake8-bugbear] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eb4a84e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md + +sap-cli 是一个 Python CLI 工具,通过 SAP ADT REST API 在本地编辑器编写 ABAP 代码并同步到 SAP 系统。 + +**版本**: 2.3.0 | **命令数**: 26 | **对象类型**: 16 + +## 运行方式 + +```bash +cd D:/Codespace/sap-cli +python main.py [options] +# 或 +python -m sapcli [options] +``` + +## 规则体系(必须遵守) + +| 规则文件 | 核心内容 | +|----------|----------| +| [`sap-tool-constraints.md`](.claude/rules/sap-tool-constraints.md) | 🚫 禁止绕过 sap-cli 直接操作 SAP、禁止修改标准对象、禁止操作系统表 | +| [`error-handling.md`](.claude/rules/error-handling.md) | 命令失败时的诊断流程和处理原则 | + +> ⚠️ **这些规则由 Hooks 强制执行**,违反会被直接拦截。 + +## 命令速查 + +### CRUD(对象生命周期) + +```bash +python main.py create --name ZMY_CLASS --type class --corr_nr DEVK901XXX +python main.py delete --name ZMY_CLASS --type class +python main.py download --name ZMY_CLASS --type class --path ./out +python main.py info --name ZMY_CLASS --type class +python main.py sync --name ZMY_CLASS --type class --path ./src/classes/zmy_class.abap --corr_nr DEVK901XXX +python main.py activate --name ZMY_CLASS --type class +``` + +### 批量操作 + +```bash +python main.py init --path D:/Codespace/SAPilot/src +python main.py refresh --path D:/Codespace/SAPilot/src +python main.py sync --all --path D:/Codespace/SAPilot/src --corr_nr DEVK901362 +python main.py sync --all --path D:/Codespace/SAPilot/src --corr_nr DEVK901362 --dry-run +``` + +### 数据查询 + +```bash +python main.py show-table --name ZSAPILOT_OBJ # 表结构(字段列表) +python main.py read-table --name ZSAPILOT_OBJ # 表数据(freestyle SQL) +python main.py read-table --name ZSAPILOT_OBJ --max-rows 50 +python main.py list --type class --package ZSAPILOT # 列出对象 +python main.py whereused --name ZMY_CLASS --type class # Where-Used +python main.py search --pattern "sapilot" --type class # 源码搜索 +``` + +### 远程执行 + +```bash +python main.py run-program --name ZSAPILOT_SETUP # 执行 ABAP 程序 +python main.py diff --name ZMY_CLASS --type class --path ./src/classes/zmy_class.abap +``` + +### 传输请求 + +```bash +python main.py transport list +python main.py transport info --corr_nr DEVK901362 +python main.py transport release --corr_nr DEVK901362 +python main.py transport objects --corr_nr DEVK901362 +``` + +### 配置与认证 + +```bash +python main.py config show +python main.py config list-profiles +python main.py config set host s4h.example.com +python main.py auth login +python main.py auth status +``` + +### 其他 + +```bash +python main.py check --name ZMY_CLASS --type class # ATC 代码检查 +python main.py format --name ZMY_CLASS --type class # Pretty Printer +python main.py package info --name ZSAPILOT +python main.py cds download --name ZMY_CDS +python main.py analyze --path ./src # 依赖分析 +python main.py scaffold --type alv-report --name ZMY_RPT # 项目模板 +``` + +## 支持的对象类型(16 种) + +`report` `class` `interface` `function` `functiongroup` `domain` `dataelement` `table` `structure` `tabletype` `include` `cdsview` `messageclass` `view` `searchhelp` `lockobject` + +## 关键参数 + +| 参数 | 说明 | +|------|------| +| `--corr_nr DEVK901XXX` | 传输请求号(必带,否则触发交互提示) | +| `--path ` | 本地文件或项目目录路径 | +| `--dry-run` | 预览不执行 | +| `--definition ` | DDIC 对象创建定义(JSON 文件) | +| `--profile ` | 使用指定配置 profile | +| `--verify-ssl` | 启用 SSL 验证(默认关闭) | + +## 批量同步依赖排序 + +``` +domain(10) → dataelement(20) → table(30) → tabletype(40) → interface(50) → class(60) → function(70) → report(80) +``` + +## 配置文件 + +- `config.ini` — SAP 连接配置(host, client, user, password) +- 测试环境配置复用此文件 + +## 文档索引 + +| 文档 | 用途 | +|------|------| +| `README.md` | 价值说明、快速开始 | +| `FEATURES.md` | 命令矩阵、对象类型全景 | +| `docs/guide.md` | 详细命令用法、参数、输出示例 | +| `docs/adt/` | ADT REST API 学习笔记 | +| `docs/NW740-COMPATIBILITY.md` | NW 7.40 真实系统兼容性矩阵 | +| `docs/dev/` | 开发者文档 | diff --git a/README.md b/README.md index 7dfc468..891865d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,19 @@ SAP-CLI-SKILL 是一个让 AI 助手直接操作 SAP ABAP 系统的技能。 - **[INSTALL.md](INSTALL.md)** - 完整安装指南 - **[SKILL.md](SKILL.md)** - AI Agent 使用指南 - **[references/](references/)** - 技术文档 +- **[docs/RELEASING.md](docs/RELEASING.md)** - 开发与发布 + +--- + +## 🗂 本仓是唯一来源 + +本仓同时是**技能分发包**与**工具源码仓**——`SKILL.md` 手工维护,工具源码在 +`assets/`,测试在 `tests/`(692 例)。原 `sap-cli` 源码仓已于 2026-09-11 归档。 + +```bash +pip install -e "./assets[dev]" +python -m unittest discover -s tests -p "test_*.py" +``` --- diff --git a/assets/pyproject.toml b/assets/pyproject.toml index 75890fd..558c91d 100644 --- a/assets/pyproject.toml +++ b/assets/pyproject.toml @@ -1,12 +1,12 @@ [build-system] requires = ["setuptools>=68.0", "wheel"] -build-backend = "setuptools.backends._legacy:_Backend" +build-backend = "setuptools.build_meta" [project] name = "sap-cli" dynamic = ["version"] description = "基于 SAP ADT REST API 的 ABAP 开发对象管理工具" -readme = "README.md" +readme = {text = "基于 SAP ADT REST API 的 ABAP 开发对象管理工具。本仓(sap-cli-skill)自 2026-09-11 起为唯一来源,原 sap-cli 源码仓已归档。", content-type = "text/markdown"} license = {text = "MIT"} requires-python = ">=3.10" authors = [ @@ -25,9 +25,9 @@ dependencies = [ ] [project.urls] -Homepage = "https://gitee.com/markwury168/sap-cli" -Repository = "https://gitee.com/markwury168/sap-cli" -Issues = "https://gitee.com/markwury168/sap-cli/issues" +Homepage = "https://gitee.com/markwury168/sap-cli-skill" +Repository = "https://gitee.com/markwury168/sap-cli-skill" +Issues = "https://gitee.com/markwury168/sap-cli-skill/issues" [project.optional-dependencies] keyring = ["keyring>=23.0"] diff --git a/docs/NW740-COMPATIBILITY.md b/docs/NW740-COMPATIBILITY.md new file mode 100644 index 0000000..1f1967f --- /dev/null +++ b/docs/NW740-COMPATIBILITY.md @@ -0,0 +1,72 @@ +# NW 7.40 兼容性矩阵 + +> 最后更新:2026-07-09 +> 测试系统:support.learningleader.com.cn (NW 7.40) +> 测试方式:真实 SAP 系统端到端 create→info→delete→download→sync + +## 背景 + +sap-cli 封装 SAP ADT REST API,但不同 SAP NetWeaver 版本的 ADT 端点覆盖范围差异显著。本文件记录 v2.2.0 在真实 NW 7.40 系统上的验证结果,供用户和开发者参考。 + +## 对象类型 × 操作 可用性矩阵 + +### ✅ 完整可用(全生命周期闭环) + +| 类型 | create | info | delete | download | sync | 说明 | +|------|:------:|:----:|:------:|:--------:|:----:|------| +| report | ✅ | ✅ | ✅ | ✅ | ✅ | 报表程序 | +| class | ✅ | ✅ | ✅ | ✅ | ✅ | ABAP 类 | +| interface | ✅ | ✅ | ✅ | ✅ | ✅ | ABAP 接口 | +| function | ✅ | ✅ | ✅ | ✅ | ✅ | 函数模块(含函数组自动创建) | +| functiongroup | ✅ | ✅ | ✅ | — | — | 函数组(容器对象,无源码) | +| include | ✅ | ✅ | ✅ | ✅ | ✅ | Include 程序 | + +> **这 6 种类型构成 NW 7.40 上 sap-cli 的核心价值闭环**:download → 本地编辑 → sync。 + +### ⚠️ 部分可用(仅 CRUD,无源码编辑) + +| 类型 | create | info | delete | download | sync | 说明 | +|------|:------:|:----:|:------:|:--------:|:----:|------| +| domain | ✅ | ✅ | ✅ | ❌ | ❌ | 源码端点 /source/main 在 NW 7.40 不存在 | +| dataelement | ✅ | ✅ | ✅ | ❌ | ❌ | 同上 | + +> DDIC 对象在 NW 7.40 上以 XML 形式存储于对象 URI 本身,没有独立的 source/main 端点。这是 NW 7.50+ 才引入的特性。即便 Eclipse ADT 在 7.40 上也无法编辑 DDIC 源码——这不是 sap-cli 的缺陷。 + +### ❌ 不可用(NW 7.40 端点缺失) + +| 类型 | 失败原因 | HTTP 状态码 | 代码状态 | +|------|----------|:-----------:|----------| +| table | 集合端点 POST Content-Type 不匹配 | 415 | 代码已写,待更正 | +| structure | 集合端点 POST Content-Type 不匹配 | 415 | 代码已写,待更正 | +| tabletype | DDIC tabletypes 集合端点不存在 | 404 | 代码已写,端点缺失 | +| view | 媒体类型不支持 | 415 | 代码已写,端点限制 | +| messageclass | handler 未注册 | 404 | 代码已写,端点缺失 | +| searchhelp | 端点不存在 | 404 | 代码已写,端点缺失 | +| lockobject | 端点不存在 | 404 | 代码已写,端点缺失 | +| cdsview | DDL source 端点不存在 | 404 | 代码已写,端点缺失 | + +> **判定原则**:这些类型的 `_build_create_body` XML 结构代码已就位,但 NW 7.40 的 ADT 端点层就不通。验证方式——如果是 XML 结构错误,SAP 会返回 400 校验消息;而这 8 种类型返回的都是 404/415(端点不存在/媒体类型不支持),请求未到达 XML 解析。需在 S/4HANA 或更高 NW 版本上验证。 + +## 其他命令的版本兼容性 + +| 命令 | NW 7.40 | 说明 | +|------|:-------:|------| +| list | ✅ | 对象列表 | +| whereused | ✅ | 引用查询 | +| search | ✅ | 源码搜索 | +| check (ATC) | ✅ | 代码检查 | +| format | ✅ | Pretty Printer | +| diff | ✅ | 本地 vs SAP 对比 | +| transport | ✅ | 传输请求管理 | +| package | ✅ | 包管理 | +| show-table | ✅ | 表结构查询(SE11) | +| read-table | ✅ | 表数据查询 | +| run-program | ✅ | 远程执行程序 | +| activate | ✅ | 对象激活 | + +## 已知限制 + +1. **DDIC 对象 sync 不可用** — sync 流程依赖 `/source/main` 端点,NW 7.40 不支持。需 NW 7.50+。 +2. **DDIC 对象 lock Accept 头** — `delete_object` 已修复(传 `application/vnd.sap.as+xml`),sync 流程未扩展(因 source/main 限制无意义)。 +3. **table/structure create** — 返回 415(Content-Type 不匹配),需调研 NW 7.40 集合端点的正确 POST 方式。 +4. **NW 7.40 激活空响应** — activate 可能返回空响应,已有 double-activate 变通机制,极端情况提示去 SE09 手动激活。 diff --git a/docs/OPERATION_MANUAL.md b/docs/OPERATION_MANUAL.md new file mode 100644 index 0000000..f4ec746 --- /dev/null +++ b/docs/OPERATION_MANUAL.md @@ -0,0 +1,689 @@ +# sap-cli 操作手册 + +> 版本 2.3.0 · 最后更新 2026-08-06 + +--- + +## 目录 + +1. [项目概述](#1-项目概述) +2. [项目结构](#2-项目结构) +3. [安装与配置](#3-安装与配置) +4. [日常使用:命令速查](#4-日常使用命令速查) +5. [典型工作流](#5-典型工作流) +6. [测试管理](#6-测试管理) +7. [版本与发布](#7-版本与发布) +8. [开发指南](#8-开发指南) +9. [故障排查](#9-故障排查) + +--- + +## 1. 项目概述 + +**sap-cli** 是一个基于 SAP ADT REST API 的 ABAP 开发对象管理工具。 + +**能做什么:** +- 从 SAP 系统下载/上传 ABAP 源代码 +- 在本地编辑器编写 ABAP,一键同步到 SAP 并激活 +- 批量管理项目(init → sync --all → refresh) +- 16 种 SAP 对象类型(report/class/table/CDS View 等) +- 传输请求管理、代码搜索、依赖分析 + +**基本信息:** +- 仓库地址:https://gitee.com/markwury168/sap-cli +- 本地路径:`D:\Codespace\sap-cli\` +- 运行方式:`python main.py <命令>` 或 `sap-cli <命令>`(安装后) +- Python 版本:≥ 3.10 + +--- + +## 2. 项目结构 + +``` +D:\Codespace\sap-cli\ +│ +├── main.py # 入口文件(python main.py <命令>) +├── config.ini # SAP 连接配置(不入库,含密码) +├── config.ini.example # 配置模板 +├── pyproject.toml # 项目构建配置 + 版本号 +├── requirements.txt # 依赖(备用,首选 pyproject.toml) +├── README.md # 项目介绍 + 快速上手 +├── FEATURES.md # 功能全景文档 +├── CHANGELOG.md # 版本变更记录 +├── CONTRIBUTING.md # 协作规范 +├── LICENSE # MIT 许可 +│ +├── sapcli/ # 源码包(核心代码) +│ ├── __init__.py # 版本号 __version__ = "2.3.0" +│ ├── cli/ # CLI 层(参数解析 + 入口路由) +│ │ ├── parser.py # 228 行 — argparse 命令定义 +│ │ ├── app.py # 159 行 — main() 入口 + 命令路由 +│ │ └── output.py # 50 行 — 彩色输出工具 +│ ├── client/ # API 层(SAP ADT 通信)— Mixin 架构 +│ │ ├── _base.py # 88 行 — 连接、认证、通用请求 +│ │ ├── _source.py # 354 行 — 源码读写、锁定、激活 +│ │ ├── _transport.py # 203 行 — 传输请求管理 +│ │ ├── _search.py # 197 行 — 搜索、Where-Used +│ │ ├── _ddic.py # 639 行 — 对象创建/删除/DDIC +│ │ └── __init__.py # 29 行 — ADTClient = 5个Mixin组合 +│ ├── commands/ # 命令层(业务逻辑) +│ │ ├── crud.py # 890 行 — download/sync/delete/info/create +│ │ ├── batch.py # 328 行 — init/sync-all/refresh +│ │ ├── search.py # 147 行 — list/whereused/search +│ │ ├── transport.py # 136 行 — 传输请求操作 +│ │ ├── cds.py # 186 行 — CDS View 操作 +│ │ ├── package_cmd.py # 107 行 — ABAP 包管理 +│ │ ├── quality.py # 131 行 — ATC 检查/格式化 +│ │ ├── diff_cmd.py # 89 行 — 代码差异对比 +│ │ ├── analyze.py # 126 行 — 依赖分析 +│ │ ├── scaffold.py # 251 行 — 项目模板 +│ │ ├── config_cmd.py # 106 行 — 配置管理 +│ │ └── __init__.py # 69 行 — 统一导出 +│ ├── config.py # 95 行 — 配置加载 +│ ├── auth.py # 124 行 — 密钥管理(keyring) +│ ├── password.py # 76 行 — 密码解析(从 auth 拆出) +│ ├── types.py # 243 行 — 16 种对象类型定义 +│ ├── manifest.py # 174 行 — 项目清单管理 +│ ├── scanner.py # 121 行 — 项目目录扫描 +│ ├── sorter.py # 152 行 — 拓扑排序 +│ ├── ddic.py # 277 行 — DDIC XML 构建 +│ ├── exceptions.py # 77 行 — 异常体系 +│ └── utils/ # 工具函数 +│ ├── xml_utils.py # 13 行 — XML 转义 +│ └── __init__.py +│ +├── tests/ # 测试(412 用例,84% 覆盖率) +│ ├── test_sapcli.py # 68 个 — 基础层测试 +│ └── unit/ # 单元测试 +│ ├── test_client.py # 82 个 — API 层测试 +│ ├── test_commands.py # 45 个 — 命令层测试 +│ ├── test_commands_extra.py # 64 个 — 命令边界场景 +│ ├── test_cli.py # 41 个 — 参数解析测试 +│ ├── test_batch_analyze.py # 40 个 — 批量操作+分析 +│ ├── test_modules.py # 58 个 — 散落模块测试 +│ └── test_app_config.py # 14 个 — 入口+配置测试 +│ +├── docs/ # 文档 +│ ├── guide.md # 详细使用指南(24KB) +│ ├── adt/ # 7 篇 ADT 原理文章 +│ └── dev/ # 开发文档 +│ ├── architecture.md # 架构说明 +│ ├── CLAUDE.md # AI 协作规范 +│ ├── AGENTS.md # AI Agent 规范 +│ ├── test-plan.md # 测试方案 +│ └── test-coverage-report.md # 覆盖率报告 +│ +├── openspec/ # OpenSpec SDD 规范 +│ ├── specs/ # 7 个领域的需求规格 +│ └── changes/ # 变更记录(已归档) +│ +├── .github/workflows/ci.yml # CI 自动测试 +├── .editorconfig # 编辑器统一配置 +├── .pre-commit-config.yaml # Git 提交前自动检查 +└── .gitignore # Git 忽略规则 +``` + +### 架构分层 + +``` +用户输入 + │ + ▼ +main.py → cli/app.py → cli/parser.py ← CLI 层:解析参数 + │ + ▼ +commands/*.py ← 命令层:业务逻辑 + │ + ▼ +client/*.py (Mixin) ← API 层:SAP ADT 通信 + │ + ▼ +config.py / types.py / manifest.py / ... ← 基础层:工具与配置 +``` + +--- + +## 3. 安装与配置 + +### 3.1 首次安装 + +```bash +cd D:\Codespace\sap-cli + +# 安装项目(开发模式,代码修改立即生效) +pip install -e . + +# 安装可选依赖(密码安全存储) +pip install -e ".[keyring]" + +# 安装开发依赖(测试工具) +pip install -e ".[dev]" +``` + +安装后可以直接使用 `sap-cli` 命令(无需 `python main.py`)。 + +### 3.2 SAP 连接配置 + +**方式一:配置文件(推荐)** + +复制模板并填写你的 SAP 系统信息: + +```bash +cp config.ini.example config.ini +``` + +编辑 `config.ini`: + +```ini +[SAP] +host = http://your-sap-server:8000 +client = 100 +user = your_username +password = your_password + +; 多系统配置(用 --profile 切换): +[DEV] +host = http://dev-sap:8000 +client = 100 +user = dev_user +password = dev_password + +[QAS] +host = http://qas-sap:8000 +client = 200 +user = qas_user +password = qas_password +``` + +> ⚠️ `config.ini` 包含密码,已在 `.gitignore` 中排除,**绝不要提交到 Git**。 + +**方式二:环境变量** + +```bash +set SAP_HOST=http://your-sap:8000 +set SAP_CLIENT=100 +set SAP_USER=your_username +set SAP_PASSWORD=your_password +``` + +**方式三:keyring 安全存储** + +```bash +python main.py auth login +# 输入 host / client / user / password 后,密码存入系统密钥环 + +python main.py auth status # 查看状态 +python main.py auth logout # 删除密码 +``` + +**密码优先级:** 环境变量 > keyring > config.ini + +### 3.3 多系统切换 + +```bash +# 默认使用 [SAP] 配置 +python main.py download --name ZTEST --type report --path ./out + +# 切换到 [DEV] 系统 +python main.py --profile DEV download --name ZTEST --type report --path ./out + +# 切换到 [QAS] 系统 +python main.py --profile QAS info --name ZCL_MY --type class +``` + +### 3.4 SSL 证书 + +默认关闭 SSL 验证(SAP 开发环境通常使用自签名证书)。如果你的 SAP 系统有正式证书: + +```bash +python main.py --verify-ssl download --name ZTEST --type report --path ./out +``` + +--- + +## 4. 日常使用:命令速查 + +### 4.1 全局参数 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `--config` | 指定配置文件路径 | `--config ./my.ini` | +| `--profile` / `-p` | 切换系统配置 | `--profile DEV` | +| `--verify-ssl` | 启用 SSL 证书验证 | `--verify-ssl` | + +### 4.2 支持的 16 种对象类型 + +``` +report class interface function functiongroup +domain dataelement table structure tabletype +include cdsview messageclass view searchhelp lockobject +``` + +### 4.3 命令一览(20 个) + +#### 核心操作 + +| 命令 | 用途 | 示例 | +|------|------|------| +| **download** | 下载 SAP 对象源码到本地 | `python main.py download --name ZTEST --type report --path ./out` | +| **sync** | 上传本地代码到 SAP 并激活 | `python main.py sync --name ZTEST --type report --path ./ztest.abap` | +| **create** | 在 SAP 创建新对象 | `python main.py create --name ZTEST --type report` | +| **delete** | 从 SAP 删除对象 | `python main.py delete --name ZTEST --type report` | +| **info** | 查询对象元数据 | `python main.py info --name ZCL_MY --type class` | + +#### 批量项目管理 + +| 命令 | 用途 | 示例 | +|------|------|------| +| **init** | 初始化项目清单 | `python main.py init --path ./my_project` | +| **refresh** | 刷新清单中的 SAP 状态 | `python main.py refresh --path ./my_project` | + +#### 批量同步模式 + +```bash +# 批量同步所有对象 +python main.py sync --all --path ./my_project + +# 预览模式(不实际操作) +python main.py sync --all --path ./my_project --dry-run + +# 遇到失败立即停止 +python main.py sync --all --path ./my_project --fail-fast + +# 指定传输请求号 +python main.py sync --name ZTEST --type report --path ./ztest.abap --corr_nr DEVK901362 +``` + +#### 搜索与浏览 + +| 命令 | 用途 | 示例 | +|------|------|------| +| **list** | 列出 SAP 对象 | `python main.py list --type report --prefix Z*` | +| **whereused** | Where-Used 引用查询 | `python main.py whereused --name ZTEST --type report` | +| **search** | 源代码搜索 | `python main.py search --query "CALL FUNCTION"` | +| **analyze** | 依赖自动分析 | `python main.py analyze --path ./my_project` | + +#### 传输管理 + +```bash +python main.py transport list # 列出可修改的传输请求 +python main.py transport info --corr_nr DEVK901362 # 查看传输详情 +python main.py transport release --corr_nr DEVK901362 # 释放传输 +python main.py transport objects --corr_nr DEVK901362 # 列出传输中的对象 +``` + +#### 代码质量 + +| 命令 | 用途 | 示例 | +|------|------|------| +| **check** | ATC 代码检查 | `python main.py check --name ZTEST --type report` | +| **format** | ABAP Pretty Printer | `python main.py format --name ZTEST --type report` | +| **diff** | 本地 vs SAP 差异 | `python main.py diff --name ZTEST --type report --path ./ztest.abap` | + +#### CDS View + +```bash +python main.py cds download --name ZMY_CDS --path ./out # 下载 DDL +python main.py cds sync --name ZMY_CDS --path ./ddl.abap # 同步 DDL +python main.py cds create --name ZMY_CDS # 创建 CDS +``` + +#### 包管理 + +```bash +python main.py package create --name ZMY_PKG --description "我的包" # 创建 +python main.py package info --name ZMY_PKG # 查看 +python main.py package list --name ZMY_PKG # 列出对象 +``` + +#### 项目模板 + +```bash +# 列出可用模板 +python main.py scaffold --name ZTEST + +# 使用指定模板 +python main.py scaffold --name ZTEST --template alv-report # ALV 报表 +python main.py scaffold --name ZTEST --template bapi-wrapper # BAPI 封装 +python main.py scaffold --name ZTEST --template interface-class # 接口类 +python main.py scaffold --name ZTEST --template data-model # 数据模型 +``` + +#### 配置与认证 + +```bash +python main.py config show # 显示当前配置 +python main.py config list-profiles # 列出所有 profile +python main.py config set host http://new-host:8000 # 修改配置 + +python main.py auth login # 保存密码到 keyring +python main.py auth logout # 删除密码 +python main.py auth status # 查看状态 +``` + +--- + +## 5. 典型工作流 + +### 工作流 A:下载 → 编辑 → 同步 + +```bash +# 1. 下载 SAP 对象源码 +python main.py download --name ZMY_REPORT --type report --path ./src + +# 2. 用你喜欢的编辑器修改 ./src/zmy_report.abap + +# 3. 同步回 SAP 并激活 +python main.py sync --name ZMY_REPORT --type report --path ./src/zmy_report.abap +``` + +### 工作流 B:新建项目 + 批量开发 + +```bash +# 1. 创建项目目录结构 +mkdir my_project +mkdir my_project\reports my_project\classes my_project\functions + +# 2. 在各目录下编写 .abap 文件 + +# 3. 初始化项目清单(扫描目录 → 查询 SAP → 写 manifest.json) +python main.py init --path ./my_project + +# 4. 批量同步到 SAP +python main.py sync --all --path ./my_project + +# 5. 后续修改后再次同步 +python main.py sync --all --path ./my_project + +# 6. 刷新 SAP 状态 +python main.py refresh --path ./my_project +``` + +### 工作流 C:使用模板快速创建 + +```bash +# 1. 生成 ALV 报表模板 +python main.py scaffold --name ZALV_DEMO --template alv-report --path ./my_project + +# 2. 编辑生成的文件 +# my_project/reports/zalv_demo.abap + +# 3. 初始化 + 同步 +python main.py init --path ./my_project +python main.py sync --all --path ./my_project +``` + +### 工作流 D:DDIC 对象管理 + +```bash +# 创建 DDIC 对象(需要 JSON 定义文件) +python main.py create --name ZMY_DOMAIN --type domain --definition ./domain_def.json +python main.py create --name ZMY_TABLE --type table --definition ./table_def.json + +# 创建时指定包和传输请求 +python main.py create --name ZMY_CLASS --type class --package ZMY_PKG --corr_nr DEVK901362 +``` + +### 项目目录命名规范 + +批量模式时,scanner 按目录名识别对象类型: + +| 目录名 | 对象类型 | +|--------|---------| +| `reports/` | report | +| `classes/` | class | +| `interfaces/` | interface | +| `functions/` | function(子目录 = 函数组名) | +| `domains/` | domain | +| `dataelements/` | dataelement | +| `tables/` | table | +| `structures/` | structure | +| `tabletypes/` | tabletype | +| `includes/` | include | +| `cdsviews/` | cdsview | +| `messageclasses/` | messageclass | +| `views/` | view | +| `searchhelps/` | searchhelp | +| `lockobjects/` | lockobject | + +函数组特殊结构: +``` +functions/ +└── zmy_group/ ← 函数组名(子目录名) + ├── zfunc1.abap ← 函数模块 + └── zfunc2.abap +``` + +--- + +## 6. 测试管理 + +### 6.1 运行测试 + +```bash +cd D:\Codespace\sap-cli + +# 运行全部测试(412 用例) +python -m unittest discover -s tests -p "test_*.py" + +# 运行单个测试文件 +python tests/unit/test_client.py +python tests/test_sapcli.py + +# 运行特定测试类 +python -m unittest tests.unit.test_cli.TestParserDownload + +# 运行特定测试方法 +python -m unittest tests.unit.test_client.TestADTClient.test_login_success +``` + +### 6.2 查看覆盖率 + +```bash +# 安装 coverage +pip install coverage + +# 运行测试并收集覆盖率 +coverage run -m unittest discover -s tests -p "test_*.py" + +# 查看终端报告 +coverage report --include="sapcli/*" + +# 生成 HTML 报告(浏览器打开) +coverage html --include="sapcli/*" +# 报告在 tests/coverage_html/index.html +``` + +### 6.3 测试文件说明 + +| 文件 | 用例数 | 覆盖内容 | +|------|--------|----------| +| `test_sapcli.py` | 68 | 基础层(types/config/exceptions/manifest) | +| `test_client.py` | 82 | API 层(ADTClient 全部方法) | +| `test_commands.py` | 45 | 命令层(各命令 happy path) | +| `test_commands_extra.py` | 64 | 命令层(边界场景/错误处理) | +| `test_cli.py` | 41 | 参数解析(全部 20 个命令) | +| `test_batch_analyze.py` | 40 | 批量操作 + 依赖分析 | +| `test_modules.py` | 58 | 散落模块(scanner/sorter/auth/password) | +| `test_app_config.py` | 14 | CLI 入口 + 配置命令 | + +### 6.4 当前覆盖率(84%) + +``` +100% — parser, manifest, exceptions, utils, commands/__init__ +96%+ — batch, sorter, scanner, scaffold, types, config +90%+ — search, transport, client (各 Mixin) +80%+ — app, auth, config_cmd, ddic, quality +60%+ — crud, output +``` + +--- + +## 7. 版本与发布 + +### 7.1 版本号在哪 + +版本号 **只在一个地方维护**: + +``` +sapcli/__init__.py → __version__ = "2.3.0" +``` + +`pyproject.toml` 通过动态读取自动同步: +```toml +[tool.setuptools.dynamic] +version = {attr = "sapcli.__version__"} +``` + +**升级版本只需改 `sapcli/__init__.py` 中的 `__version__`。** + +### 7.2 发布流程 + +```bash +# 1. 修改版本号 +# 编辑 sapcli/__init__.py → __version__ = "2.2.0" + +# 2. 更新 CHANGELOG.md + +# 3. 提交 +git add -A && git commit -m "release: v2.2.0" + +# 4. 打标签 +git tag v2.2.0 + +# 5. 推送 +git push --tags +``` + +### 7.3 Git 操作常用命令 + +```bash +# 查看状态 +git status + +# 查看历史 +git log --oneline -10 + +# 提交代码 +git add -A +git commit -m "feat: 新增 xxx 功能" +git push + +# 回退未提交的修改 +git checkout -- . + +# 查看 config.ini 是否被误加入暂存 +git ls-files | grep config.ini +``` + +--- + +## 8. 开发指南 + +### 8.1 添加新命令 + +**步骤:** + +1. 在 `sapcli/commands/` 下新建或修改命令文件: + ```python + # sapcli/commands/my_cmd.py + import argparse + from sapcli.client import ADTClient + + def cmd_my_command(args: argparse.Namespace, client: ADTClient) -> None: + """我的新命令。""" + print(f"执行 my-command: {args.name}") + ``` + +2. 在 `sapcli/commands/__init__.py` 中导出: + ```python + from sapcli.commands.my_cmd import cmd_my_command + ``` + +3. 在 `sapcli/cli/parser.py` 的 `build_parser()` 中添加子命令定义 + +4. 在 `sapcli/cli/app.py` 的 `command_map` 中注册路由 + +5. 写测试 → 运行测试 + +### 8.2 添加新对象类型 + +在 `sapcli/types.py` 中注册: +```python +_TYPE_REGISTRY["mytype"] = ObjectTypeConfig( + obj_type="mytype", + program_type="MYT", + adt_type="MYT", + adt_uri="mytypes", + # ... +) +``` + +然后在 `scanner.py` 的 `DIRECTORY_TYPE_MAP` 和 `sorter.py` 的 `TYPE_PRIORITY` 中补充。 + +### 8.3 代码风格 + +- Python PEP 8 +- `.editorconfig` 已配置:4 空格缩进,UTF-8,LF 换行 +- pre-commit hooks 自动检查(安装后运行 `pre-commit install`) + +### 8.4 新增 API 方法 + +API 方法在 `sapcli/client/` 目录的 Mixin 模块中: + +| 功能域 | 文件 | 示例方法 | +|--------|------|---------| +| 连接/认证 | `_base.py` | login, object_exists | +| 源码读写 | `_source.py` | get_source, set_source, lock, unlock | +| 传输管理 | `_transport.py` | list_transport_requests, transport_release | +| 搜索查询 | `_search.py` | list_objects, where_used, search_code | +| 对象操作 | `_ddic.py` | create_object, delete_object, create_ddic_object | + +添加新方法时,放在对应 Mixin 文件中即可。`ADTClient` 类自动继承所有 Mixin。 + +--- + +## 9. 故障排查 + +### 常见问题 + +| 问题 | 原因 | 解决 | +|------|------|------| +| `✗ 配置文件不存在` | 没有 config.ini | 复制 `config.ini.example` 为 `config.ini` 并填写 | +| `✗ 登录失败` | SAP 地址/密码错误 | 检查 config.ini 中的 host/user/password | +| `✗ 对象不存在` | 名称或类型不对 | 检查大小写,SAP 对象名通常大写 | +| `SSL 证书错误` | SAP 自签名证书 | 默认已关闭验证,检查是否误加 `--verify-ssl` | +| `keyring 不可用` | 未安装 keyring | `pip install keyring` | +| `config.ini 入了 Git` | 误提交 | `git rm --cached config.ini && git commit` | + +### 日志文件 + +运行日志自动写入 `log/adt_tools.log`(`.gitignore` 已排除)。 + +### 调试技巧 + +```bash +# 查看完整错误堆栈 +python main.py download --name ZTEST --type report --path ./out 2>&1 | more + +# 查看日志 +cat log/adt_tools.log | tail -50 + +# 验证配置是否正确 +python main.py config show + +# 验证连接是否可用 +python main.py list --type report --prefix Z* +``` + +--- + +## 附录:版本历史 + +| 版本 | 日期 | 关键变更 | +|------|------|---------| +| v2.1.0 | 2026-06-09 | 全面重构:20 命令、16 类型、Mixin 架构、412 测试 | +| v2.0.0 | 2026-06-08 | OpenSpec SDD 重构、项目结构整理 | +| v1.0.0 | 2026-05 | 初始版本 | + +> 详细变更记录见 `CHANGELOG.md` diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..c5a8ab7 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,97 @@ +# 开发与发布 + +## 本仓是唯一来源(2026-09-11 起) + +`sap-cli-skill` 同时是**技能分发包**与**工具源码仓**,也是 SAP 相关实机操作的运行版。 +原 `sap-cli` 源码仓已**归档**(只读留档),不再接受改动。 + +``` +sap-cli-skill/ +├── SKILL.md ← 技能文档(手工维护,AI Agent 使用指南) +├── INSTALL.md ← 完整安装指南(手工维护) +├── README.md ← 项目说明 +├── VERSION ← 版本号(须与 assets/sapcli/__version__ 一致) +├── assets/ ← 工具源码与打包元数据(Python 包根) +│ ├── sapcli/ ← 工具源码 +│ ├── main.py +│ ├── pyproject.toml ← 版本动态取自 sapcli.__version__ +│ ├── requirements.txt +│ └── config.ini.example +├── references/ ← 规则正本(sap-tool-constraints / abap-coding-rules / error-handling) +├── tests/ ← 单元测试与 E2E 夹具 +├── log/ ← 错误知识库(LLM-WIKI 格式,md 入库) +├── docs/ ← 开发文档、ADT 原理文档 +├── openspec/ ← SDD 规格 +├── scripts/setup.py ← 安装脚本 +├── .claude/rules/ ← 规则副本(供 Claude Code,须与 references/ 一致) +└── .github/workflows/ ← CI +``` + +> 目录语义说明:`assets/` 是**技能包**的载荷目录(Hermes / Claude Code 技能约定), +> 因此工具源码放在其中而非仓库根。安装与调用都指向 `assets/`。 + +## 日常开发 + +```bash +cd D:/Gitee/sap-cli-skill + +# 装依赖(editable) +pip install -e "./assets[dev]" + +# 跑全量测试(692 例) +python -m unittest discover -s tests -p "test_*.py" + +# 只跑单元测试(更快) +python -m unittest discover -s tests/unit -t . +``` + +改代码 → 改文档(需要时)→ 跑测试 → 提交。**未绿不提交。** + +## 版本与发布 + +版本号权威源是 `assets/sapcli/__init__.py` 的 `__version__`: + +1. 改 `assets/sapcli/__init__.py` 的 `__version__` +2. 同步 `VERSION` 文件(`test_repo_guards.py` 会断言二者一致,忘了会红) +3. 提交并打 tag: + ```bash + git add -A && git commit -m "chore: bump to vX.Y.Z" + git tag -a vX.Y.Z -m "sap-cli vX.Y.Z" + git push origin HEAD && git push origin vX.Y.Z + ``` + +发布即推送本仓。Hermes 侧无需拷贝——`profiles/*/skills/productivity/sap-cli` +是指向本仓的 junction,专家下一轮即读到新内容。 + +## 规则文件:两处副本必须一致 + +`references/*.md` 是**正本**(随技能分发给专家、被 `SKILL.md` 引用); +`.claude/rules/*.md` 是供 Claude Code 读取的**副本**。 + +两者内容必须一致,由 `tests/unit/test_repo_guards.py` 的 +`TestRuleCopiesInSync` 断言。改规则时**两处都要改**,只改一处测试会红。 + +## 两道守卫(提交前必过) + +`tests/unit/test_repo_guards.py` 守住几类「静默失效」: + +| 守卫 | 防的问题 | +|------|----------| +| SKILL.md 记录 parser **全部** CLI 命令 | 文档与代码脱节(曾只写 9 个而实际 31 个) | +| 铁律 1–5 是**真实小节标题** | 子串检查被正文交叉引用蒙过 | +| 示例不得出现 `--path ./src` | 违反铁律 5 的目录结构 | +| `references/` 规则齐备且含关键规则 | 规则静默丢失 | +| `VERSION` == `sapcli.__version__` == README 版本 | 版本漂移(曾 2.3.0 vs 2.5.1) | +| `.claude/rules/` == `references/` | 规则副本漂移 | + +## 已废弃的做法(勿再使用) + +- **`pack_skill.py` / `skill-src/SKILL.md.tmpl` 打包流程**——已随 sap-cli 归档。 + 现在 `SKILL.md` 是手工维护的正本,**没有构建步骤**,也就没有「构建覆盖手改」的风险。 + 不要再引入「模板渲染 → 覆盖仓库文档」这类机制。 +- **`D:/Codespace/SAP-CLI-SKILL`**——早期开发副本,与分发仓同远端,2026-09-11 已删除。 +- **向 `D:/Gitee/sap-cli` 提交改动**——已归档。 + +## Gitee 远程 + +https://gitee.com/markwury168/sap-cli-skill.git diff --git a/docs/adt/01.ADT架构与通信原理.md b/docs/adt/01.ADT架构与通信原理.md new file mode 100644 index 0000000..2b4d35e --- /dev/null +++ b/docs/adt/01.ADT架构与通信原理.md @@ -0,0 +1,215 @@ +--- +title: ADT 架构与通信原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - architecture + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +--- + +# ADT 架构与通信原理 + +> [!abstract] 核心洞察 +> ADT 本质上是一个 **REST API 客户端**。SAP 通过在 ABAP 服务器上构建一套完整的 REST 层(`/sap/bc/adt/*`),然后开发 Eclipse 插件来消费这些 API,实现了前后端的完全解耦。 + +## 1. 三层架构模型 + +ADT 采用经典的 **客户端/服务器** 分层架构: + +``` +┌───────────────────────────────────────────────────────────┐ +│ Layer 1: IDE 客户端层 (Eclipse / VS Code / 自定义客户端) │ +│ │ +│ • Eclipse ADT 插件(Java 实现) │ +│ • VS Code 扩展(2026年起支持,复用 Eclipse 代码库) │ +│ • 任何 HTTP 客户端(Python requests / JS fetch / AI Agent) │ +│ • 客户端 UI 无关代码:2.9 百万行! │ +└────────────────────────┬──────────────────────────────────┘ + │ HTTP(S) + Basic Auth + ▼ +┌───────────────────────────────────────────────────────────┐ +│ Layer 2: REST 通信层 (/sap/bc/adt/*) │ +│ │ +│ • 基于 HTTP 协议的 RESTful API │ +│ • 请求格式: XML / ATOM Feed / JSON │ +│ • CSRF Token 机制(写操作必须先获取 Token) │ +│ • 状态会话管理(Stateful HTTP Session) │ +│ • 服务发现端点: /sap/bc/adt/discovery │ +└────────────────────────┬──────────────────────────────────┘ + │ ICF Handler 路由 + ▼ +┌───────────────────────────────────────────────────────────┐ +│ Layer 3: SAP ABAP 后端 │ +│ │ +│ • ICF (Internet Communication Framework) 处理请求路由 │ +│ • ADT REST Framework (增强点 SADT_REST_RFC_APPLICATION) │ +│ • ABAP Runtime(语法检查、编译、激活) │ +│ • Repository(仓库对象存储) │ +│ • CTS(传输管理系统) │ +│ • ATC(代码质量检查引擎) │ +└───────────────────────────────────────────────────────────┘ +``` + +## 2. REST 通信层详解 + +### 2.1 端点结构 + +所有 ADT 操作通过 `/sap/bc/adt/` 前缀的端点进行通信: + +| 端点路径 | 功能 | 最低版本要求 | +|----------|------|-------------| +| `/sap/bc/adt/discovery` | 服务发现(Atom Feed) | NW 7.31 SP04 | +| `/sap/bc/adt/repository` | 仓库对象搜索与浏览 | NW 7.31 SP04 | +| `/sap/bc/adt/oo` | 类与接口管理 | NW 7.31 SP04 | +| `/sap/bc/adt/programs` | 程序与 Include | NW 7.31 SP04 | +| `/sap/bc/adt/functions` | 函数组与函数模块 | NW 7.31 SP04 | +| `/sap/bc/adt/ddic` | ABAP Dictionary 访问 | NW 7.40 SP02 | +| `/sap/bc/adt/datapreview` | 表数据预览(SE16N 替代) | NW 7.40 SP05 | +| `/sap/bc/adt/cts` | 传输请求管理 | NW 7.40 SP02 | +| `/sap/bc/adt/activation` | 对象激活 | NW 7.31 SP04 | +| `/sap/bc/adt/atc` | ABAP Test Cockpit | NW 7.50+ | + +### 2.2 通信协议特征 + +> [!important] 关键协议特征 + +**认证方式:** +- **Basic Authentication**(最常用)— 通过 HTTPS 发送用户名/密码 +- **X.509 证书认证** — 生产环境推荐 +- **SAP OAuth 2.0** — 高级场景 + +**CSRF Token 机制:** +``` +1. 客户端发送 GET 请求,Header: x-csrf-token = "Fetch" +2. 服务器返回响应,Header 中包含 CSRF Token +3. 后续所有写操作(POST/PUT/DELETE)必须携带此 Token +4. Token 过期时(HTTP 403),重新获取并重试 +``` + +**会话管理:** +- ADT 使用 **有状态的 HTTP 会话**(Stateful Session) +- 通过 Cookie 维持会话,用于对象锁定等操作 +- `sap-client` Header 指定 SAP 客户端编号 + +**数据格式:** +- 请求/响应主要使用 **XML** 格式 +- 部分端点支持 `text/plain`(源代码读写) +- 服务发现使用 **ATOM Feed** 格式 + +## 3. 服务发现机制 + +> [!tip] 核心概念 +> ADT 采用 **服务发现** 模式,而非硬编码 URL。客户端可以通过 `/sap/bc/adt/discovery` 端点动态获取所有可用的服务及其 URI。 + +### 发现流程 + +``` +1. 客户端请求 GET /sap/bc/adt/discovery + → 返回 Atom Feed,列出所有已注册的服务集合 + +2. 每个服务集合包含: + - href: 服务的 URI 路径 + - title: 服务的描述名称 + - category: 服务的分类(scheme + term) + +3. 客户端根据 category scheme/term 查找需要的具体服务 URI + → 避免硬编码,支持不同系统版本的兼容性 +``` + +### 发现端点示例响应结构 + +```xml + + Classes + + +``` + +## 4. 后端扩展机制 + +### ADT REST Framework 的扩展点 + +SAP 提供了 **增强点 (Enhancement Spot)** 来扩展 ADT: + +| BAdI | 用途 | +|------|------| +| `BADI_ADT_DISCOVERY_PROVIDER` | 实现自定义发现提供者,注册新的 URI | +| `SADT_REST_RFC_APPLICATION` | 实现自定义 REST 资源处理 | + +### 扩展架构 + +``` +客户端请求 + │ + ▼ +RFC Handler(ADT REST Framework 核心) + │ + ├── 根据 URI 路径匹配对应的 BAdI 实现 + │ + ├── 调用 Discovery Controller(发现控制器) + │ └── 提供 URI schema(如 /ztransportutils) + │ + └── 调用 Resource Controller(资源控制器) + └── 处理具体的 GET/POST/PUT/DELETE 操作 +``` + +### 创建自定义 ADT 资源的步骤 + +1. **创建 Discovery Controller** — 继承 `CL_ADT_RES_APP_BASE`,定义 URI 路径 +2. **注册 BAdI `BADI_ADT_REST_RFC_APPLICATION`** — 链接到 Discovery Controller +3. **创建 Resource Application** — 继承 `CL_ADT_DISC_RES_APP_BASE` +4. **创建 Resource Controller** — 继承 `CL_ADT_REST_RESOURCE`,实现 GET/POST 方法 +5. **注册 BAdI `BADI_ADT_DISCOVERY_PROVIDER`** — 使资源可被发现 + +## 5. 架构演进:从 Eclipse 到 VS Code + +> [!success] 架构演进策略(2025年 SAP 官方博客) + +SAP 面临的核心挑战: +- **290万行** 客户端 UI 无关代码 +- **88个** 独特的对象类型编辑器(仅 SAP BTP ABAP 环境) + +### 解决方案 1:Language Server 复用 + +借鉴 Java VS Code 扩展的思路 — 将 Eclipse ADT 插件 "包装" 为 Language Server: + +``` +VS Code ←→ Language Server Protocol (LSP) ←→ Eclipse ADT 代码库 (2.9M 行) +``` + +- 无需从头重写客户端代码 +- Eclipse 和 VS Code 共享同一代码库 +- 连接任何 Eclipse 支持的 ABAP 服务器版本 + +### 解决方案 2:Server-Driven Development + +从 2020 年起,新对象类型采用 **服务器驱动开发** 模式: +- UI 逻辑完全在 ABAP 端定义(类似 Dynpro / Fiori Elements) +- 客户端只需 **两个渲染引擎**: + - **表单渲染器** — 用于表单类对象 + - **源码渲染器** — 用于源码类对象 +- 不再需要为每个 IDE 实现 88 个独立编辑器 + +## 6. 权限控制 + +| 权限对象 | 控制范围 | +|----------|---------| +| `S_ADT_RES` | 所有 ADT API 访问(端点级别) | +| `S_DEVELOP` | 对象读写操作 | +| `S_CTS_ADMI` | 传输管理 | +| `S_TRANSPRT` | 传输发布 | + +## 🔗 相关笔记 + +- [[sap-cli/02.查询代码-原理|02.查询代码 - 原理]] +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] +- [[sap-cli/04.检查代码-原理|04.检查代码 - 原理]] +- [[sap-cli/05.提交与传输-原理|05.提交与传输 - 原理]] + +## 📚 参考来源 + +- [Behind the Design: How We Transformed the ABAP Development Tools](https://community.sap.com/t5/technology-blog-posts-by-sap/behind-the-design-how-we-transformed-the-abap-development-tools/ba-p/14258121) +- [Creating an ABAP in Eclipse Plug-in Using the ADT SDK](https://community.sap.com/t5/application-development-and-automation-blog-posts/creating-a-abap-in-eclipse-plug-in-using-the-adt-sdk-part-2/ba-p/13093582) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) diff --git a/docs/adt/02.查询代码-原理.md b/docs/adt/02.查询代码-原理.md new file mode 100644 index 0000000..e34e348 --- /dev/null +++ b/docs/adt/02.查询代码-原理.md @@ -0,0 +1,198 @@ +--- +title: 查询代码 - 原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - search + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/01.ADT架构与通信原理|01.ADT架构与通信原理]]" +next: "[[sap-cli/03.修改代码-原理|03.修改代码 - 原理]]" +--- + +# 查询代码 — 原理 + +> [!abstract] 概述 +> ADT 提供三种主要的代码查询能力:**仓库对象搜索**(按名称/类型)、**源代码全文搜索**(按代码片段)和 **Where-Used 导航**(引用追踪)。这些功能全部通过 `/sap/bc/adt/repository` 端点的 REST API 实现。 + +## 1. 仓库对象搜索 (Repository Object Search) + +### 原理 + +通过 REST API 向 `/sap/bc/adt/repository/informationsystem/search` 发送 GET 请求,在后端 ABAP Repository 中搜索对象。 + +### REST API 调用 + +``` +GET /sap/bc/adt/repository/informationsystem/search + ?operation=quickSearch + &query=<搜索关键词> + &maxResults=50 + &objectType=<对象类型> (可选) +``` + +### 参数说明 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `operation` | 搜索操作类型 | `quickSearch` | +| `query` | 搜索关键词 | `ZCL_SALES*` | +| `maxResults` | 最大返回数量 | `50` | +| `objectType` | 对象类型过滤 | `CLAS`, `PROG`, `FUGR`, `TABL`, `DTEL`, `DOMA` | + +### 响应格式 + +```xml + + + +``` + +### 后端处理流程 + +``` +1. Eclipse 发送 GET 请求到 repository search 端点 +2. ICF Handler 将请求路由到 ABAP Repository Search 类 +3. 搜索引擎扫描 ABAP Repository(类似 SE80 搜索) +4. 返回匹配对象的元数据:名称、类型、URI、包名 +5. Eclipse 在 Project Explorer 中展示搜索结果 +6. 双击结果 → 通过 URI 加载对象的源代码编辑器 +``` + +### 对应的 SAP GUI 事务码 + +| ADT 搜索 | SAP GUI 等效 | +|----------|-------------| +| 快速搜索 | `SE80` — Repository Browser | +| 按类型搜索 | `SE24`(类) / `SE38`(程序) / `SE37`(函数) | +| 按包搜索 | `SE80` — Package 节点 | + +## 2. 源代码全文搜索 (ABAP Source Code Search) + +### 原理 + +> [!info] 前置条件 +> 源代码搜索需要激活业务功能 **`SRIS_SOURCE_SEARCH`**。 + +ADT 提供了一个 **搜索控制台**,允许在整个 ABAP 源码仓库中进行全文搜索,类似 IDE 中的 "Search in Files" 功能。 + +### 工作流程 + +``` +1. 用户在 Eclipse 搜索控制台输入代码片段 + ↓ +2. 客户端发送搜索请求到 ADT REST 端点 + ↓ +3. 后端搜索引擎执行全文本索引查找 + ↓ +4. 返回匹配的源码位置(对象名 + 行号) + ↓ +5. Eclipse 展示搜索结果,点击可直接跳转到编辑器 +``` + +### 搜索能力 + +- **任意代码片段搜索** — 搜索变量名、方法调用、SQL 语句等 +- **正则表达式支持** — 模式匹配搜索 +- **范围限定** — 可限定搜索范围(包、对象类型等) +- **直接导航** — 搜索结果可直接跳转到对应的开发对象 + +### 对应 SAP GUI 的优势 + +在传统 SAP GUI 中,**不存在等效的全文本源码搜索功能**。这是 ADT 相对于 SE80 的重大改进之一。 + +## 3. 对象浏览与导航 + +### Repository Browser + +``` +GET /sap/bc/adt/repository/informationsystem +``` + +浏览 ABAP Repository 的层级结构: +- **按包浏览** — 展开包 → 查看其中所有对象 +- **按类型浏览** — 按类、程序、表等类型分类 +- **按传输层浏览** — 按传输层组织查看 + +### 对象详情获取 + +``` +GET /sap/bc/adt/oo/classes/{class_name} +GET /sap/bc/adt/programs/programs/{program_name} +GET /sap/bc/adt/functions/groups/{group_name} +``` + +每个对象类型的详细信息通过对应的端点获取。 + +## 4. Where-Used 导航 + +### 原理 + +Where-Used 分析追踪对象在整个仓库中的引用关系。例如:查找哪些程序使用了某个类或方法。 + +### 处理流程 + +``` +1. 用户在编辑器中选中一个标识符(类名/方法名/变量) + ↓ +2. Eclipse 发送 Where-Used 请求到 Repository 端点 + ↓ +3. 后端 ABAP Runtime 执行交叉引用分析 + ↓ +4. 返回所有引用位置(对象 + 行号 + 列号) + ↓ +5. Eclipse 展示引用列表,支持直接导航 +``` + +### 对应的 SAP GUI 事务码 + +| ADT Where-Used | SAP GUI 等效 | +|----------------|-------------| +| 引用查找 | `SE80` → Where-Used List | +| 类型层次 | `SE24` → Superclass/Subclass | + +## 5. 数据预览 (Data Preview) + +### 原理 + +ADT 提供了表数据读取能力,可以替代传统的 SE16N 事务码。 + +### REST API 调用 + +``` +POST /sap/bc/adt/datapreview/{table_name} +Content-Type: application/xml + + + + * + 100 + +``` + +### 优势对比 + +| 特性 | SE16N (SAP GUI) | ADT Data Preview | +|------|-----------------|-------------------| +| 访问方式 | 事务码,需要 SAP GUI | REST API,任何 HTTP 客户端 | +| 数据格式 | ALV Grid(屏幕渲染) | 结构化 XML/JSON | +| 并行查询 | 不支持 | 支持多线程并发 | +| 自动化 | 困难 | 可编程访问 | +| 平台限制 | Windows + SAP GUI | 任何平台 | + +## 🔗 相关笔记 + +- [[sap-cli/01.ADT架构与通信原理|01.ADT架构与通信原理]] +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] + +## 📚 参考来源 + +- [SAP Help Portal - Searching ABAP Source Code](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/searching-abap-source-code) +- [ABAP Source Code Search - SAP Community](https://community.sap.com/t5/application-development-blog-posts/abap-sourcecode-search/bc-p/13074593) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) diff --git a/docs/adt/03.修改代码-原理.md b/docs/adt/03.修改代码-原理.md new file mode 100644 index 0000000..1c8ff05 --- /dev/null +++ b/docs/adt/03.修改代码-原理.md @@ -0,0 +1,257 @@ +--- +title: 修改代码 - 原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - edit + - lock + - activation + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/02.查询代码-原理|02.查询代码 - 原理]]" +next: "[[sap-cli/04.检查代码-原理|04.检查代码 - 原理]]" +--- + +# 修改代码 — 原理 + +> [!abstract] 核心流程 +> ADT 中修改代码遵循 **锁定 → 编辑 → 保存 → 解锁 → 激活** 的严格工作流。每一步都通过 REST API 调用对应的 ABAP 后端服务完成。 + +## 1. 完整工作流 + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ 1.锁定 │───▶│ 2.编辑 │───▶│ 3.保存 │───▶│ 4.解锁 │───▶│ 5.激活 │ +│ Lock │ │ Edit │ │ Save │ │ Unlock │ │ Activate│ +└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + POST lock GET source PUT source POST unlock POST activate + → lockHandle → 纯文本 + lockHandle + lockHandle → 激活结果 +``` + +> [!warning] 重要约束 +> - **必须先锁定** 才能修改代码,否则 PUT 请求会被拒绝 +> - **锁定句柄 (lockHandle)** 是贯穿整个编辑流程的关键凭证 +> - **必须激活** 后代码才会在运行时生效(保存 ≠ 激活) + +## 2. Step 1: 锁定对象 (Lock) + +### 原理 + +ABAP 使用 **乐观锁 (Optimistic Lock)** 机制。当开发者打开一个对象进行编辑时,ADT 向后端发送锁定请求,获取一个唯一的 `lockHandle`。 + +### REST API 调用 + +``` +POST /sap/bc/adt/oo/classes/{class_name} + ?_action=LOCK + &accessMode=MODIFY +``` + +### 响应 + +```xml + +``` + +- 返回的 `LOCK_HANDLE` 是一个唯一标识符 +- 后续的写入操作必须携带此句柄 +- 锁定是 **会话级** 的,HTTP 会话断开后锁自动释放 + +### 后端处理 + +``` +1. ADT Framework 接收锁定请求 + ↓ +2. 调用 ABAP 锁定管理器 (ENQUEUE) + ↓ +3. 检查对象是否已被其他用户锁定 + ↓ +4. 如果未锁定 → 创建锁定条目,返回 lockHandle +5. 如果已锁定 → 返回错误(锁定冲突) +``` + +### 对应 SAP GUI + +| ADT 操作 | SAP GUI 等效 | +|----------|-------------| +| Lock 对象 | 打开 SE24/SE38 编辑模式时自动锁定 | + +## 3. Step 2: 读取源代码 (Read Source) + +### REST API 调用 + +``` +GET /sap/bc/adt/oo/classes/{class_name}/source/main +Accept: text/plain +``` + +### 对象路径模式 + +不同对象类型使用不同的路径: + +| 对象类型 | 路径模板 | +|----------|---------| +| 类 | `oo/classes/{name}/source/main` | +| 程序 | `programs/programs/{name}/source/main` | +| 函数模块 | `functions/groups/{group}/fmodules/{fm}/source/main` | +| CDS View | `ddic/cds/views/{name}/source/main` | + +### 响应 + +直接返回 **纯文本** 格式的 ABAP 源代码(Content-Type: `text/plain`)。 + +## 4. Step 3: 写入源代码 (Write Source) + +### REST API 调用 + +``` +PUT /sap/bc/adt/oo/classes/{class_name}/source/main +Content-Type: text/plain; charset=utf-8 +X-sap-adt-lockhandle: {lockHandle} + +<修改后的 ABAP 源代码> +``` + +> [!important] 关键要点 +> - 请求体是 **纯文本**(不是 XML) +> - **必须携带** `X-sap-adt-lockhandle` Header +> - 保存 ≠ 激活:代码已写入仓库但仍处于 **非活跃状态** + +### 后端处理 + +``` +1. 验证 lockHandle 是否有效 + ↓ +2. 将源代码写入 ABAP Repository 的非活跃版本 (Inactive Version) + ↓ +3. 对象状态变为 "Modified"(在 Project Explorer 中显示 * 标记) + ↓ +4. 记录到传输请求任务中(如果已分配) +``` + +### 与 SAP GUI 的对比 + +| 特性 | SAP GUI (SE80/SE38) | ADT (Eclipse) | +|------|---------------------|---------------| +| 保存行为 | 保存即激活(或保存为非活跃) | 保存 ≠ 激活(显式分离) | +| 锁定管理 | 隐式(打开编辑时自动锁定) | 显式(REST API 锁定/解锁) | +| 版本管理 | 版本数据库 | 本地历史 + 版本数据库 | + +## 5. Step 4: 解锁对象 (Unlock) + +### REST API 调用 + +``` +POST /sap/bc/adt/oo/classes/{class_name} + ?_action=UNLOCK + &lockHandle={lockHandle} +``` + +解锁后,其他开发者可以锁定并编辑该对象。 + +## 6. Step 5: 激活对象 (Activate) + +### 原理 + +> [!tip] 激活 vs 保存 +> - **保存 (Save)**:将源代码写入仓库的非活跃版本 +> - **激活 (Activate)**:编译代码并生成可执行版本(Active Version) +> - 只有激活后的代码才能被运行时系统使用 + +### REST API 调用 + +``` +POST /sap/bc/adt/activation +Content-Type: application/xml + + + + + +``` + +### 后端激活流程 + +``` +1. ADT 激活框架接收激活请求 + ↓ +2. 执行预检查 (Pre-audit) + ├── 语法检查 (Syntax Check) + ├── 依赖检查(引用的对象是否已激活) + └── 权限检查 + ↓ +3. 如果预检查通过: + ├── 编译 ABAP 代码 → 生成 ABAP Load + ├── 更新 ABAP Repository 中的活跃版本 (Active Version) + ├── 触发 ATC 检查(如果配置了自动检查) + └── 将活跃版本写入传输请求 + ↓ +4. 如果预检查失败: + └── 返回错误列表(语法错误、缺失引用等) +``` + +### 对应 SAP GUI + +| ADT 操作 | SAP GUI 等效 | +|----------|-------------| +| 激活单个对象 | `SE80` → Activate (Ctrl+F3) | +| 批量激活 | `SE80` → Inactive Objects → Activate All | + +## 7. 完整 REST API 调用序列示例 + +以下是一个修改 ABAP 类的完整 REST API 调用序列: + +```python +# 1. 锁定对象 +lock_resp = POST("/sap/bc/adt/oo/classes/zcl_example", + _action="LOCK", accessMode="MODIFY") +lock_handle = parse_lock_handle(lock_resp) + +# 2. 读取当前源代码 +source = GET("/sap/bc/adt/oo/classes/zcl_example/source/main", + Accept="text/plain") + +# 3. 修改源代码(客户端操作) +modified_source = modify_source(source) + +# 4. 写入修改后的源代码 +PUT("/sap/bc/adt/oo/classes/zcl_example/source/main", + body=modified_source, + headers={"X-sap-adt-lockhandle": lock_handle}) + +# 5. 解锁对象 +POST("/sap/bc/adt/oo/classes/zcl_example", + _action="UNLOCK", lockHandle=lock_handle) + +# 6. 激活对象 +POST("/sap/bc/adt/activation", + body=activation_xml("ZCL_EXAMPLE", + "/sap/bc/adt/oo/classes/zcl_example")) +``` + +## 8. 错误处理 + +| 场景 | HTTP 状态码 | 处理方式 | +|------|-----------|---------| +| 对象已被锁定 | 403/409 | 提示用户等待或强制解锁 | +| CSRF Token 过期 | 403 | 自动重新获取 Token 并重试 | +| 语法错误 | 400 | 返回错误列表,阻止激活 | +| 权限不足 | 401/403 | 检查 S_DEVELOP 权限 | + +## 🔗 相关笔记 + +- [[sap-cli/02.查询代码-原理|02.查询代码 - 原理]] +- [[sap-cli/04.检查代码-原理|04.检查代码 - 原理]] + +## 📚 参考来源 + +- [SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) +- [erpl-adt (GitHub)](https://github.com/DataZooDE/erpl-adt) diff --git a/docs/adt/04.检查代码-原理.md b/docs/adt/04.检查代码-原理.md new file mode 100644 index 0000000..5531874 --- /dev/null +++ b/docs/adt/04.检查代码-原理.md @@ -0,0 +1,270 @@ +--- +title: 检查代码 - 原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - ATC + - syntax-check + - code-quality + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/03.修改代码-原理|03.修改代码 - 原理]]" +next: "[[sap-cli/05.提交与传输-原理|05.提交与传输 - 原理]]" +--- + +# 检查代码 — 原理 + +> [!abstract] 概述 +> ADT 提供三层代码检查能力:**语法检查 (Syntax Check)**、**ABAP Test Cockpit (ATC)** 静态分析** 和 **单元测试执行**。检查结果通过 REST API 返回,在 Eclipse 中以结构化问题列表展示。 + +## 1. 三层代码检查体系 + +``` +┌──────────────────────────────────────────────────────┐ +│ Level 3: 单元测试 (ABAP Unit) │ +│ • 执行实际的测试代码 │ +│ • 验证运行时行为 │ +│ • REST: /sap/bc/adt/oo/classes/{name}/testruns │ +├──────────────────────────────────────────────────────┤ +│ Level 2: ATC 质量检查 (ABAP Test Cockpit) │ +│ • 静态代码分析(功能/性能/安全) │ +│ • 可在激活时自动触发 │ +│ • REST: /sap/bc/adt/atc/runs │ +├──────────────────────────────────────────────────────┤ +│ Level 1: 语法检查 (Syntax Check) │ +│ • 编译期错误检测 │ +│ • 激活前必须通过 │ +│ • REST: 集成在 activation 端点中 │ +└──────────────────────────────────────────────────────┘ +``` + +## 2. 语法检查 (Syntax Check) + +### 原理 + +语法检查是 **最低级别** 的代码检查,验证 ABAP 代码是否能够成功编译。它作为激活流程的一部分自动执行,也可以手动触发。 + +### 触发时机 + +| 场景 | 自动/手动 | 说明 | +|------|----------|------| +| 激活对象前 | 自动 | 激活框架首先执行语法检查 | +| 编辑器中保存后 | 可配置 | 可设置为保存时自动检查 | +| 手动触发 | 手动 | Ctrl+F2 或菜单 | + +### 工作流程 + +``` +1. 客户端触发语法检查请求 + ↓ +2. ADT Framework 调用 ABAP 编译器 (ABAP Compiler) + ↓ +3. 编译器分析源代码: + ├── 关键字拼写检查 + ├── 类型兼容性检查 + ├── 接口实现完整性检查 + └── 引用对象存在性检查 + ↓ +4. 返回检查结果: + ├── Errors (错误) — 阻止激活 + ├── Warnings (警告) — 不阻止但建议修复 + └── Information (信息) — 代码改进建议 + ↓ +5. Eclipse 在编辑器中以标记形式展示 + (红色波浪线 = Error,黄色 = Warning) +``` + +### 与 SAP GUI 的对比 + +| ADT 语法检查 | SAP GUI 等效 | +|-------------|-------------| +| 自动检查 + 编辑器标记 | `SE38` → Check (Ctrl+F2) | +| 保存时可自动检查 | 需要手动触发 | +| 实时错误提示 | 无实时检查 | + +## 3. ABAP Test Cockpit (ATC) + +### 原理 + +ATC 是 SAP 的 **标准化代码质量检查工具**,提供比语法检查更深入的静态分析能力,涵盖功能正确性、性能优化和安全合规。 + +### REST API 调用 + +#### 创建 ATC 运行 + +``` +POST /sap/bc/adt/atc/runs +Content-Type: application/xml + + + + + + + + + + + +``` + +#### 获取 ATC 结果 + +``` +GET /sap/bc/adt/atc/runs/{run_id}/results +``` + +### 响应格式 + +```xml + + + + +``` + +### ATC 检查类别 + +| 类别 | 说明 | 示例检查项 | +|------|------|-----------| +| **功能检查** | 逻辑错误、空引用 | 未处理的异常、未使用的变量 | +| **性能检查** | 性能反模式 | SELECT *、嵌套 SELECT、缺少索引 | +| **安全检查** | 安全漏洞 | SQL 注入、权限检查缺失 | +| **编程规范** | 编码标准 | 命名规范、注释要求 | +| **Cloud Readiness** | 云兼容性 | 不允许的语句/对象 | + +### ATC 运行模式 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 模式 1: 手动检查 │ +│ 开发者右键 → Run ATC Check │ +│ → 立即执行,结果展示在 ATC Results 视图中 │ +├──────────────────────────────────────────────────────────┤ +│ 模式 2: 激活时自动检查 │ +│ 配置 Preferences → ABAP → ATC │ +│ → 每次激活时自动触发 ATC │ +│ → 发现问题时提示开发者 │ +├──────────────────────────────────────────────────────────┤ +│ 模式 3: 传输发布时检查 │ +│ 传输发布前自动运行 ATC │ +│ → 基于 Baseline 只检查新增/修改的代码 │ +│ → 确保不会将质量问题引入生产环境 │ +├──────────────────────────────────────────────────────────┤ +│ 模式 4: 远程代码分析 (Remote Code Analysis) │ +│ 在中央 ATC 系统上分析代码 │ +│ → 使用最新检查规则 │ +│ → 跨系统的代码质量监控 │ +└──────────────────────────────────────────────────────────┘ +``` + +### ATC Baseline 概念 + +> [!info] Baseline 的作用 +> Baseline 允许团队将现有代码标记为"已接受",只对**新增或修改的代码**运行检查。这避免了在遗留代码上产生大量已知问题。 + +``` +首次使用: + 1. 对整个仓库运行 ATC → 生成 Baseline + 2. Baseline 记录所有已知问题 + 3. 后续检查只报告 Baseline 之后新增的问题 + +传输检查: + 1. 传输发布时运行 ATC + 2. 与 Baseline 比对 + 3. 只有新增问题会阻止传输发布 +``` + +## 4. ABAP Unit 测试 + +### 原理 + +ABAP Unit 是 ABAP 的内置单元测试框架。测试方法使用 `FOR TESTING` 标记,在专门的测试会话中执行。 + +### REST API 调用 + +``` +POST /sap/bc/adt/oo/classes/{test_class_name}/testruns +``` + +### 后端执行流程 + +``` +1. ADT 接收测试执行请求 + ↓ +2. ABAP Runtime 创建测试隔离环境 + ↓ +3. 执行标记为 FOR TESTING 的方法 + ↓ +4. 收集测试结果: + ├── Passed (通过) + ├── Failed (失败) + 失败原因 + └── Error (错误) + 异常信息 + ↓ +5. 返回结果给客户端 +``` + +## 5. 代码检查与 CI/CD 集成 + +### 自动化检查流水线 + +``` +代码修改 → 语法检查 → ATC 检查 → ABAP Unit → 传输发布 + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + 保存成功 无错误 无严重问题 全部通过 发布到目标系统 +``` + +### 通过 REST API 实现自动化 + +```python +# 完整的自动检查流程 +def automated_code_check(client, object_uri): + # 1. 语法检查(通过激活预检) + activation_result = activate_object(client, object_uri) + if activation_result.get("errors"): + return "BLOCKED: Syntax errors" + + # 2. ATC 检查 + atc_result = run_atc_check(client, object_uri) + critical_findings = [f for f in atc_result["findings"] + if f["priority"] == "1"] + if critical_findings: + return "BLOCKED: Critical ATC findings" + + # 3. 单元测试 + test_result = run_unit_tests(client, test_class_uri) + if test_result["failed"] > 0: + return "BLOCKED: Unit test failures" + + return "PASSED: All checks green" +``` + +## 6. Eclipse 中的检查结果展示 + +| 视图 | 说明 | +|------|------| +| **Problems View** | 语法检查和编译错误 | +| **ATC Results View** | ATC 检查发现,按严重级别分组 | +| **ABAP Unit Results** | 单元测试执行结果 | +| **Editor Markers** | 编辑器中的行内错误/警告标记 | + +## 🔗 相关笔记 + +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] — 激活流程中集成语法检查 +- [[sap-cli/05.提交与传输-原理|05.提交与传输 - 原理]] — 传输发布时触发 ATC + +## 📚 参考来源 + +- [SAP Help Portal - Checking Quality of ABAP Code with ATC](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/checking-quality-of-abap-code-with-atc) +- [Remote Code Analysis in ATC](https://community.sap.com/t5/application-development-and-automation-blog-posts/remote-code-analysis-in-abap-test-cockpit-atc-faq/ba-p/13320130) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) diff --git a/docs/adt/05.提交与传输-原理.md b/docs/adt/05.提交与传输-原理.md new file mode 100644 index 0000000..03dbdae --- /dev/null +++ b/docs/adt/05.提交与传输-原理.md @@ -0,0 +1,317 @@ +--- +title: 提交与传输 - 原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - transport + - CTS + - version-management + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/04.检查代码-原理|04.检查代码 - 原理]]" +--- + +# 提交与传输 — 原理 + +> [!abstract] 核心概念 +> SAP 中的 "提交代码" 与 Git 等版本控制系统不同。ABAP 使用 **CTS (Change and Transport System)** 来管理代码变更。代码修改被记录在 **传输请求 (Transport Request)** 中,通过传输请求将变更从开发系统 → 测试系统 → 生产系统进行迁移。 + +## 1. 传输管理概念模型 + +### 传输系统架构 + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 开发系统 │ │ 测试系统 │ │ 生产系统 │ +│ (DEV) │ │ (QAS) │ │ (PRD) │ +│ │ │ │ │ │ +│ 创建/修改 │────▶│ 测试/验证 │────▶│ 部署上线 │ +│ 代码 │ │ 代码 │ │ 代码 │ +└──────┬───────┘ └──────────────┘ └──────────────┘ + │ + │ 导出 + ▼ +┌──────────────┐ +│ 传输目录 │ +│ (Transport │ +│ Directory) │ +│ │ +│ .TR 文件 │ +│ 数据文件 │ +└──────────────┘ + │ + │ 导入 + ▼ + 目标系统 (QAS/PRD) +``` + +### 传输请求结构 + +``` +传输请求 (Transport Request - TR) +├── 请求头 (Request Header) +│ ├── 编号: K9XXXXXX +│ ├── 类型: K (Workbench) / W (Customizing) / T (Transport of Copies) +│ ├── 描述: "Implement sales order validation" +│ ├── 所有者: DEVELOPER01 +│ ├── 状态: D (Modifiable) → R (Released) +│ └── 目标系统: QAS +│ +├── 任务 (Task) — 可以有多个 +│ ├── 编号: K9XXXXYY +│ ├── 所有者: DEVELOPER01 +│ └── 状态: D → R +│ +└── 对象列表 (Object List) + ├── ZCL_SALES_ORDER (CLAS) + ├── Z_SALES_REPORT (PROG) + └── Z_SALES_TABLE (TABL) +``` + +## 2. ADT 中的传输工作流 + +### 完整流程 + +``` +1. 创建传输请求 + ↓ +2. 将对象分配到传输请求 + ↓ +3. 开发/修改代码 + ↓ +4. 激活代码 + ↓ +5. (可选) 运行 ATC 检查 + ↓ +6. 发布传输请求 + ↓ +7. 导入到目标系统 +``` + +## 3. REST API 详解 + +### 3.1 列出传输请求 + +``` +GET /sap/bc/adt/cts/transportrequests + ?user={username} + &status={D|R} +``` + +| 参数 | 说明 | +|------|------| +| `user` | 按所有者过滤 | +| `status` | `D` = 可修改, `R` = 已发布 | + +### 响应 + +```xml + + + +``` + +### 3.2 创建传输请求 + +``` +POST /sap/bc/adt/cts/transportrequests +Content-Type: application/xml + + + +``` + +| 字段 | 说明 | +|------|------| +| `type="K"` | Workbench 请求(程序、类等) | +| `type="W"` | Customizing 请求(配置数据) | +| `type="T"` | Transport of Copies(复制传输) | + +### 3.3 发布传输请求 + +``` +POST /sap/bc/adt/cts/transportrequests/{request_number}/newreleasejobs +``` + +### 后端处理流程 + +``` +1. ADT CTS 端点接收发布请求 + ↓ +2. 传输系统执行预检查: + ├── 检查所有任务是否已发布 + ├── 检查所有对象是否已激活 + ├── (可选) 运行 ATC 检查 + └── 检查是否有未释放的对象 + ↓ +3. 将传输请求导出到传输目录 + ├── 生成数据文件(包含对象内容) + └── 生成控制文件(包含元数据) + ↓ +4. 请求状态变为 R (Released) + ↓ +5. 传输管理系统 (tp / STMS) 将请求导入目标系统 +``` + +### 3.4 Transport of Copies + +> [!tip] Transport of Copies +> Transport of Copies (ToC) 是一种特殊类型的传输,用于在不改变原始传输请求的情况下将代码复制到目标系统。常用于紧急修复或临时部署。 + +``` +POST /sap/bc/adt/cts/transportrequests/{source_request}/tocopies +``` + +## 4. 对象分配到传输请求 + +### 原理 + +当开发者修改一个对象时,系统会提示选择或创建传输请求。对象随后被记录到传输请求的任务中。 + +### 分配方式 + +``` +┌────────────────────────────────────────────────────┐ +│ 方式 1: 自动提示 │ +│ 修改对象时 → Eclipse 弹出对话框 → 选择/创建 TR │ +├────────────────────────────────────────────────────┤ +│ 方式 2: 拖拽分配 │ +│ 在 Project Explorer 中拖拽对象到 TR │ +├────────────────────────────────────────────────────┤ +│ 方式 3: 批量分配 │ +│ 选择多个对象 → Assign to Transport Request │ +└────────────────────────────────────────────────────┘ +``` + +## 5. 版本管理 + +### ABAP 版本管理机制 + +ABAP 内置了 **版本数据库 (Version Database)**,每次激活对象时自动创建一个版本。 + +### ADT 中的版本比较 + +``` +┌────────────────────────────────────────────┐ +│ 版本类型 │ +├────────────────────────────────────────────┤ +│ 活跃版本 (Active Version) │ +│ → 当前系统中正在运行的代码版本 │ +│ 非活跃版本 (Inactive Version) │ +│ → 已保存但未激活的修改版本 │ +│ 传输版本 (Transport Version) │ +│ → 传输请求中记录的版本 │ +│ 历史版本 (Historical Version) │ +│ → 版本数据库中保存的所有历史版本 │ +└────────────────────────────────────────────┘ +``` + +### 比较操作 + +| 操作 | 说明 | +|------|------| +| Compare with Active Version | 当前编辑 vs 活跃版本 | +| Compare with Local History | 当前 vs 本地历史(客户端缓存) | +| Compare with Transport Version | 当前 vs 传输中的版本 | +| Version History | 查看所有历史版本 | + +## 6. ADT 传输管理 vs SAP GUI 传输管理 + +| 功能 | SAP GUI | ADT Eclipse | +|------|---------|-------------| +| 创建 TR | `SE01` / `SE09` | 右键 → New → Transport Request | +| 查看 TR | `SE01` / `SE09` | Transport Requests 视图 | +| 发布 TR | `SE01` → Release | 右键 → Release | +| 查看 TR 内容 | `SE01` → Display | 双击 TR → 展开对象列表 | +| TR 详情 | `SE03` | Properties 视图 | +| 版本比较 | `SE80` → Version Management | 右键 → Compare With | +| 批量操作 | `SE01` | 多选 + 上下文菜单 | + +## 7. 传输发布与 ATC 集成 + +> [!important] 传输发布质量门禁 +> 传输发布时可以配置 **ATC 质量门禁**,确保代码质量达标后才能发布到目标系统。 + +### 配置流程 + +``` +1. 在 SAP 系统中配置 ATC 传输检查 + (事务码: SATC -> 配置传输发布检查) + ↓ +2. 设置检查规则: + ├── 设置 Baseline + ├── 配置检查范围 + └── 设置严重级别阈值 + ↓ +3. 传输发布时自动触发 ATC + ↓ +4. 只有通过检查的传输才能继续发布 + (或配置为仅警告不阻止) +``` + +## 8. 通过 REST API 实现自动化传输管理 + +### Python 自动化示例 + +```python +def automated_transport_workflow(client, object_uri, description): + """自动化传输工作流""" + + # 1. 创建传输请求 + tr_number = create_transport_request( + client, + description=description, + target_system="QAS" + ) + + # 2. 修改代码(参见 03.修改代码-原理) + lock_handle = lock_object(client, object_uri) + # ... 修改源代码 ... + write_source(client, object_uri, modified_source, lock_handle) + unlock_object(client, object_uri, lock_handle) + + # 3. 激活代码 + activate_object(client, object_uri) + + # 4. 运行 ATC 检查 + atc_result = run_atc_check(client, object_uri) + critical = [f for f in atc_result["findings"] + if f["priority"] == "1"] + if critical: + raise Exception("ATC check failed") + + # 5. 发布传输请求 + release_transport(client, tr_number) + + return tr_number +``` + +## 9. 常见问题与解决方案 + +| 问题 | 原因 | 解决方案 | +|------|------|---------| +| 无法创建 TR | 缺少 S_CTS_ADMI 权限 | 联系 BASIS 分配权限 | +| 发布失败 | 对象未激活 | 先激活所有对象 | +| ATC 阻止发布 | 存在严重问题 | 修复 ATC 发现或更新 Baseline | +| 传输导入失败 | 目标系统缺少依赖 | 确保依赖对象已在目标系统激活 | +| 锁定冲突 | 其他用户正在编辑 | 等待或请求解锁 | + +## 🔗 相关笔记 + +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] — 代码修改 → 传输请求 +- [[sap-cli/04.检查代码-原理|04.检查代码 - 原理]] — ATC 检查与传输发布集成 + +## 📚 参考来源 + +- [SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide) +- [ABAP Tools – Work with Eclipse (Transport) – Software-Heroes](https://software-heroes.com/en/blog/abap-tools-work-with-eclipse-transport) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) +- [Transporting ABAP and Non-ABAP Objects (SAP Community Call)](https://www.youtube.com/watch?v=FpAuj8QD-rM) +- [Version Management in ABAP in Eclipse - SAP Community](https://community.sap.com/t5/application-development-discussions/version-management-in-abap-in-eclipse/m-p/12611386) diff --git a/docs/adt/06.ADT测试代码.md b/docs/adt/06.ADT测试代码.md new file mode 100644 index 0000000..b79ede7 --- /dev/null +++ b/docs/adt/06.ADT测试代码.md @@ -0,0 +1,336 @@ +--- +title: ADT 测试代码 — 读取程序源代码 +created: 2026-05-19 +tags: + - SAP + - ADT + - REST + - python + - test-code +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +--- + +# ADT 测试代码 — 通过 REST API 读取程序源代码 + +> [!abstract] 概述 +> 通过 ADT REST API 连接 SAP 系统,测试连接、服务发现、读取程序源代码、搜索仓库对象。 +> 参见:[[sap-cli/02.查询代码-原理]] + +## 运行方式 + +```bash +# 安装依赖 +pip install requests + +# 运行测试 +python sap-cli/test_adt_client.py +``` + +## 测试结果 + +> [!success] 2026-05-19 测试通过 +> - 连接验证: HTTP 200 ✅ +> - 服务发现: 311 个可用服务 ✅ +> - 读取源代码: `ZIDTR_IMPORT_ACCOUNTING_ORDER` (37,655 字符 / 1,096 行) ✅ +> - 仓库搜索: quickSearch 返回 0 结果 ⚠️ + +## 完整代码 + +```python +""" +SAP ADT REST Client — 测试脚本 +通过 ADT REST API 连接 SAP 系统并获取程序源代码 + +参考: [[sap-cli/02.查询代码-原理]] + [[sap-cli/01.ADT架构与通信原理]] + +端点: /sap/bc/adt/programs/programs/{name}/source/main +""" + +import requests +from urllib.parse import urljoin +import xml.etree.ElementTree as ET +import sys +import json +import io + +# 修复 Windows 控制台编码问题 +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + + +# ============================================================ +# 配置 — 来自 [[sap-cli/README]] 测试服务器信息 +# ============================================================ +ADT_BASE_URL = "http://support.learningleader.com.cn:55955/sap/bc/adt" +SAP_CLIENT = "100" +USER = "admin2" +PASSWORD = "654321" +PROGRAM_NAME = "ZIDTR_IMPORT_ACCOUNTING_ORDER" + + +class ADTClient: + """简易 ADT REST 客户端""" + + CSRF_HEADER = "x-csrf-token" + CSRF_FETCH = "Fetch" + + def __init__(self, base_url: str, client: str, user: str, password: str): + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + self.session.auth = (user, password) + self.session.headers.update({ + "Accept": "*/*", + "Accept-Language": "EN", + "sap-client": client, + }) + # 忽略 SSL 警告(测试环境可能是自签名证书) + requests.packages.urllib3.disable_warnings( + requests.packages.urllib3.exceptions.InsecureRequestWarning + ) + self.session.verify = False + self._csrf_token: str | None = None + + def _url(self, path: str) -> str: + return f"{self.base_url}/{path}" + + def fetch_csrf_token(self) -> str: + """获取 CSRF Token — 写操作必需""" + print(" → 正在获取 CSRF Token...") + resp = self.session.get( + self._url("discovery"), + headers={self.CSRF_HEADER: self.CSRF_FETCH}, + ) + resp.raise_for_status() + self._csrf_token = resp.headers.get(self.CSRF_HEADER, "") + print(f" ✓ CSRF Token 获取成功: {self._csrf_token[:20]}...") + return self._csrf_token + + def get(self, path: str, **kwargs) -> requests.Response: + """发送 GET 请求""" + resp = self.session.get(self._url(path), **kwargs) + resp.raise_for_status() + return resp + + +def test_connection(client: ADTClient) -> bool: + """测试 1: 验证 ADT 连接""" + print("\n" + "=" * 60) + print("测试 1: 验证 ADT 连接") + print("=" * 60) + print(f" URL: {client.base_url}") + print(f" User: {client.session.auth[0]}") + print(f" Client: {client.session.headers.get('sap-client')}") + + try: + resp = client.session.get(client._url("discovery")) + print(f" → HTTP Status: {resp.status_code}") + if resp.status_code == 401: + print(f" ✗ 认证失败 (401 Unauthorized)") + print(f" 响应体: {resp.text[:300]}") + return False + resp.raise_for_status() + print(f" ✓ 连接成功! HTTP {resp.status_code}") + print(f" ✓ Content-Type: {resp.headers.get('content-type', 'N/A')}") + return True + except requests.exceptions.HTTPError as e: + print(f" ✗ HTTP 错误: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f" 响应体: {e.response.text[:300]}") + return False + except requests.exceptions.ConnectionError as e: + print(f" ✗ 网络连接失败: {e}") + return False + + +def test_discovery(client: ADTClient) -> list[dict]: + """测试 2: 服务发现 — 列出可用的 ADT 服务""" + print("\n" + "=" * 60) + print("测试 2: 服务发现 (Discovery)") + print("=" * 60) + + try: + resp = client.get("discovery") + root = ET.fromstring(resp.content) + + # 解析 Atom Feed 中的服务集合 + ns = { + "app": "http://www.w3.org/2007/app", + "atom": "http://www.w3.org/2005/Atom", + } + + services = [] + for collection in root.findall(".//app:collection", ns): + href = collection.attrib.get("href", "") + title_el = collection.find("atom:title", ns) + title = title_el.text if title_el is not None else "" + services.append({"href": href, "title": title}) + + print(f" ✓ 发现 {len(services)} 个可用服务:") + for svc in services[:15]: # 只显示前 15 个 + print(f" • {svc['title']}: {svc['href']}") + if len(services) > 15: + print(f" ... 还有 {len(services) - 15} 个服务") + + return services + except Exception as e: + print(f" ✗ 服务发现失败: {e}") + return [] + + +def test_read_program_source(client: ADTClient, program_name: str) -> str | None: + """测试 3: 读取程序源代码""" + print("\n" + "=" * 60) + print(f"测试 3: 读取程序源代码 ({program_name})") + print("=" * 60) + + # 尝试多种可能的端点路径 + endpoints = [ + f"programs/programs/{program_name.lower()}/source/main", + f"programs/programs/{program_name}/source/main", + ] + + for endpoint in endpoints: + try: + print(f" → 尝试端点: GET {endpoint}") + resp = client.get( + endpoint, + headers={"Accept": "text/plain"}, + ) + source = resp.text + + print(f" ✓ 源代码获取成功!") + print(f" ✓ 程序名: {program_name}") + print(f" ✓ 源代码长度: {len(source)} 字符") + print(f" ✓ 行数: {len(source.splitlines())}") + + # 显示前 30 行 + lines = source.splitlines() + print(f"\n ┌─── 源代码 (前 30 行) ──────────────────────") + for i, line in enumerate(lines[:30], 1): + print(f" │ {i:4d} | {line}") + if len(lines) > 30: + print(f" │ ... 省略剩余 {len(lines) - 30} 行 ...") + print(f" └────────────────────────────────────────────") + + return source + + except requests.exceptions.HTTPError as e: + print(f" ✗ HTTP 错误: {e.response.status_code} {e.response.reason}") + if e.response.status_code == 404: + print(f" (程序不存在或端点路径不正确,尝试下一个...)") + continue + else: + print(f" 响应内容: {e.response.text[:200]}") + return None + except Exception as e: + print(f" ✗ 读取失败: {e}") + return None + + print(f" ✗ 所有端点尝试均失败") + return None + + +def test_search_object(client: ADTClient, program_name: str) -> list[dict]: + """测试 4: 搜索仓库对象""" + print("\n" + "=" * 60) + print(f"测试 4: 搜索仓库对象 ({program_name})") + print("=" * 60) + + try: + resp = client.get( + "repository/informationsystem/search", + params={ + "operation": "quickSearch", + "query": program_name, + "maxResults": "10", + }, + ) + + ns = "http://www.sap.com/adt/repository/informationsystem" + root = ET.fromstring(resp.content) + + results = [] + for obj in root.iter(f"{{{ns}}}object"): + results.append({ + "name": obj.attrib.get("name", ""), + "type": obj.attrib.get("type", ""), + "uri": obj.attrib.get("uri", ""), + "package": obj.attrib.get("packageName", ""), + }) + + print(f" ✓ 搜索完成,找到 {len(results)} 个结果:") + for r in results: + print(f" • [{r['type']}] {r['name']} (Package: {r['package']})") + print(f" URI: {r['uri']}") + + return results + + except Exception as e: + print(f" ✗ 搜索失败: {e}") + return [] + + +def main(): + print("=" * 60) + print(" SAP ADT REST API 测试脚本") + print(" 参考: [[sap-cli/02.查询代码-原理]]") + print("=" * 60) + + # 创建客户端 + client = ADTClient( + base_url=ADT_BASE_URL, + client=SAP_CLIENT, + user=USER, + password=PASSWORD, + ) + + # 测试 1: 连接验证 + if not test_connection(client): + print("\n❌ 连接失败,请检查服务器配置和网络连接。") + sys.exit(1) + + # 测试 2: 服务发现 + test_discovery(client) + + # 测试 3: 读取程序源代码 + source = test_read_program_source(client, PROGRAM_NAME) + + # 测试 4: 搜索仓库对象 + test_search_object(client, PROGRAM_NAME) + + # 汇总结果 + print("\n" + "=" * 60) + print(" 测试结果汇总") + print("=" * 60) + if source: + print(" ✅ 程序源代码获取成功!") + print(f" 程序: {PROGRAM_NAME}") + print(f" 长度: {len(source)} 字符, {len(source.splitlines())} 行") + else: + print(" ⚠️ 程序源代码获取失败,请确认程序名称是否正确") + print("=" * 60) + + +if __name__ == "__main__": + main() +``` + +## 关键 API 端点速查 + +| 操作 | HTTP 方法 | 端点 | Accept | +|------|----------|------|--------| +| 服务发现 | `GET` | `/sap/bc/adt/discovery` | `*/*` | +| 读取程序源码 | `GET` | `/sap/bc/adt/programs/programs/{name}/source/main` | `text/plain` | +| 读取类源码 | `GET` | `/sap/bc/adt/oo/classes/{name}/source/main` | `text/plain` | +| 仓库搜索 | `GET` | `/sap/bc/adt/repository/informationsystem/search` | `*/*` | + +> [!note] 注意事项 +>> - discovery 端点的 Accept 必须用 `*/*`,用 `application/xml` 会返回 406 +> - 程序名在 URL 中使用**小写** + +## 🔗 相关笔记 + +- [[sap-cli/01.ADT架构与通信原理|01.ADT架构与通信原理]] +- [[sap-cli/02.查询代码-原理|02.查询代码 - 原理]] +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] diff --git a/docs/adt/07.ADT代码同步工具.md b/docs/adt/07.ADT代码同步工具.md new file mode 100644 index 0000000..ec1c3a3 --- /dev/null +++ b/docs/adt/07.ADT代码同步工具.md @@ -0,0 +1,352 @@ +--- +title: ADT 代码同步工具 — 使用指南 +created: 2026-05-19 +tags: + - SAP + - ADT + - REST + - python + - sync + - pull + - push + - activate +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/06.ADT测试代码|06.ADT 测试代码]]" +--- + +# ADT 代码同步工具 — 使用指南 + +> [!abstract] 概述 +> 通过 `test_adt_client.py` 脚本实现本地与 SAP 系统之间的 ABAP 代码双向同步。支持拉取代码到本地编辑、修改后推送回 SAP 并自动激活。 +> +> 完整工作流遵循 [[sap-cli/03.修改代码-原理]] 中描述的 **锁定 → 编辑 → 保存 → 解锁 → 激活** 流程。 + +## 1. 快速开始 + +### 前置条件 + +```bash +pip install requests +``` + +### 基本用法 + +```bash +# 拉取代码(从 SAP 下载到本地) +python sap-cli/test_adt_client.py pull + +# 推送代码(从本地上传到 SAP 并激活) +python sap-cli/test_adt_client.py push +``` + +### 配置修改 + +脚本顶部的配置区域: + +```python +ADT_BASE_URL = "http://support.learningleader.com.cn:55955" # SAP 服务器地址 +SAP_CLIENT = "100" # SAP 客户端 +USER = "admin2" # 用户名 +PASSWORD = "654321" # 密码 +PROGRAM_NAME = "ZIDTR_IMPORT_ACCOUNTING_ORDER" # 程序名 +``` + +## 2. 拉取代码 (Pull) + +### 流程 + +``` +登录 → GET 源代码 → 保存到本地 .abap 文件 +``` + +### REST API 调用序列 + +| 步骤 | HTTP 方法 | URL | 说明 | +|------|----------|-----|------| +| 登录 | `GET` | `/sap/bc/adt/compatibility/graph` | 获取 CSRF Token | +| 读取源码 | `GET` | `/sap/bc/adt/programs/programs/{name}/source/main` | Accept: text/plain | + +### 关键请求头 + +```http +GET /sap/bc/adt/programs/programs/zidtr_import_accounting_order/source/main +Accept: text/plain +x-csrf-token: {token} +X-sap-adt-sessiontype: stateless +sap-client: 100 +``` + +### 输出 + +源代码保存到脚本同目录下的 `{程序名小写}.abap` 文件,例如: + +``` +sap-cli/zidtr_import_accounting_order.abap +``` + +> [!tip] 格式说明 +> SAP ADT API 返回的源代码每行之间可能插入多余空行。脚本已内置格式优化逻辑,自动清理连续空行。 + +## 3. 推送代码 (Push) + +### 完整流程 + +``` +登录 → 查询传输请求 → 锁定对象 → 写入源代码 → 解锁对象 → 激活对象 +``` + +对应 [[sap-cli/03.修改代码-原理]] 中的完整工作流: + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ 1.锁定 │───▶│ 2.写入 │───▶│ 3.解锁 │───▶│ 4.激活 │ +│ Lock │ │ Write │ │ Unlock │ │ Activate│ +└─────────┘ └─────────┘ └─────────┘ └─────────┘ +``` + +### REST API 调用序列 + +#### Step 0: 登录获取 CSRF Token + +```http +GET /sap/bc/adt/compatibility/graph +x-csrf-token: fetch +X-sap-adt-sessiontype: stateless +``` + +响应头中返回 CSRF Token,后续所有写操作必须携带。 + +#### Step 1: 查询传输请求 + +```http +GET /sap/bc/adt/cts/transportrequests +Accept: application/vnd.sap.adt.transportorganizer.v1+xml +``` + +响应 XML 中解析第一个状态为 `D`(可修改)的传输请求号。 + +响应示例: + +```xml + +``` + +> [!important] 传输请求号 (corrNr) +> 传输请求号是代码写入 SAP 的必要参数。脚本自动查询并使用第一个可修改的请求。 +> +> XML 属性名带有命名空间前缀:`{http://www.sap.com/cts/adt/tm}number` + +#### Step 2: 锁定对象 + +```http +POST /sap/bc/adt/programs/programs/{name} +?_action=LOCK +&accessMode=MODIFY +X-sap-adt-sessiontype: stateful +x-csrf-token: {token} +``` + +响应: + +```xml + +``` + +> [!warning] 会话状态 +> 锁定操作**必须在 stateful 会话**中执行(`X-sap-adt-sessiontype: stateful`)。 +> 登录端点必须使用 `/sap/bc/adt/compatibility/graph`(不是 `/discovery`),否则锁定会失败。 + +#### Step 3: 写入源代码 + +```http +PUT /sap/bc/adt/programs/programs/{name}/source/main +?lockHandle={lockHandle} +&corrNr={传输请求号} +Content-Type: text/plain; charset=utf-8 +X-sap-adt-sessiontype: stateful +x-csrf-token: {token} + +{ABAP 源代码内容} +``` + +> [!important] 关键参数 +> - **lockHandle**:来自锁定步骤的返回值,作为**查询参数**传递 +> - **corrNr**:传输请求号,也是查询参数 +> - 请求体是**纯文本**(不是 XML),UTF-8 编码 + +#### Step 4: 解锁对象 + +```http +POST /sap/bc/adt/programs/programs/{name} +?_action=UNLOCK +&lockHandle={lockHandle} +X-sap-adt-sessiontype: stateful +x-csrf-token: {token} +``` + +#### Step 5: 激活对象 + +```http +POST /sap/bc/adt/activation +?method=activate +&preauditRequested=true +Content-Type: application/xml +x-csrf-token: {token} + + + + + +``` + +激活响应可能包含错误消息: + +```xml + + + Type "XXX" is unknown. + + +``` + +| type | 含义 | +|------|------| +| `E` | 错误 — 阻止激活 | +| `W` | 警告 — 不阻止 | +| `I` | 信息 — 不阻止 | +| `S` | 成功 | + +> [!note] 空响应 +> 激活成功时可能返回空响应体(HTTP 200),这表示激活无错误。 + +## 4. 传输请求详解 + +### 查询可用的传输请求 + +```http +GET /sap/bc/adt/cts/transportrequests +Accept: application/vnd.sap.adt.transportorganizer.v1+xml +``` + +### 响应结构 + +```xml + + + + + + +``` + +### 属性说明 + +| 属性 | 值 | 说明 | +|------|-----|------| +| `status` | `D` | 可修改 (Modifiable) | +| `status` | `R` | 已发布 (Released) | +| `number` | `DEVK901360` | 传输请求号 | +| `desc` | 文本描述 | 请求描述 | + +### XML 解析注意事项 + +属性名带有命名空间前缀,解析时需要使用完整名称: + +```python +tm_ns = "http://www.sap.com/cts/adt/tm" +for req in root.findall(f".//{{{tm_ns}}}request"): + number = req.attrib.get(f"{{{tm_ns}}}number", "") # 不是 "number" + status = req.attrib.get(f"{{{tm_ns}}}status", "") # 不是 "status" +``` + +## 5. 日志系统 + +每次执行 push/pull 操作时,详细的 API 请求/响应日志会写入: + +``` +sap-cli/adt_client.log +``` + +### 日志内容 + +``` +2026-05-19 10:57:56 | INFO | REQUEST: POST http://.../activation +2026-05-19 10:57:56 | INFO | PARAMS: {'method': 'activate', 'preauditRequested': 'true'} +2026-05-19 10:57:56 | INFO | HEADER: x-csrf-token = xxx... +2026-05-19 10:57:56 | INFO | BODY: +2026-05-19 10:57:56 | INFO | RESPONSE [ACTIVATE]: HTTP 200 OK +2026-05-19 10:57:56 | INFO | BODY (xml): ... +2026-05-19 10:57:56 | INFO | Activation msg [E] line=1: Type "XXX" is unknown. +2026-05-19 10:57:56 | INFO | Activation summary: 1 errors, 0 warnings, 0 info +2026-05-19 10:57:56 | WARNING | Activation has ERRORS - object may NOT be active! +``` + +### 排查激活失败 + +1. 查看 `adt_client.log` 中 `[ACTIVATE]` 部分 +2. 搜索 `Activation msg [E]` 找到错误消息 +3. 错误消息中的 `line` 是语句序号(不是源文件行号) +4. `href` 中的 `start=XXX` 是字符偏移量,可用于定位源码位置 + +## 6. 完整 API 端点速查 + +### 通用端点 + +| 操作 | 方法 | 端点 | 关键参数 | +|------|------|------|---------| +| 登录/CSRF | `GET` | `/sap/bc/adt/compatibility/graph` | `x-csrf-token: fetch` | +| 服务发现 | `GET` | `/sap/bc/adt/discovery` | — | +| 激活 | `POST` | `/sap/bc/adt/activation` | `method=activate` | + +### 程序 (PROG) 端点 + +| 操作 | 方法 | 端点 | 关键参数 | +|------|------|------|---------| +| 读取源码 | `GET` | `/sap/bc/adt/programs/programs/{name}/source/main` | `Accept: text/plain` | +| 锁定 | `POST` | `/sap/bc/adt/programs/programs/{name}` | `_action=LOCK` | +| 写入源码 | `PUT` | `/sap/bc/adt/programs/programs/{name}/source/main` | `lockHandle`, `corrNr` | +| 解锁 | `POST` | `/sap/bc/adt/programs/programs/{name}` | `_action=UNLOCK` | + +### 类 (CLASS) 端点 + +| 操作 | 方法 | 端点 | +|------|------|------| +| 读取源码 | `GET` | `/sap/bc/adt/oo/classes/{name}/source/main` | +| 锁定 | `POST` | `/sap/bc/adt/oo/classes/{name}` | +| 写入源码 | `PUT` | `/sap/bc/adt/oo/classes/{name}/source/main` | +| 解锁 | `POST` | `/sap/bc/adt/oo/classes/{name}` | + +### CTS 传输管理端点 + +| 操作 | 方法 | 端点 | +|------|------|------| +| 查询传输请求 | `GET` | `/sap/bc/adt/cts/transportrequests` | +| 创建传输请求 | `POST` | `/sap/bc/adt/cts/transportrequests` | +| 发布传输请求 | `POST` | `/sap/bc/adt/cts/transportrequests/{number}/newreleasejobs` | + +## 7. ABAP 开发对象激活步骤 + +> [!important] 标准激活流程 +> 1. **修改本地源代码** — 在 `.abap` 文件中编辑 +> 2. **同步到 SAP** — `python test_adt_client.py push` +> - 脚本自动执行:锁定 → 写入 → 解锁 → 激活 +> 3. **查看日志确认** — 检查 `adt_client.log` 确认无激活错误 + +## 🔗 相关笔记 + +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] — 锁定/编辑/激活原理 +- [[sap-cli/04.检查代码-原理|04.检查代码 - 原理]] — 语法检查与 ATC +- [[sap-cli/05.提交与传输-原理|05.提交与传输 - 原理]] — CTS 传输管理 +- [[sap-cli/06.ADT测试代码|06.ADT 测试代码]] — 初始测试脚本 + +## 📚 参考来源 + +- [abap-adt-py (GitHub)](https://github.com/timkoehne/abap-adt-py) — Python ADT 客户端库 +- [SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) diff --git a/docs/adt/README.md b/docs/adt/README.md new file mode 100644 index 0000000..cc9b162 --- /dev/null +++ b/docs/adt/README.md @@ -0,0 +1,92 @@ +--- +title: SAP ADT 学习笔记总览 +created: 2026-05-18 +tags: + - SAP + - ADT + - ABAP + - learning-notes +--- + +# SAP ADT (ABAP Development Tools) 学习笔记 + +> [!info] 关于本系列 +> 本系列笔记深入探讨 SAP ADT 的技术原理,覆盖从底层架构到各项开发功能的实现机制。 +> 参考来源:[SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide?locale=en-US) + +## 📌 什么是 SAP ADT? + +**ABAP Development Tools (ADT)** 是 SAP 提供的现代化 ABAP 开发工具集,基于 Eclipse 平台构建,使开发者能够在 Eclipse IDE 中进行 ABAP 开发,替代传统的 SAP GUI + SE80 开发模式。 + +核心特点: +- **客户端/服务器架构** — IDE 作为客户端,SAP 后端作为服务器 +- **REST API 驱动** — 所有操作通过 `/sap/bc/adt/*` REST 端点与后端通信 +- **多 IDE 支持** — Eclipse(当前) → VS Code(2026年) → 更多 IDE +- **平台无关** — 基于 HTTP(S) 协议,任何平台均可访问 + +## 📂 笔记结构 + +### 架构与原理 +| 笔记 | 说明 | +|------|------| +| [[sap-cli/01.ADT架构与通信原理\|01.ADT架构与通信原理]] | ADT 的三层架构、REST 通信层、服务发现机制 | + +### 核心功能原理 +| 笔记 | 说明 | +|------|------| +| [[sap-cli/02.查询代码-原理\|02.查询代码 - 原理]] | 仓库对象搜索、源代码全文搜索、Where-Used 导航 | +| [[sap-cli/03.修改代码-原理\|03.修改代码 - 原理]] | 锁定-编辑-解锁-激活工作流、源代码读写 | +| [[sap-cli/04.检查代码-原理\|04.检查代码 - 原理]] | 语法检查、ATC 代码质量检查、静态分析 | +| [[sap-cli/05.提交与传输-原理\|05.提交与传输 - 原理]] | 传输请求管理、传输发布、版本管理 | + +### 实践 +| 笔记 | 说明 | +|------|------| +| [[sap-cli/06.ADT测试代码\|06.ADT测试代码]] | Python 测试脚本:连接验证、服务发现、读取程序源代码 | +| [[sap-cli/07.ADT代码同步工具\|07.ADT代码同步工具]] | 完整使用指南:拉取代码、传输请求、推送代码、激活代码 | + +## 测试服务器 +ADT url=http://support.learningleader.com.cn:55955/sap/bc/adt?sap-client=100 +user=admin2 +password=654321 +PROGRAM=ZIDTR_IMPORT_ACCOUNTING_ORDER + +## 🔗 关键外部资源 + +- [SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide?locale=en-US) +- [Behind the Design: How We Transformed the ABAP Development Tools](https://community.sap.com/t5/technology-blog-posts-by-sap/behind-the-design-how-we-transformed-the-abap-development-tools/ba-p/14258121) +- [End-to-End SAP Automation with ADT REST Services and AI](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) +- [Creating an ABAP in Eclipse Plug-in Using the ADT SDK](https://community.sap.com/t5/application-development-and-automation-blog-posts/creating-a-abap-in-eclipse-plug-in-using-the-adt-sdk-part-2/ba-p/13093582) +- [abap-adt-api (GitHub)](https://github.com/marcellourbani/abap-adt-api) — 开源 ADT REST 客户端 +- [erpl-adt (GitHub)](https://github.com/DataZooDE/erpl-adt) — ADT REST CLI 和 MCP 服务器 + +## 🗺️ 技术栈一览 + +``` +┌─────────────────────────────────────────────────────┐ +│ 开发者 IDE 层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ Eclipse │ │ VS Code │ │ 自定义 HTTP 客户端│ │ +│ │ (ADT插件) │ │ (2026+) │ │ (Python/JS/AI) │ │ +│ └─────┬─────┘ └────┬─────┘ └───────┬──────────┘ │ +│ │ │ │ │ +│ └──────────────┼────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ REST over HTTP(S) │ │ +│ │ /sap/bc/adt/* │ │ +│ └─────────┬───────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ SAP ABAP 后端服务器 │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌───────────────┐ │ │ +│ │ │ICF Handler│ │ADT REST │ │ ABAP Runtime │ │ │ +│ │ │ (SICF) │ │Framework│ │ (Syntax/ATC) │ │ │ +│ │ └─────────┘ └─────────┘ └───────────────┘ │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌───────────────┐ │ │ +│ │ │Repository│ │Transport│ │ Activation │ │ │ +│ │ │ (搜索) │ │ (CTS) │ │ Framework │ │ │ +│ │ └─────────┘ └─────────┘ └───────────────┘ │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` diff --git a/docs/assets/architecture.drawio b/docs/assets/architecture.drawio new file mode 100644 index 0000000..cf3d0a6 --- /dev/null +++ b/docs/assets/architecture.drawio @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/architecture.drawio.png b/docs/assets/architecture.drawio.png new file mode 100644 index 0000000..c3a51fb Binary files /dev/null and b/docs/assets/architecture.drawio.png differ diff --git a/docs/dev/AGENTS.md b/docs/dev/AGENTS.md new file mode 100644 index 0000000..4d901c6 --- /dev/null +++ b/docs/dev/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md — AI 代理协作指南 + +## 项目概述 + +sap-cli 是 Python CLI 工具,通过 SAP ADT REST API 管理 ABAP 开发对象。 +修改代码前请先阅读 `CLAUDE.md` 了解项目结构和开发规范。 + +## OpenSpec 规范驱动开发 + +本项目使用 OpenSpec 进行规范驱动开发。**修改任何功能前,必须先查阅对应的 spec 文件。** + +### Spec 文件位置 + +所有系统行为的 source of truth 位于 `openspec/specs/` 下: + +| 域 | 文件 | 内容 | +|----|------|------| +| 连接与认证 | `openspec/specs/connection/spec.md` | 登录、认证、会话管理 | +| 对象生命周期 | `openspec/specs/object-lifecycle/spec.md` | 16 种对象类型的 CRUD | +| 批量操作 | `openspec/specs/batch-ops/spec.md` | 初始化、批量同步、清单刷新 | +| 搜索与浏览 | `openspec/specs/search-browse/spec.md` | 对象列表、where-used、源码搜索 | +| 传输管理 | `openspec/specs/transport/spec.md` | 传输请求管理、传输对象列表 | +| 代码质量 | `openspec/specs/quality/spec.md` | ATC 检查、代码格式化、Diff | +| 配置管理 | `openspec/specs/config/spec.md` | 多系统配置、凭据存储、Profile | + +### 变更流程 + +1. **查阅 spec**: 在 `openspec/specs/` 中找到相关的 domain spec +2. **创建 change**: 在 `openspec/changes//` 下创建提案 +3. **编写 proposal.md**: 描述变更内容、影响范围 +4. **编写 design.md**: 技术设计,包含 ADT 端点详情 +5. **编写 tasks.md**: 任务列表,每个任务独立可测试 +6. **实现**: 按 tasks.md 逐步编写代码 +7. **更新 spec**: 完成后将变更合并回 specs/ + +### Spec 编写规则 + +- 使用 Given/When/Then 格式编写 Scenario +- 每个 Requirement MUST 指定 ADT API 端点 +- 使用 RFC 2119 关键词: SHALL(必须)、MUST(强制)、SHOULD(推荐) +- 中文编写说明文字,英文编写 Requirement 名称和 Scenario 关键词 + +## 代码修改指南 + +### 修改前检查清单 + +- [ ] 阅读了相关的 spec 文件 +- [ ] 理解了现有的代码结构 +- [ ] 已创建 change proposal(如果涉及功能变更) +- [ ] 了解受影响的 ADT 端点 + +### 代码风格 + +- PEP 8 编码规范 +- 使用 type hints(`from __future__ import annotations`) +- 使用 f-strings 格式化字符串 +- 函数和类使用 docstring +- logging 使用 `logging.getLogger("sapcli.module")` + +### 新增对象类型 + +如需添加新的 ABAP 对象类型支持: + +1. 在 `sapcli/types.py` 中定义 `ObjectTypeConfig` 并 `_register()` +2. 更新 `openspec/specs/core/spec.md` 的类型注册表 +3. 如有特殊的 URI 模板或创建逻辑,在 `sapcli/client.py` 中处理 +4. 在 `sapcli/scanner.py` 的 `DIRECTORY_TYPE_MAP` 中添加目录映射 +5. 编写测试用例 + +### 新增 CLI 命令 + +1. 在 `sapcli/cli/parser.py` 的 `build_parser()` 中添加 subparser +2. 在 `sapcli/commands/` 下实现 `cmd_xxx()` 函数(或添加到现有模块) +3. 在 `sapcli/commands/__init__.py` 中导出 +4. 在 `sapcli/cli/app.py` 的 `command_map` 中注册 +5. 更新相关 spec 文件 +6. 编写测试用例 + +### 错误处理 + +- 所有自定义异常继承自 `SapCliError` +- 新增异常类型在 `sapcli/exceptions.py` 中定义 +- CLI 主循环捕获 `SapCliError`,打印友好信息后 exit(1) + +## 测试 + +```bash +python -m pytest tests/test_sapcli.py -v +``` + +## 注意事项 + +- SAP ADT REST API 使用 HTTP Basic Auth + CSRF Token +- 大部分 ADT 端点需要 stateful 会话(锁定/写入时) +- DDIC 对象通过 XML body 创建(非纯文本) +- function 类型名称格式: `组名/模块名` +- tabletype 使用 VIT 端点,需要大写名称 diff --git a/docs/dev/CLAUDE.md b/docs/dev/CLAUDE.md new file mode 100644 index 0000000..7930615 --- /dev/null +++ b/docs/dev/CLAUDE.md @@ -0,0 +1,180 @@ +# sap-cli — AI 协作指南 + +> [!danger] 核心约束:文档优先,禁止擅自读取源代码 +> 本项目提供了完整的技术文档体系,AI 在协作时**必须优先阅读文档**获取所需信息,**严禁未经许可直接读取 Python 源代码文件**(`sapcli/` 包内的 `.py` 文件、`main.py`、`test/` 目录)。 +> +> 如果发现文档描述与实际行为不一致,**必须先向用户说明差异并征得同意**,才能读取源代码进行校验。 + +## 项目简介 + +sap-cli 是一个命令行工具,通过 SAP ADT REST API 实现 ABAP 开发对象的远程管理(创建、同步、下载、查询、删除),让开发者可以在本地编辑器中编写 ABAP 代码,一行命令同步到 SAP 系统。 + +## 文档体系(AI 必读) + +> [!important] 文档覆盖了项目的全部设计细节,AI 应按需查阅以下文档,而非阅读源代码。 + +| 文档 | 用途 | 文件路径 | +| 文档 | 说明 | 路径 | +|------|------|------| +| 使用指南 | 命令详解、参数说明、输出示例 | `docs/guide.md` | +| 技术说明 | 架构、模块职责、通信流程、异常体系 | `docs/dev/architecture.md` | +| 批量同步设计 | manifest 清单、拓扑排序、批量 sync | `docs/dev/batch-design.md` | +| 测试报告 | 测试场景覆盖 | `docs/dev/test-report.md` | +| ADT 学习笔记总览 | ADT 原理系列索引 | `docs/adt/README.md` | + +### 按需求查阅 + +| 需求 | 文档 | +|------|------| +| 了解某个命令怎么用 | `docs/guide.md` | +| 了解模块接口 / API 调用链 | `docs/dev/architecture.md` | +| 了解批量同步 / manifest | `docs/dev/batch-design.md` | +| 了解 ADT REST API 原理 | `docs/adt/` 目录下的系列笔记 | +| 了解支持哪些对象类型 | `README.md` 功能矩阵 | + +## AI 行为规范 + +### 1. 文档优先原则 + +- **所有问题先查文档** — 项目文档完整覆盖了架构、命令、API、模块接口、异常体系等全部信息 +- **文档即真相** — 以文档描述为准进行操作,不需要通过读代码"验证" +- **引用文档回答** — 回答用户问题时引用具体文档路径,方便用户追溯 + +### 2. 源代码访问限制 + +> [!warning] 以下文件属于源代码,未经用户许可**禁止读取**: +> - `main.py` — CLI 入口 +> - `sapcli/*.py` — 核心包(config / types / client / commands / exceptions / manifest / scanner / sorter) +> - `test/**/*.py` — 测试套件 + +**允许读取的文件:** +- `*.md` — 所有文档文件 +- `*.abap` — ABAP 源代码文件(这是用户要同步到 SAP 的业务代码) +- `config.ini` — 连接配置(注意不要泄露密码) +- `manifest.json` — 项目清单文件 +- `*.drawio` / `*.png` — 架构图等资源文件 + +### 3. 源代码校验流程 + +当文档描述与实际执行效果不一致时: + +``` +1. 向用户报告:"文档描述 XXX,但实际表现为 YYY,差异点为 ZZZ" +2. 说明需要读取哪些源文件、读取目的是什么 +3. 等待用户明确同意后,方可读取源代码 +4. 读取后只关注差异点,不要全量阅读无关代码 +``` + +### 4. 典型操作流程 + +#### 帮助用户同步代码到 SAP + +``` +1. 确认用户的 ABAP 文件路径和对象信息(名称、类型) +2. 执行: python main.py sync --name <名称> --type <类型> --path <文件路径> +3. 如果成功 → 告知用户 +4. 如果语法检查失败 → 展示错误信息,提示用户修改后重试 +5. 如果激活失败 → 展示错误信息,提示用户修改后重试 +``` + +#### 帮助用户创建新对象 + +``` +1. 确认对象名称、类型、描述 +2. 执行: python main.py create --name <名称> --type <类型> --description "<描述>" +3. 创建成功后提示用户下载模板或直接编辑 +``` + +#### 帮助用户批量同步 + +``` +1. 确认项目根目录路径 +2. 如果没有 manifest.json,先执行: python main.py init --path <项目目录> +3. 预览执行计划: python main.py sync --all --path <项目目录> --dry-run +4. 确认后执行(推荐指定 --corr_nr 避免交互中断): + python main.py sync --all --path <项目目录> --corr_nr <传输请求号> +5. 如果不指定 --corr_nr,会在启动时弹出一次传输请求选择,后续自动复用 +``` + +## 命令速查 + +```bash +# 单对象操作 +python main.py create --name <名称> --type <类型> [--description <描述>] [--corr_nr <请求号>] +python main.py info --name <名称> --type <类型> +python main.py download --name <名称> --type <类型> --path <保存目录> +python main.py sync --name <名称> --type <类型> --path <.abap文件> [--corr_nr <请求号>] +python main.py delete --name <名称> --type <类型> + +# 批量操作(基于 manifest 清单) +python main.py init --path <项目目录> +python main.py sync --all --path <项目目录> [--dry-run] [--fail-fast] +python main.py refresh --path <项目目录> + +# 搜索与浏览 +python main.py list --type <类型> [--package <包名>] [--prefix <前缀>] +python main.py whereused --name <名称> --type <类型> +python main.py search --query <关键词> [--type <类型>] + +# 传输管理 +python main.py transport list +python main.py transport info --corr_nr <请求号> +python main.py transport release --corr_nr <请求号> +python main.py transport objects --corr_nr <请求号> + +# 代码质量 +python main.py check --name <名称> --type <类型> +python main.py format --name <名称> --type <类型> +python main.py diff --name <名称> --type <类型> [--path <本地文件>] + +# 包管理 +python main.py package create --name <包名> [--description <描述>] +python main.py package info --name <包名> +python main.py package list --name <包名> + +# CDS View +python main.py cds download --name --path <目录> +python main.py cds sync --name --path +python main.py cds create --name [--description <描述>] + +# 辅助工具 +python main.py analyze --path <项目目录> +python main.py scaffold --name <名称> --template <模板> [--package <包名>] +python main.py config show +python main.py config list-profiles +``` + +## 对象类型速查 + +| 类型参数 | 说明 | 名称格式 | +|---------|------|---------| +| `report` | 程序/报表 | 直接使用程序名 | +| `class` | ABAP 类 | 直接使用类名 | +| `interface` | ABAP 接口 | 直接使用接口名 | +| `function` | 函数模块 | `函数组名/函数模块名` | +| `functiongroup` | 函数组 | 直接使用函数组名 | +| `include` | Include 程序 | 直接使用程序名 | +| `domain` | 域 | 直接使用域名 | +| `dataelement` | 数据元素 | 直接使用元素名 | +| `table` | 透明表 | 直接使用表名 | +| `structure` | 结构 | 直接使用结构名 | +| `tabletype` | 表类型 | 直接使用类型名 | +| `cdsview` | CDS View | 直接使用 CDS 名 | +| `messageclass` | 消息类 | 直接使用消息类名 | +| `view` | 数据库视图 | 直接使用视图名 | +| `searchhelp` | 搜索帮助 | 直接使用搜索帮助名 | +| `lockobject` | 锁对象 | 直接使用锁对象名 | + +## 依赖排序优先级(批量同步) + +``` +domain(10) → dataelement(20) → structure(25) → table(30) → tabletype(40) → view(42) → lockobject(44) → searchhelp(46) → messageclass(48) → interface(50) → class(60) → function(70) → functiongroup(75) → include(78) → cdsview(79) → report(80) +``` + +## 关键技术要点 + +- **认证**: HTTP Basic Auth + CSRF Token +- **sync 流程**: 检查/创建 → 锁定 → 写入 → 解锁 → 语法检查 → 激活 +- **锁定机制**: stateful session,自动检测传输请求绑定 +- **配置加载优先级**: 环境变量 > --config 指定文件 > 工作目录 config.ini > 脚本目录 config.ini +- **唯一第三方依赖**: `requests` diff --git a/docs/dev/adt-endpoint-reference.md b/docs/dev/adt-endpoint-reference.md new file mode 100644 index 0000000..4b695b5 --- /dev/null +++ b/docs/dev/adt-endpoint-reference.md @@ -0,0 +1,51 @@ +# ADT 对象类型码映射(来源:abap-adt-api objectcreator.ts) + +## 已确认的 ADT 可创建类型 + +| TypeId | 对象类型 | ADT 端点路径 | +|--------|---------|-------------| +| CLAS/OC | Class | /oo/classes | +| INTF/OI | Interface | /oo/interfaces | +| PROG/P | Program/Report | /programs/programs | +| PROG/I | Include | /programs/includes | +| FUGR/F | Function Module | /functions/groups/{grp}/fmodules | +| FUGR/FF | Function Group | /functions/groups | +| DOMA/DD | Domain | /ddic/domains | +| DTEL/DE | Data Element | /ddic/dataelements | +| TABL/DT | Table | /ddic/tables | +| TABL/DS | Structure | /ddic/structures | +| MSAG/N | Message Class | /oo/t100/messages/classes | +| DDLS/DF | CDS View (DDL Source) | /ddic/ddlsources | +| DCLS/DL | CDS Access Control (DCL) | /ddic/dclsources | +| DDLX/EX | Metadata Extension | /ddic/metadataextensions | +| DDLA/ADF | Abstract Entity | /ddic/ddlabstractentities (推测) | +| SRVD/SRV | Service Definition | /ddic/servicedefinitions (推测) | +| AUTH | Authorization Object | (需验证) | +| SUSO/B | Auth Check Object | (需验证) | + +## 确认不在 ADT 标准创建列表中的类型 + +| 类型 | 原因 | 替代方案 | +|------|------|---------| +| Type Group (TYPE-POOL) | ADT 可能归入 programs | 通过 /programs/typegroups 尝试 | +| Number Range Object | 无 ADT 创建端点 | 需 RFC(NUMBER_RANGE_GET_INFO) | +| Enhancement Spot | 使用专用 /enhancements 端点 | 需验证 | +| Behavior Definition (BDEF) | RAP 专用,S/4HANA Cloud 才有 | NW 7.40 可能不支持 | +| SICF Service | 使用 /discovery/services | 需验证 | +| Table Type | 通过 VIT 端点 | 已有部分支持 | + +## Phase 2 调整建议 + +### 可直接实现(ADT 有标准端点) +- ✅ DCL (CDS Access Control) — /ddic/dclsources,类似 CDS View +- ✅ DDLX (Metadata Extension) — /ddic/metadataextensions,类似 CDS View +- ✅ DDLA (Abstract Entity) — 类似 CDS View + +### 需要验证端点后决定 +- ⚠️ Type Group — 可能在 /programs/typegroups +- ⚠️ Enhancement Spot — 可能在 /enhancements +- ⚠️ SICF Service — 可能通过 /discovery/services + +### NW 7.40 不支持(需移到远期) +- ❌ Behavior Definition (BDEF) — RAP 专用,NW 7.40 无此功能 +- ❌ Number Range Object — 无 ADT 端点,需 RFC diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md new file mode 100644 index 0000000..60d71a9 --- /dev/null +++ b/docs/dev/architecture.md @@ -0,0 +1,379 @@ +# sap-cli — 技术说明 + +## 目录 + +- [项目定位](#项目定位) +- [架构概览](#架构概览) +- [包结构](#包结构) +- [模块职责](#模块职责) +- [通信流程](#通信流程) +- [对象类型映射](#对象类型映射) +- [异常体系](#异常体系) +- [配置加载策略](#配置加载策略) +- [日志机制](#日志机制) +- [错误处理策略](#错误处理策略) +- [测试架构](#测试架构) +- [依赖关系](#依赖关系) + +--- + +## 项目定位 + +sap-cli 是一个轻量级命令行工具,通过 SAP ADT (ABAP Development Tools) REST API 与 SAP NetWeaver 系统交互,实现 ABAP 开发对象的远程管理(创建、同步、下载、查询、删除)。 + +**核心设计原则:** + +- 零 SAP 专有客户端依赖,仅基于标准 HTTP 协议 +- 唯一第三方依赖为 `requests` +- 所有通信均通过 ADT REST API 完成,数据格式为 XML / Plain Text + +--- + +## 架构概览 + +![[sap-cli/doc/assets/architecture.drawio.png]] + +架构分为三层: + +| 层级 | 组件 | 说明 | +|------|------|------| +| **用户终端** | `main.py` | CLI 入口,argparse 参数解析与命令分发 | +| **核心包** | `sapcli/` | 5 个模块:config / types / exceptions / client / commands | +| **外部系统** | SAP NetWeaver | ADT REST API 端点(Programs / OO Objects / DDIC / CTS) | + +左侧外部文件通过虚线连接至核心包: + +- `config.ini` — 连接配置(读取) +- `.abap 文件` — 本地源代码(读写) +- `log/` — 运行日志(写入) + +核心通信链路:`client.py (ADTClient)` 通过 HTTP/REST(Basic Auth + CSRF Token)与 SAP 系统交互。 + +## 包结构 + +``` +sap-cli/ +├── main.py # CLI 入口(argparse + 命令分发) +├── config.ini # 默认连接配置文件 +├── sapcli/ # 核心功能包 +│ ├── __init__.py +│ ├── config.py # 配置加载(INI / 环境变量) +│ ├── types.py # 对象类型定义与名称解析 +│ ├── client.py # ADT REST API 客户端 +│ ├── commands.py # 5 个业务命令的实现 +│ └── exceptions.py # 自定义异常层级 +├── test/ +│ └── script/ +│ └── test_main.py # 集成测试套件(子进程调用) +├── doc/ # 文档 +│ ├── ADT/ # ADT 技术原理系列 +│ ├── 技术说明.md # 本文档 +│ ├── sap-cli使用指南.md # 用户操作指南 +│ └── 测试报告.md # 测试覆盖报告 +└── log/ # 运行时日志目录 +``` + +--- + +## 模块职责 + +### main.py — CLI 入口 + +| 组件 | 职责 | +|------|------| +| 参数定义 | 定义 5 个子命令(download / sync / delete / info / create)及其参数 | +| 配置加载 | 调用 `sapcli.config.load_config()` 获取连接参数 | +| 客户端初始化 | 创建 `ADTClient` 实例并执行登录(获取 CSRF Token) | +| 命令分发 | 根据子命令名称映射到对应的 `cmd_*()` 函数 | + +### sapcli/config.py — 配置管理 + +| 组件 | 职责 | +|------|------| +| `SAPConfig` | 连接参数数据类(host / client / user / password) | +| `load_config()` | 按优先级链加载配置:环境变量 → 指定文件 → 工作目录 → 脚本目录 | + +### sapcli/types.py — 对象类型系统 + +| 组件 | 职责 | +|------|------| +| `ObjectTypeConfig` | 单个 ABAP 对象类型的 URI 模板与元数据(数据类) | +| `OBJECT_TYPE_CONFIG` | 9 种 ABAP 对象类型的注册表 | +| `ParsedName` | 解析后的对象名组件(数据类) | +| `parse_object_name()` | 将用户输入解析为 ADT URI 所需的路径片段 | +| `get_type_config()` | 按类型键查询对象类型配置 | +| `all_type_keys()` | 返回所有受支持的对象类型列表 | + +### sapcli/client.py — ADT 通信层 + +| 方法 | 职责 | +|------|------| +| `login()` | Basic Auth 认证 + 获取 CSRF Token | +| `object_exists()` | 检查对象是否存在于 SAP 系统 | +| `get_source()` | 下载对象源代码 | +| `set_source()` | 上传(写入)对象源代码 | +| `lock()` / `unlock()` | 对象锁定 / 解锁(stateful session) | +| `get_transport_request()` | 获取可用的传输请求号 | +| `activate()` | 激活对象 | +| `syntax_check()` | 语法检查 | +| `create_object()` | 创建新对象(支持 9 种类型) | +| `delete_object()` | 删除对象 | +| `function_group_exists()` | 检查函数组是否存在 | +| `create_function_group()` | 创建函数组容器 | + +### sapcli/commands.py — 命令处理 + +| 函数 | 职责 | +|------|------| +| `cmd_download()` | 下载对象源码到本地 `.abap` 文件 | +| `cmd_sync()` | 同步本地源码到 SAP(含自动创建、锁定、写入、检查、激活) | +| `cmd_info()` | 查询并展示对象元数据 | +| `cmd_delete()` | 从 SAP 系统删除对象(需用户确认) | +| `cmd_create()` | 在 SAP 系统创建新对象(支持模板预填充) | + +### sapcli/exceptions.py — 异常层级 + +| 异常类 | 触发场景 | +|--------|---------| +| `SapCliError` | 所有自定义异常的基类 | +| `ConfigError` | 配置文件缺失或格式错误 | +| `LoginError` | SAP 登录失败(认证错误或网络不通) | +| `ObjectNotFoundError` | 请求的对象在 SAP 系统中不存在 | +| `ObjectAlreadyExistsError` | 创建时对象已存在 | +| `LockError` | 对象锁定冲突 | +| `ActivationError` | 对象激活失败 | +| `SyntaxCheckError` | 语法检查未通过 | +| `DeleteError` | 删除操作失败 | +| `CreateError` | 创建操作失败 | +| `InvalidNameError` | 对象名称格式不合法 | + +--- + +## 通信流程 + +### 认证机制 + +采用 HTTP Basic Auth + CSRF Token 双重验证: + +``` +┌────────┐ ┌────────┐ +│ Client │ │ SAP │ +└───┬────┘ └───┬────┘ + │ GET /sap/bc/adt/compatibility/graph │ + │ Header: x-csrf-token: fetch │ + │ Header: Authorization: Basic *** │ + │ ──────────────────────────────────────────► │ + │ │ + │ 200 OK │ + │ Header: x-csrf-token: │ + │ ◄────────────────────────────────────────── │ + │ │ + │ 后续所有请求携带 CSRF Token │ + │ (POST / PUT / DELETE 必须携带) │ + │ ──────────────────────────────────────────► │ +``` + +### sync 命令 — 完整 API 调用链 + +`sync` 是最核心的命令,执行以下完整流程: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Step 1: 登录获取 CSRF Token │ +│ GET /sap/bc/adt/compatibility/graph │ +├─────────────────────────────────────────────────────────────┤ +│ Step 2: 检查对象存在性(不存在则自动创建) │ +│ GET /sap/bc/adt/programs/programs/{name} │ +├─────────────────────────────────────────────────────────────┤ +│ Step 3: 获取传输请求号 │ +│ GET /sap/bc/adt/cts/transportrequests │ +├─────────────────────────────────────────────────────────────┤ +│ Step 4: 锁定对象(stateful session) │ +│ POST /sap/bc/adt/programs/programs/{name} │ +│ ?_action=LOCK&accessMode=MODIFY │ +├─────────────────────────────────────────────────────────────┤ +│ Step 5: 写入源代码 │ +│ PUT /sap/bc/adt/programs/programs/{name}/source/main │ +│ ?lockHandle={handle} │ +├─────────────────────────────────────────────────────────────┤ +│ Step 6: 解锁对象 │ +│ POST /sap/bc/adt/programs/programs/{name} │ +│ ?_action=UNLOCK&lockHandle={handle} │ +├─────────────────────────────────────────────────────────────┤ +│ Step 7: 语法检查(可选) │ +│ POST /sap/bc/adt/activation?method=check │ +├─────────────────────────────────────────────────────────────┤ +│ Step 8: 激活对象 │ +│ POST /sap/bc/adt/activation?method=activate │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 锁定与传输请求 — 容错机制 + +工具内置了两层自动重试策略: + +1. **锁定重试** — 若对象已在其他传输请求中被锁定,从错误响应中提取已有的传输请求号,使用该传输号重新锁定 +2. **写入重试** — 若写入源码时因传输号不匹配而失败,自动使用正确的传输号重试 + +### Session 管理 + +| 操作类型 | Session 模式 | 说明 | +|---------|-------------|------| +| 锁定 / 写入 / 解锁 | **stateful** | 通过 `X-sap-adt-sessiontype: stateful` 头维持会话状态 | +| 读取 / 激活 | **stateless** | 无状态请求,独立响应 | +| 解锁后 | 自动切换回 **stateless** | 确保会话资源释放 | + +--- + +## 对象类型映射 + +每种 ABAP 对象类型对应不同的 ADT URI 模板: + +| 类型 | 对象 URI | 源码 URI | 备注 | +|------|---------|---------|------| +| `report` | `/sap/bc/adt/programs/programs/{name}` | `.../source/main` | ABAP 报表程序 | +| `class` | `/sap/bc/adt/oo/classes/{name}` | `.../source/main` | ABAP 类 | +| `interface` | `/sap/bc/adt/oo/interfaces/{name}` | `.../source/main` | ABAP 接口 | +| `function` | `/sap/bc/adt/functions/groups/{group}/fmodules/{name}` | `.../source/main` | 需 `函数组/函数名` 格式 | +| `functiongroup` | `/sap/bc/adt/functions/groups/{name}` | 无源码 | 仅容器,不含源码 | +| `domain` | `/sap/bc/adt/ddic/domains/{name}` | `.../source/main` | ABAP 域 | +| `dataelement` | `/sap/bc/adt/ddic/dataelements/{name}` | `.../source/main` | ABAP 数据元素 | +| `table` | `/sap/bc/adt/ddic/tables/{name}` | `.../source/main` | ABAP 透明表 | +| `tabletype` | `/sap/bc/adt/vit/wb/object_type/ttypda/object_name/{name}` | 无源码 (VIT 端点) | ABAP 表类型,仅支持元数据查询 | + +> [!note] function 类型的特殊性 +> `function` 类型需要 `函数组名/函数名` 格式输入(如 `ZGROUP/Z_MY_FUNC`),URI 中包含两层路径参数(`{group}` 和 `{name}`)。 + +--- + +## 配置加载策略 + +配置按以下优先级链加载,高优先级源覆盖低优先级源: + +``` + ┌──────────────────────────────┐ + │ 环境变量 (SAP_HOST 等) │ ← 最高优先级 + └──────────────┬───────────────┘ + │ 覆盖 + ┌──────────────▼───────────────┐ + │ --config 指定的 INI 文件 │ + └──────────────┬───────────────┘ + │ 回退 + ┌──────────────▼───────────────┐ + │ 当前工作目录 / config.ini │ + └──────────────┬───────────────┘ + │ 回退 + ┌──────────────▼───────────────┐ + │ 脚本同目录 / config.ini │ ← 最低优先级 + └──────────────────────────────┘ +``` + +支持的环境变量映射: + +| INI 字段 | 环境变量 | +|---------|---------| +| `host` | `SAP_HOST` | +| `client` | `SAP_CLIENT` | +| `user` | `SAP_USER` | +| `password` | `SAP_PASSWORD` | + +--- + +## 日志机制 + +| 属性 | 值 | +|------|-----| +| 日志路径 | `log/adt_tools.log` | +| 日志级别 | `DEBUG`(记录所有 HTTP 请求/响应详情) | +| 日志格式 | `时间 │ 级别 │ 消息` | +| 写入模式 | 追加写入(append) | + +日志记录每次 API 调用的完整上下文:请求 URL、请求参数、响应状态码、响应体摘要,便于生产环境问题排查。 + +--- + +## 错误处理策略 + +### 按场景分类 + +| 场景 | 处理方式 | 对应异常 | +|------|---------|---------| +| 登录失败 | 终止并报错 | `LoginError` | +| 对象不存在(download / info / delete) | 提示不存在并退出 | `ObjectNotFoundError` | +| 对象不存在(sync) | 自动创建空对象后继续 | — | +| 对象已存在(create) | 提示已存在并退出 | `ObjectAlreadyExistsError` | +| 语法检查失败 | 显示错误详情,不执行激活 | `SyntaxCheckError` | +| 激活失败 | 显示错误/警告详情并退出 | `ActivationError` | +| 锁定冲突 | 自动提取已有传输号重试 | `LockError` | +| 写入冲突 | 自动使用正确传输号重试 | — | +| 删除操作 | 需用户输入 `yes` 确认 | — | + +### 错误处理设计原则 + +1. **快速失败** — 非预期错误立即终止,避免静默失败 +2. **自动恢复** — 锁定冲突和写入冲突具备自动重试能力 +3. **信息透明** — 所有错误均输出详细信息,日志记录完整上下文 + +--- + +## 测试架构 + +测试套件位于 `test/script/test_main.py`,通过子进程调用 `main.py`,确保端到端验证。 + +### 测试设计 + +- **独立性** — 每个测试场景独立运行,使用时间戳生成唯一临时对象名 +- **完整性** — 每个场景覆盖完整生命周期(创建 → 验证 → 修改 → 验证 → 删除 → 验证) +- **自清理** — 测试结束后自动清理临时文件(`test_output/` 目录) +- **可分组** — 支持按场景组选择运行 + +### 测试场景组 + +| 组名 | 说明 | 包含场景 | +|------|------|---------| +| `happy` | 正常流程 | Report / Class / Function+FunctionGroup / Interface 生命周期 | +| `error` | 错误处理 | 语法错误 / 对象不存在 / 重复创建 | +| `edge` | 边界条件 | sync 自动创建 / 删除确认 / DDIC 查询 | +| `existing` | 已有对象操作 | 对已有对象的下载和同步 | +| `config` | 配置验证 | 配置文件加载测试 | + +--- + +## 依赖关系 + +### 运行时依赖 + +``` +main.py +├── sapcli/ # 内部包 +│ ├── config.py +│ │ └── configparser # 解析 INI 配置文件 +│ ├── client.py +│ │ ├── requests # HTTP 通信(唯一第三方依赖) +│ │ └── xml.etree # 解析 ADT XML 响应 +│ ├── commands.py +│ │ └── logging # 日志记录 +│ ├── types.py +│ │ └── re # 对象名称解析 +│ └── exceptions.py +│ └── (无外部依赖) +├── argparse # 命令行参数解析 +└── logging # 日志配置 +``` + +### 测试时依赖 + +``` +test/script/test_main.py +├── subprocess # 子进程调用 main.py +└── 标准库(os, sys, time, shutil, re) +``` + +### 第三方依赖汇总 + +| 依赖 | 用途 | 安装方式 | +|------|------|---------| +| `requests` | HTTP 通信 | `pip install requests` | + +> [!tip] 零额外依赖 +> 除 `requests` 外,所有依赖均为 Python 标准库,无需额外安装。 diff --git a/docs/dev/batch-design.md b/docs/dev/batch-design.md new file mode 100644 index 0000000..3556980 --- /dev/null +++ b/docs/dev/batch-design.md @@ -0,0 +1,684 @@ +--- +title: 批量同步设计 — 项目清单驱动 +created: 2026-05-25 +updated: 2026-05-25 +tags: + - SAP + - ADT + - design +parent: "[[sap-cli/doc/sap-cli使用指南|sap-cli使用指南]]" +related: + - "[[sap-cli/doc/技术说明|技术说明]]" + - "[[sap-cli/doc/sap-cli使用指南#4.3 源代码同步激活 (`sync`)|sync 命令]]" +--- + +# 批量同步设计 — 项目清单驱动 + +> [!abstract] 概述 +> 为 sap-cli 工具增加**项目清单**机制,支持批量源代码同步。核心思路:在源代码目录下维护 `manifest.json` 清单文件,记录每个开发对象在 SAP 系统中的状态,后续操作基于清单驱动,避免重复查询。 + +> [!info] 设计动机 +> 现有 `sync` 命令只支持单对象操作,缺少以下能力: +> - 批量扫描本地文件并识别对象 +> - 预查询 SAP 系统状态,制定执行计划 +> - 按依赖关系排序,批量执行同步 +> - 缓存对象状态,避免每次重复查询 + +## 1. 目录约定 + +### 1.1 项目目录结构 + +项目根目录下按对象类型组织子目录,`manifest.json` 位于根目录: + +``` +project/ ← --path 指向这里(项目根目录) +├── manifest.json ← 状态清单(init 自动生成) +├── reports/ +│ └── {对象名小写}.abap +├── classes/ +│ └── {对象名小写}.abap +├── interfaces/ +│ └── {对象名小写}.abap +├── functions/ +│ └── {函数组名小写}/ +│ └── {函数模块名小写}.abap +├── domains/ +│ └── {对象名小写}.abap +├── dataelements/ +│ └── {对象名小写}.abap +├── tables/ +│ └── {对象名小写}.abap +└── tabletypes/ + └── {对象名小写}.abap +``` + +### 1.2 目录名 → 类型映射 + +`init` 扫描时按以下映射表从目录名推断对象类型: + +| 目录名 | 对象类型 | 文件 → 对象名规则 | +|--------|---------|------------------| +| `reports/` | `report` | `zmy_report.abap` → `ZMY_REPORT` | +| `classes/` | `class` | `zcl_my_class.abap` → `ZCL_MY_CLASS` | +| `interfaces/` | `interface` | `zif_my_interface.abap` → `ZIF_MY_INTERFACE` | +| `functions/` | `function` | `zgroup/z_my_func.abap` → `ZGROUP/Z_MY_FUNC` | +| `domains/` | `domain` | `z_status.abap` → `Z_STATUS` | +| `dataelements/` | `dataelement` | `z_status.abap` → `Z_STATUS` | +| `tables/` | `table` | `zmy_table.abap` → `ZMY_TABLE` | +| `tabletypes/` | `tabletype` | `zty_table.abap` → `ZTY_TABLE` | + +> [!note] 文件名 → 对象名规则 +> - 文件名去掉 `.abap` 后缀后转为大写即为对象名 +> - `functions/` 下的子目录名作为函数组名,子目录内的文件名作为函数模块名 +> - 对象名为 `函数组名大写/函数模块名大写` 格式 + +## 2. 清单文件 (`manifest.json`) + +### 2.1 结构定义 + +```json +{ + "version": 1, + "last_init": "2026-05-25T10:00:00", + "last_refresh": "2026-05-25T14:00:00", + "objects": { + "<对象名>": { + "type": "<对象类型>", + "file": "<相对文件路径>", + "system_status": "<状态>", + "corr_nr": "<传输请求号或null>", + "depends_on": ["<依赖对象名>"], + "last_sync": "", + "last_sync_result": "<结果>" + } + } +} +``` + +### 2.2 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `version` | int | 清单格式版本号,当前为 `1` | +| `last_init` | string | 最近一次 `init` 的时间戳 | +| `last_refresh` | string | 最近一次 `refresh` 的时间戳 | +| `objects` | object | 以对象名为 key 的字典 | +| `objects..type` | string | 对象类型(report/class/interface/...) | +| `objects..file` | string | 相对于项目根目录的文件路径 | +| `objects..system_status` | string | `active` / `inactive` / `not_exists` | +| `objects..corr_nr` | string\|null | 传输请求号,`null` 表示本地对象 `$TMP` | +| `objects..depends_on` | string[] | 依赖的对象名列表 | +| `objects..last_sync` | string\|null | 最近一次成功同步的时间 | +| `objects..last_sync_result` | string | `success` / `failed` / `pending` | + +### 2.3 示例 + +```json +{ + "version": 1, + "last_init": "2026-05-25T10:00:00", + "last_refresh": null, + "objects": { + "Z_STATUS": { + "type": "domain", + "file": "domains/z_status.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T10:30:00", + "last_sync_result": "success" + }, + "ZIF_MY_INTERFACE": { + "type": "interface", + "file": "interfaces/zif_my_interface.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T10:30:00", + "last_sync_result": "success" + }, + "ZCL_MY_CLASS": { + "type": "class", + "file": "classes/zcl_my_class.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": ["ZIF_MY_INTERFACE"], + "last_sync": "2026-05-25T11:00:00", + "last_sync_result": "success" + }, + "ZMY_REPORT": { + "type": "report", + "file": "reports/zmy_report.abap", + "system_status": "inactive", + "corr_nr": null, + "depends_on": ["ZCL_MY_CLASS"], + "last_sync": null, + "last_sync_result": "pending" + }, + "ZGROUP/Z_MY_FUNC": { + "type": "function", + "file": "functions/zgroup/z_my_func.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T10:00:00", + "last_sync_result": "success" + } + } +} +``` + +## 3. 命令变更一览 + +| 命令 | 变更类型 | 说明 | +|------|---------|------| +| `init` | **新增** | 扫描项目目录 → 查询 SAP → 生成 `manifest.json` | +| `create` | **变更** | 新增 `--path` 参数;创建成功后自动写入清单 | +| `sync` | **变更** | 新增 `--all` 批量模式;单对象模式操作后更新清单 | +| `delete` | **变更** | 新增 `--path` 参数;删除成功后从清单移除 | +| `refresh` | **新增** | 重新查询清单中所有对象的 SAP 状态 | + +## 4. 命令详细设计 + +### 4.1 `init` — 项目初始化(新增) + +#### 命令格式 + +```bash +python main.py init --path <项目根目录> +``` + +#### 执行流程 + +``` +扫描项目目录 + ├─ 按目录映射表识别文件 → 生成 {对象名, 类型, 文件路径} 列表 + ├─ 输出: "扫描到 N 个本地文件" + └─ 忽略非 .abap 文件和隐藏文件 + +逐个查询 SAP 系统 + ├─ 对象存在 → 查询 info → 获取 system_status + corr_nr + ├─ 对象不存在 → 标记 system_status: "not_exists" + └─ 查询失败 → 标记 system_status: "unknown" + +应用类型默认优先级生成初始 depends_on + (init 时只设置类型层面的默认依赖,用户可后续手动编辑) + domain/dataelement/table → 无依赖 + tabletype → 依赖同项目中的 table + interface → 无依赖 + class → 依赖同项目中被引用的 interface + function → 无显式依赖 + report → 无显式依赖 + +写入 manifest.json +输出汇总报告 +``` + +#### 输出示例 + +``` +============================================================ + sap-cli 项目初始化 +============================================================ + 项目路径: ./project + 扫描到 5 个本地文件 + + → 正在查询 SAP 系统状态... + ✓ Z_STATUS (domain) active corr: DEVK901362 + ✓ ZIF_MY_INTERFACE (interface) active corr: DEVK901362 + ✓ ZCL_MY_CLASS (class) active corr: DEVK901362 + ⚠ ZMY_REPORT (report) inactive corr: 本地对象 + ✗ ZCL_NEW_CLASS (class) 不存在 + + ✓ 清单已生成: ./project/manifest.json + 5 个对象: 3 已激活, 1 未激活, 1 不存在 +``` + +> [!tip] 清单可手动编辑 +> `manifest.json` 生成后,用户可以手动编辑 `depends_on` 字段来补充对象间的显式依赖关系。 +> `init` 只设置类型层面的默认依赖,具体的引用关系(如某个 class 实现了哪个 interface)需要用户自行标注。 + +--- + +### 4.2 `sync --all` — 批量同步(新增) + +#### 命令格式 + +```bash +python main.py sync --all --path <项目根目录> [--corr_nr <传输请求号>] [--fail-fast] [--dry-run] +``` + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--all` | 是 | 启用批量模式 | +| `--path` | 是 | 项目根目录(包含 `manifest.json`) | +| `--corr_nr` | 否 | 统一传输请求号(跳过交互选择,所有对象共用) | +| `--fail-fast` | 否 | 遇到失败立即停止(默认:跳过并继续) | +| `--dry-run` | 否 | 仅输出执行计划,不实际操作 | + +#### 执行流程 + +``` +读取 manifest.json + ├─ 不存在 → 报错: "请先执行 init" + └─ 存在 → 继续 + +扫描本地文件 vs 清单差异 + ├─ 本地有但清单没有的文件 → 提示: "N 个未纳入清单的文件,建议先 create" + └─ 清单有但本地被删的文件 → 警告,跳过 + +过滤待处理对象 + ├─ system_status == "active" && last_sync_result == "success" → 跳过(已同步) + ├─ system_status == "not_exists" → 标记为 [create + sync] + └─ 其他 → 标记为 [sync] + +拓扑排序 + ├─ 按 depends_on 建有向图 + ├─ 无显式依赖的按类型默认优先级排序 + └─ 检测循环依赖 → 报错退出 + +--dry-run 模式: + 输出执行计划,不实际操作 + +传输请求预选择(一次性交互): + ├─ 扫描待处理对象,统计需要传输请求号的数量 + ├─ 指定了 --corr_nr → 直接使用,无交互 + ├─ 全部对象已绑定请求号或为本地对象 → 无交互 + └─ 有对象需要请求号 → 弹出一次选择,选定后全局复用: + ├─ 选择已有请求号 → 后续所有需要请求号的对象都使用此请求号 + ├─ 新建请求号 → 同上 + └─ 不使用请求号 → 所需对象都作为本地对象处理 + +执行阶段(按排序顺序逐个处理,使用预选的传输请求号): + 对每个对象: + ├─ 本地文件不存在 → 跳过,警告 + ├─ system_status == "not_exists" → 先执行 create(使用预选请求号) + ├─ 执行 sync 逻辑(使用预选请求号,跳过逐对象交互): + │ ├─ 对象已绑定请求号 → 使用对象自身绑定的请求号 + │ ├─ 本地对象($TMP)→ 无需请求号 + │ └─ 未绑定 → 使用预选的统一请求号(不再弹出交互) + ├─ 成功 → 更新 manifest: + │ system_status: "active" + │ corr_nr: 实际使用的请求号 + │ last_sync: 当前时间 + │ last_sync_result: "success" + └─ 失败 → + ├─ --fail-fast → 停止,输出已处理和未处理的对象 + └─ 默认 → 标记 last_sync_result: "failed",跳过,继续下一个 + 后续依赖此对象的对象也自动跳过(标记 skipped) + +输出汇总报告 +``` + +#### dry-run 输出示例 + +``` +============================================================ + sap-cli 批量同步 (dry-run) +============================================================ + 项目路径: ./project + 清单对象: 5 + + 执行计划(按依赖排序): + 1. Z_STATUS (domain) → sync [inactive → active] + 2. ZIF_MY_INTERFACE (interface) → 跳过 [已是最新] + 3. ZCL_MY_CLASS (class) → sync [依赖: ZIF_MY_INTERFACE ✓] + 4. ZMY_REPORT (report) → sync [依赖: ZCL_MY_CLASS] + 5. ZGROUP/Z_MY_FUNC (function) → 跳过 [已是最新] + + 待处理: 3 个 | 跳过: 2 个 +``` + +#### 执行输出示例 + +``` +============================================================ + sap-cli 批量同步 +============================================================ + 项目路径: ./project + 待处理: 3 个对象 + + → 预检传输请求需求... + 2 个对象已绑定请求号: Z_STATUS(DEVK901362), ZCL_MY_CLASS(DEVK901362) + 1 个对象需要传输请求: ZMY_REPORT + + → 查询可用的传输请求... + 找到 2 个可修改的传输请求: + + 1. DEVK901362 Implement sales validation (所有者: ADMIN2) + 2. DEVK901365 Bugfix for invoice printing (所有者: ADMIN2) + 3. 新建传输请求 + 0. 不使用传输请求(本地对象) + + 请选择 [0-3]: 1 + ✓ 统一传输请求: DEVK901362(后续所有对象共用此请求号) + + ── [1/3] Z_STATUS (domain) ── + ✓ 锁定成功(对象已绑定传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 + ✓ 语法检查通过 + ✓ 激活成功 + + ── [2/3] ZCL_MY_CLASS (class) ── + ✓ 锁定成功(对象已绑定传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 + ✓ 语法检查通过 + ✓ 激活成功 + + ── [3/3] ZMY_REPORT (report) ── + ✓ 锁定成功(使用统一传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 + ✗ 语法检查未通过! 1 个错误 + [错误] 行 10: Field "XXX" is unknown + ⊘ 跳过(标记为 failed) + + ============================================================ + 批量同步完成 + ============================================================ + ✓ 成功: Z_STATUS, ZCL_MY_CLASS + ✗ 失败: ZMY_REPORT (语法错误,行 10) + ⊘ 跳过: (无) + 清单已更新: ./project/manifest.json +``` + +> [!tip] 指定 `--corr_nr` 跳过交互 +> 如果已知传输请求号,使用 `--corr_nr` 参数可以直接跳过交互选择: +> ```bash +> python main.py sync --all --path ./project --corr_nr DEVK901362 +> ``` +> 此时所有未绑定请求号的对象都会使用指定的请求号,**全程零交互**。 + +--- + +### 4.3 `refresh` — 刷新清单状态(新增) + +#### 命令格式 + +```bash +python main.py refresh --path <项目根目录> +``` + +#### 执行流程 + +``` +读取 manifest.json +逐个重新查询 SAP info +更新每个对象的 system_status 和 corr_nr +(不修改 last_sync 和 last_sync_result) +输出变更摘要 +``` + +#### 使用场景 + +- 其他人修改了 SAP 端的对象 +- 传输请求状态发生变化(释放、导入等) +- 需要确认当前系统状态但不想执行同步 + +#### 输出示例 + +``` +============================================================ + sap-cli 清单刷新 +============================================================ + 项目路径: ./project + 清单对象: 5 + + → 正在查询 SAP 系统状态... + ✓ Z_STATUS (domain) active corr: DEVK901362 (未变化) + ✓ ZIF_MY_INTERFACE (interface) active corr: DEVK901362 (未变化) + ~ ZCL_MY_CLASS (class) inactive corr: DEVK901362 (状态变更: active → inactive) + ✓ ZMY_REPORT (report) inactive corr: null (未变化) + ✓ ZGROUP/Z_MY_FUNC (function) active corr: DEVK901362 (未变化) + + ✓ 刷新完成 + 变更: 1 个 | 未变化: 4 个 +``` + +--- + +### 4.4 `create` — 变更:自动写入清单 + +#### 变更内容 + +- 新增 `--path` 参数:指向项目根目录 +- 创建成功后:自动在 `manifest.json` 中新增一条记录 + +#### 新增的清单记录 + +```json +{ + "ZCL_NEW_CLASS": { + "type": "class", + "file": "classes/zcl_new_class.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T14:00:00", + "last_sync_result": "success" + } +} +``` + +#### 向后兼容 + +- 如果未指定 `--path`,或指定目录下没有 `manifest.json`:仅创建对象,不写清单 +- 现有不含 `--path` 的用法不受影响 + +--- + +### 4.5 `delete` — 变更:从清单移除 + +#### 变更内容 + +- 新增 `--path` 参数:指向项目根目录 +- 删除成功后:从 `manifest.json` 中移除该对象记录 + +#### 向后兼容 + +- 如果未指定 `--path`,或指定目录下没有 `manifest.json`:仅删除对象,不更新清单 + +--- + +### 4.6 `sync`(单对象) — 变更:操作后更新清单 + +#### 变更内容 + +- 新增 `--path` 参数:指向项目根目录(可选) +- sync 成功或失败后:更新 `manifest.json` 中对应对象的记录 + +#### 向后兼容 + +- 如果未指定 `--path`,或指定目录下没有 `manifest.json`:行为与现有完全一致 + +## 5. 依赖排序策略 + +### 5.1 类型默认优先级 + +无显式 `depends_on` 时,按类型优先级排序: + +| 优先级 | 数值 | 类型 | 说明 | +|--------|------|------|------| +| 1 | 10 | `domain` | 基础域定义 | +| 2 | 20 | `dataelement` | 依赖 domain | +| 3 | 30 | `table` | 依赖 dataelement | +| 4 | 40 | `tabletype` | 依赖 table | +| 5 | 50 | `interface` | 接口定义 | +| 6 | 60 | `class` | 可能实现 interface | +| 7 | 70 | `function` | 函数模块 | +| 8 | 80 | `report` | 程序,可能引用以上所有 | + +### 5.2 排序算法 + +采用拓扑排序(Kahn 算法): + +``` +1. 以 manifest 中所有对象为节点 +2. 建立有向边: + - 显式依赖: depends_on 中列出的对象 → 当前对象 + - 类型默认优先级: 仅当两个对象之间没有显式依赖时作为 tie-breaker +3. 执行 Kahn 拓扑排序 +4. 检测循环依赖 → 若存在未入队的节点则报错,输出循环链 +``` + +### 5.3 依赖失败传播 + +批量 `sync` 时,若对象 A 依赖对象 B,而 B 同步失败: + +``` +A.depends_on = ["B"] +B.last_sync_result = "failed" + +→ A 自动跳过,标记 last_sync_result: "skipped" +→ 依赖 A 的对象同样跳过(级联跳过) +``` + +## 6. 边界情况处理 + +| 场景 | 处理方式 | +|------|---------| +| 本地新增了 .abap 文件但清单中没有 | `sync --all` 时提示: "N 个未纳入清单的文件",建议先执行 `create` | +| 清单中有对象但本地文件被删了 | `sync --all` 时警告并跳过该对象 | +| `manifest.json` 不存在 | `sync --all` 报错,提示先执行 `init` | +| 依赖对象 sync 失败 | 跳过所有依赖它的对象(级联标记 skipped) | +| 循环依赖 | 检测到后报错,输出循环链中的对象名 | +| 非项目目录(无子目录结构) | `init` 时只扫描当前目录下的 `.abap` 文件 | +| SAP 连接中断 | 标记已处理的对象,输出中断位置,下次可从断点继续 | +| `manifest.json` 版本不匹配 | 提示版本不兼容,建议重新 `init` | +| 部分对象已绑定请求号,部分未绑定 | 已绑定的使用自身请求号,未绑定的使用预选的统一请求号 | +| 全部对象都是本地对象($TMP) | 无需传输请求,直接执行,零交互 | +| 指定了 `--corr_nr` 但部分对象已绑定其他请求号 | 已绑定的使用自身请求号(不受 `--corr_nr` 覆盖) | + +## 7. 项目生命周期 + +``` +┌─────────────────────────────────────────────────────────┐ +│ │ +│ init ────────────────────────────────────────────── │ +│ │ 扫描本地文件 → 查询 SAP → 生成 manifest.json │ +│ │ │ +│ ▼ │ +│ create(新增对象时) ──────────────────────────────── │ +│ │ 在 SAP 创建 → 自动写入 manifest │ +│ │ │ +│ ▼ │ +│ sync --all(日常同步)───────────────────────────── │ +│ │ 读 manifest → 拓扑排序 → 逐个 sync → 更新 manifest │ +│ │ │ +│ ▼ │ +│ refresh(SAP 端有变化时)──────────────────────────── │ +│ 重新查询 SAP → 更新 manifest 中的状态字段 │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +> [!tip] 典型工作流 +> ```bash +> # 1. 首次使用:初始化项目清单 +> python main.py init --path ./my_project +> +> # 2. 日常开发:新增对象 +> python main.py create --name ZCL_NEW --type class --path ./my_project --description "新类" +> +> # 3. 日常开发:修改本地 .abap 文件后,批量同步 +> python main.py sync --all --path ./my_project +> +> # 4. 如需查看执行计划(不实际操作) +> python main.py sync --all --path ./my_project --dry-run +> +> # 5. 其他人修改了 SAP 端对象后,刷新清单 +> python main.py refresh --path ./my_project +> ``` + +## 8. 模块设计(实现层面) + +### 8.1 新增模块 + +| 模块 | 职责 | +|------|------| +| `sapcli/manifest.py` | 清单文件的读取、写入、查询、更新 | +| `sapcli/scanner.py` | 扫描本地目录,识别对象名和类型 | +| `sapcli/sorter.py` | 拓扑排序,依赖检测 | + +### 8.2 清单模块 (`manifest.py`) 接口 + +```python +@dataclass +class ManifestEntry: + name: str + type: str + file: str + system_status: str # "active" / "inactive" / "not_exists" + corr_nr: str | None + depends_on: list[str] + last_sync: str | None # ISO timestamp + last_sync_result: str # "success" / "failed" / "pending" + +class Manifest: + version: int + last_init: str | None + last_refresh: str | None + objects: dict[str, ManifestEntry] + + @classmethod + def load(cls, project_path: str) -> "Manifest": ... + def save(self) -> None: ... + def upsert(self, entry: ManifestEntry) -> None: ... + def remove(self, name: str) -> None: ... + def get(self, name: str) -> ManifestEntry | None: ... + def pending_objects(self) -> list[ManifestEntry]: ... +``` + +### 8.3 扫描模块 (`scanner.py`) 接口 + +```python +DIRECTORY_TYPE_MAP = { + "reports": "report", + "classes": "class", + "interfaces": "interface", + "functions": "function", + "domains": "domain", + "dataelements": "dataelement", + "tables": "table", + "tabletypes": "tabletype", +} + +@dataclass +class ScannedObject: + name: str # 大写对象名,function 类型含 / + type: str # 对象类型 + file: str # 相对于项目根目录的路径 + +def scan_project(project_path: str) -> list[ScannedObject]: ... +``` + +### 8.4 排序模块 (`sorter.py`) 接口 + +```python +TYPE_PRIORITY = { + "domain": 10, + "dataelement": 20, + "table": 30, + "tabletype": 40, + "interface": 50, + "class": 60, + "function": 70, + "report": 80, +} + +class CyclicDependencyError(SapCliError): ... + +def topological_sort( + objects: list[ManifestEntry], +) -> list[ManifestEntry]: ... +# → 按 depends_on + TYPE_PRIORITY 排序 +# → 检测循环依赖,抛出 CyclicDependencyError +``` + +## 9. 相关笔记 + +- [[sap-cli/doc/sap-cli使用指南|sap-cli 使用指南]] — 现有命令的使用说明 +- [[sap-cli/doc/技术说明|技术说明]] — 技术架构与模块设计 +- [[sap-cli/doc/ADT/03.修改代码-原理|修改代码原理]] — 锁定/编辑/激活工作流 diff --git a/docs/dev/extension-plan.md b/docs/dev/extension-plan.md new file mode 100644 index 0000000..f6147c4 --- /dev/null +++ b/docs/dev/extension-plan.md @@ -0,0 +1,530 @@ +# sap-cli 功能扩展分析报告 + +> **项目**: sap-cli — 用你喜欢的编辑器写 ABAP +> **版本**: 当前 (v1.0) +> **编制日期**: 2026-06-09 +> **定位**: 基于 SAP ADT REST API 的命令行 ABAP 开发工具 + +--- + +## 一、现有功能全景 + +### 1.1 已实现的 7 个命令 + +| 命令 | 功能 | 适用对象 | +|:-----|:-----|:---------| +| `download` | 从 SAP 下载源码到本地 .abap 文件 | report / class / interface / function / domain / dataelement / table / structure | +| `sync` | 本地代码 → SAP(锁定→写入→解锁→语法检查→激活) | 同上 | +| `create` | 在 SAP 创建开发对象(支持模板和 JSON 定义) | 10 种类型(含 functiongroup、tabletype) | +| `info` | 查询对象元数据(名称/类型/状态/负责人/修改时间等) | 10 种类型 | +| `delete` | 从 SAP 系统删除对象(需确认) | 10 种类型 | +| `init` | 扫描本地目录 → 查询 SAP → 生成 manifest.json | 批量 | +| `refresh` | 刷新清单中对象的 SAP 状态 | 批量 | + +### 1.2 已支持的对象类型 + +**ABAP 程序对象(5 种)**:report、class、interface、function、functiongroup + +**ABAP 字典对象(5 种)**:domain、dataelement、table、structure、tabletype + +### 1.3 核心能力 + +- ✅ 单对象 CRUD 全生命周期 +- ✅ 批量同步(基于 manifest.json + 拓扑排序) +- ✅ 传输请求管理(list / create / select) +- ✅ DDIC 对象通过 JSON 定义文件创建 +- ✅ 锁定-编辑-解锁-激活完整工作流 +- ✅ 语法检查集成 + +--- + +## 二、功能扩展方向 + +### 🔵 方向一:对象类型扩展 + +#### 2.1.1 ADT 已支持但工具未覆盖的对象类型 + +| 优先级 | 对象类型 | ADT 端点 | 使用场景 | 实现难度 | +|:------:|:---------|:---------|:---------|:--------:| +| ⭐⭐⭐ | **CDS View (DDL)** | `/sap/bc/adt/ddic/ddlsources` | S/4HANA 核心开发,CDS View 是现代 ABAP 开发的基础 | 中 | +| ⭐⭐⭐ | **CDS Access Control (DCL)** | `/sap/bc/adt/authorization/dclsources` | CDS 角色权限控制,与 CDS View 配套 | 中 | +| ⭐⭐⭐ | **Include 程序** | `/sap/bc/adt/programs/programs` | 大型报表拆分,复用代码片段 | 低 | +| ⭐⭐ | **消息类 (Message Class)** | `/sap/bc/adt/messageclasses` | MESSAGE 语句依赖,项目必备 | 低 | +| ⭐⭐ | **数据库视图 (View)** | `/sap/bc/adt/ddic/views` | 数据建模,查询优化 | 中 | +| ⭐⭐ | **搜索帮助 (Search Help)** | `/sap/bc/adt/ddic/searchhelps` | ALV 屏幕、F4 帮助 | 中 | +| ⭐⭐ | **锁对象 (Lock Object)** | `/sap/bc/adt/ddic/lockobjects` | 并发控制,多用户数据一致性 | 中 | +| ⭐ | **类型组 (Type Pool)** | `/sap/bc/adt/programs/programs` | 常量/类型定义集中管理 | 低 | +| ⭐ | **AMDP 类** | 复用 class 端点 | HANA 数据库过程调用 | 低 | +| ⭐ | **Web Dynpro 组件** | 专用端点 | 传统 Web UI(逐渐淘汰) | 高 | + +**实现建议**: +- 第一批优先:**Include 程序**(改动最小,复用现有 report 逻辑)+ **CDS View**(战略价值最高) +- CDS View 需要新增 `cdsview` 类型,源码是 DDL 语法(非 ABAP),激活流程与 DDIC 类似 + +#### 2.1.2 tabletype 的完整支持 + +当前 `tabletype` 使用 VIT 端点,不支持 download/sync/delete。可以探索: +- 通过 DDIC 专用端点(`/sap/bc/adt/ddic/tabletypes`)的源码读写接口 +- 或使用 VIT 的 PUT 操作实现间接源码写入 + +--- + +### 🔵 方向二:代码浏览与搜索 + +#### 2.2.1 对象列表浏览(`list` 命令) + +``` +python main.py list --type class --prefix ZCL_* --package ZFINANCE +``` + +**ADT API**: `GET /sap/bc/adt/repository/informationsystem/search` +**使用场景**: +- 浏览某个包下的所有对象 +- 按名称前缀模糊搜索 +- 按类型/所有者/修改时间筛选 + +#### 2.2.2 Where-Used 引用查询 + +``` +python main.py whereused --name ZCL_MY_CLASS --type class +``` + +**ADT API**: `GET /sap/bc/adt/whereused` +**使用场景**: +- 修改前评估影响范围 +- 自动填充 `manifest.json` 的 `depends_on` 字段(替代手动维护) +- 批量同步时自动推导依赖关系 + +#### 2.2.3 源代码全文搜索 + +``` +python main.py search --query "SELECT * FROM ZMY_TABLE" --type report +``` + +**ADT API**: `GET /sap/bc/adt/repository/informationsystem/search` (code search) +**使用场景**: 快速定位引用特定表/函数/变量的代码位置 + +--- + +### 🔵 方向三:代码质量与检查 + +#### 2.3.1 ATC 代码检查集成 + +``` +python main.py check --name ZMY_REPORT --type report --variant SAP_ABA_CHECK +``` + +**ADT API**: `POST /sap/bc/adt/qualitymanager/ats/checkruns` +**使用场景**: +- 同步前自动运行 ATC 检查(替代或补充现有语法检查) +- CI/CD 流水线中的质量门禁 +- 支持自定义检查变体 + +#### 2.3.2 代码格式化(ABAP Pretty Printer) + +``` +python main.py format --name ZMY_REPORT --type report +``` + +**ADT API**: `POST /sap/bc/adt/prettyprinter` +**使用场景**: +- 下载后自动格式化(统一缩进、大小写风格) +- sync 前自动格式化(保持 SAP 端代码风格一致) + +#### 2.3.3 代码差异对比 + +``` +python main.py diff --name ZMY_REPORT --type report --path ./src/zmy_report.abap +``` + +**使用场景**: +- sync 前预览即将上传的改动 +- 显示本地文件与 SAP 端源码的差异 +- 防止误覆盖他人的修改 + +--- + +### 🔵 方向四:传输管理与多系统 + +#### 2.4.1 传输请求高级管理 + +``` +python main.py transport list --status D # 查看可修改的传输请求 +python main.py transport info DEVK901362 # 查看传输请求详情 +python main.py transport release DEVK901362 # 释放传输请求 +python main.py transport objects DEVK901362 # 列出传输请求中的所有对象 +``` + +**ADT API**: +- `GET /sap/bc/adt/cts/transportrequests/{id}` — 详情 +- `POST /sap/bc/adt/cts/transportrequests/{id}?method=release` — 释放 + +**使用场景**: +- 批量同步后一键释放传输请求 +- 在命令行中管理传输请求,无需进入 SAP GUI(SE01/SE09) + +#### 2.4.2 多系统配置与跨系统同步 + +```ini +# config.ini +[DEV] +host = http://dev-sap:8000 +client = 100 + +[QAS] +host = http://qas-sap:8000 +client = 200 + +[PRD] +host = http://prd-sap:8000 +client = 300 +``` + +``` +python main.py --profile DEV download --name ZMY_REPORT --type report --path ./src +python main.py --profile QAS info --name ZMY_REPORT --type report +``` + +**使用场景**: +- 开发/测试/生产多环境切换 +- 跨系统对象对比(DEV vs QAS 代码差异) +- 一键从 DEV 下载 → 修改 → 推送到 QAS + +#### 2.4.3 传输导入监控 + +``` +python main.py transport import DEVK901362 --target QAS +python main.py transport status DEVK901362 +``` + +**使用场景**: 跟踪传输请求在目标系统的导入状态 + +--- + +### 🔵 方向五:包管理与项目结构 + +#### 2.5.1 ABAP 包(Package)操作 + +``` +python main.py package create ZFINANCE --description "财务模块" --superpackage ZBUSINESS +python main.py package list --superpackage ZBUSINESS +python main.py package move --name ZMY_REPORT --type report --package ZFINANCE +``` + +**ADT API**: `/sap/bc/adt/packages/{name}` +**使用场景**: +- 项目初始化时自动创建包结构 +- 将 $TMP 本地对象迁移到正式包中 + +#### 2.5.2 项目模板(Scaffolding) + +``` +python main.py scaffold --name ZSALES_ORDER --template "ALV Report" --package ZSALES +``` + +预置模板: +- **ALV 报表** — 包含 ALV GRID、字段目录、布局管理 +- **BAPI 封装** — 函数组 + 函数模块 + 异常处理 +- **接口类** — 接口 + 实现类 + 工厂方法 +- **数据模型** — domain + dataelement + table + tabletype + CDS View +- **增强实现** — BAdI 定义 + 实现(如果 ADT 支持) + +**使用场景**: 快速创建符合项目规范的标准代码骨架 + +#### 2.5.3 依赖自动分析 + +``` +python main.py analyze --path ./project +``` + +**功能**: +- 解析本地 .abap 文件中的 `TYPE REF TO`、`CALL METHOD`、`PERFORM` 等语句 +- 自动生成 `depends_on` 关系 +- 替代当前手动维护 manifest.json 依赖的方式 +- 结合 Where-Used API 交叉验证 + +--- + +### 🔵 方向六:Git 集成与 DevOps + +#### 2.6.1 Git Hooks 集成 + +```bash +# pre-commit hook: sync 前自动语法检查 +# post-pull hook: 自动 download 远端最新代码 +``` + +**使用场景**: +- `git commit` 前自动触发 ATC 检查 +- `git pull` 后自动拉取 SAP 端最新代码 +- `git push` 后自动触发 CI/CD + +#### 2.6.2 CI/CD Pipeline 集成 + +```yaml +# .gitlab-ci.yml 示例 +abap-sync: + stage: deploy + script: + - python main.py sync --all --path ./src --corr_nr $TR_NUMBER --fail-fast + - python main.py check --all --path ./src +``` + +**使用场景**: +- Git 提交后自动同步到 SAP 开发系统 +- ATC 检查作为质量门禁 +- 传输请求自动创建和释放 + +#### 2.6.3 代码版本对比(SAP 版本管理集成) + +``` +python main.py history --name ZMY_REPORT --type report +python main.py version --name ZMY_REPORT --type report --version 1.2 +``` + +**ADT API**: `/sap/bc/adt/programs/programs/{name}/versions` +**使用场景**: +- 查看 SAP 端的版本历史 +- 对比不同版本之间的代码差异 +- 回滚到之前的版本 + +--- + +### 🔵 方向七:交互体验优化 + +#### 2.7.1 交互式终端 UI(TUI) + +``` +python main.py tui +``` + +**功能**: +- 基于 [Textual](https://github.com/Textualize/textual) 或 [Rich](https://github.com/Textualize/rich) 的终端 UI +- 对象树状浏览器(按类型/包/状态分组) +- 源码差异对比视图 +- 批量操作进度条 + +#### 2.7.2 全局配置与 Profile + +```bash +sap-cli config set default_host http://dev-sap:8000 +sap-cli config set default_client 100 +sap-cli config set auto_format true +sap-cli config set atc_variant SAP_ABA_CHECK +sap-cli config set timeout 30 +``` + +**使用场景**: +- 替代手动编辑 config.ini +- 按项目/环境保存不同配置 +- 设置全局默认参数(超时、格式化、检查变体等) + +#### 2.7.3 Shell 自动补全 + +```bash +eval "$(sap-cli completion bash)" +eval "$(sap-cli completion zsh)" +``` + +**使用场景**: Tab 键自动补全命令、对象类型、对象名 + +--- + +### 🔵 方向八:AI 与智能化 + +#### 2.8.1 AI 辅助代码生成 + +``` +python main.py generate --type report --description "根据销售订单号查询交货明细的ALV报表" --output ./src/zdelivery_detail.abap +``` + +**使用场景**: +- 结合 LLM API(如 Claude/GPT)根据自然语言描述生成 ABAP 代码 +- 自动识别所需的表、数据元素、函数模块 +- 生成的代码自动通过语法检查 + +#### 2.8.2 MCP 服务器模式 + +``` +python main.py mcp-server --port 8080 +``` + +**参考**: [erpl-adt](https://github.com/DataZooDE/erpl-adt) 已实现 MCP 服务器 +**使用场景**: +- 作为 MCP 工具供 AI 助手(如 Claude Desktop)调用 +- AI 可以直接浏览 SAP 对象、下载/同步代码 +- 实现"用自然语言修改 SAP 系统"的终极目标 + +#### 2.8.3 智能代码审查 + +``` +python main.py review --name ZMY_REPORT --type report --rules performance,security +``` + +**使用场景**: +- 基于规则的静态分析(SQL 注入、性能反模式、命名规范) +- 结合 AI 的代码审查建议 +- 自动生成改进建议 + +--- + +### 🔵 方向九:安全与运维 + +#### 2.9.1 密码安全存储 + +``` +python main.py auth login # 交互式输入密码,存入系统 keyring +python main.py auth status # 检查登录状态 +python main.py auth logout # 清除凭证 +``` + +**实现方案**: +- 使用 [keyring](https://github.com/jaraco/keyring) 库 +- 支持 Windows Credential Manager / macOS Keychain / Linux Secret Service +- 不再明文存储密码 + +#### 2.9.2 SSL 证书验证 + +```ini +[DEV] +host = https://dev-sap:44300 +verify_ssl = true +ca_bundle = /path/to/corp-ca.pem +``` + +**使用场景**: +- 生产环境安全要求 +- 企业内网 CA 证书支持 +- 当前硬编码 `verify=False`,存在中间人攻击风险 + +#### 2.9.3 审计日志 + +``` +python main.py audit --from 2026-06-01 --to 2026-06-09 +``` + +**使用场景**: +- 记录所有 create/sync/delete 操作的审计日志 +- 支持按时间/用户/操作类型查询 +- 满足企业合规要求 + +--- + +## 三、扩展优先级矩阵 + +按照 **业务价值 × 实现难度** 排序: + +| 优先级 | 扩展方向 | 业务价值 | 实现难度 | 建议版本 | +|:------:|:---------|:--------:|:--------:|:--------:| +| 🔴 P0 | Include 程序支持 | ⭐⭐⭐ | ⭐ | v1.1 | +| 🔴 P0 | 对象列表浏览 (list) | ⭐⭐⭐ | ⭐⭐ | v1.1 | +| 🔴 P0 | 多系统配置 (profile) | ⭐⭐⭐ | ⭐⭐ | v1.2 | +| 🔴 P0 | 密码安全存储 (keyring) | ⭐⭐⭐ | ⭐ | v1.2 | +| 🟠 P1 | CDS View 支持 | ⭐⭐⭐ | ⭐⭐⭐ | v1.3 | +| 🟠 P1 | Where-Used 引用查询 | ⭐⭐⭐ | ⭐⭐ | v1.3 | +| 🟠 P1 | 代码差异对比 (diff) | ⭐⭐⭐ | ⭐⭐ | v1.3 | +| 🟠 P1 | 传输请求高级管理 | ⭐⭐⭐ | ⭐⭐ | v1.4 | +| 🟠 P1 | ABAP 包操作 | ⭐⭐ | ⭐⭐ | v1.4 | +| 🟡 P2 | ATC 代码检查集成 | ⭐⭐ | ⭐⭐⭐ | v2.0 | +| 🟡 P2 | 代码格式化 (Pretty Printer) | ⭐⭐ | ⭐⭐ | v2.0 | +| 🟡 P2 | 依赖自动分析 | ⭐⭐⭐ | ⭐⭐⭐ | v2.0 | +| 🟡 P2 | 项目模板 (Scaffolding) | ⭐⭐ | ⭐⭐ | v2.0 | +| 🟡 P2 | CI/CD Pipeline 集成 | ⭐⭐⭐ | ⭐⭐ | v2.1 | +| 🟢 P3 | 消息类 / 视图 / 搜索帮助 / 锁对象 | ⭐⭐ | ⭐⭐ | v2.x | +| 🟢 P3 | 源代码全文搜索 | ⭐⭐ | ⭐⭐ | v2.x | +| 🟢 P3 | SAP 版本管理集成 | ⭐⭐ | ⭐⭐⭐ | v2.x | +| 🟢 P3 | Shell 自动补全 | ⭐ | ⭐ | v2.x | +| 🔵 P4 | 交互式终端 UI (TUI) | ⭐⭐ | ⭐⭐⭐ | v3.0 | +| 🔵 P4 | AI 辅助代码生成 | ⭐⭐⭐ | ⭐⭐⭐ | v3.0 | +| 🔵 P4 | MCP 服务器模式 | ⭐⭐⭐ | ⭐⭐ | v3.0 | +| 🔵 P4 | 智能代码审查 | ⭐⭐ | ⭐⭐⭐ | v3.0 | + +--- + +## 四、技术架构建议 + +### 4.1 近期架构优化(支持 v1.x 扩展) + +``` +sapcli/ +├── __init__.py +├── main.py # CLI 入口(拆分 argparse 到独立模块) +├── cli/ # 命令注册与解析(新增) +│ ├── parser.py # argparse 定义 +│ ├── completions.py # Shell 补全 +│ └── output.py # 格式化输出(替代散落各处的 print) +├── client.py # ADT REST 客户端(保持) +├── commands/ # 按功能拆分命令(新增目录) +│ ├── crud.py # download / sync / create / delete / info +│ ├── batch.py # init / refresh / sync-all +│ ├── transport.py # 传输请求管理(新增) +│ ├── search.py # list / whereused / search(新增) +│ └── check.py # syntax-check / atc(新增) +├── types.py # 对象类型注册(保持) +├── ddic.py # DDIC 定义(保持) +├── manifest.py # 清单管理(保持) +├── scanner.py # 目录扫描(保持) +├── sorter.py # 拓扑排序(保持) +├── config.py # 配置管理(增强多 profile) +├── auth.py # 凭证管理(新增:keyring 集成) +├── exceptions.py # 异常定义(保持) +└── utils/ # 工具函数(新增) + ├── xml_utils.py # XML 安全转义、解析 + ├── diff.py # 代码差异对比 + └── format.py # ABAP 格式化 +``` + +### 4.2 新增对象类型的标准流程 + +每次新增一个对象类型,需要修改的文件: + +| 步骤 | 文件 | 内容 | +|:-----|:-----|:-----| +| 1 | `types.py` | 注册 `ObjectTypeConfig`(URI 模板、content-type) | +| 2 | `client.py` | `_build_create_body()` 新增分支(或数据驱动) | +| 3 | `scanner.py` | `DIRECTORY_TYPE_MAP` 新增目录映射 | +| 4 | `commands.py` | 特殊逻辑处理(如有) | +| 5 | `ddic.py` | 如有 XML/DDL 定义需求 | + +### 4.3 建议引入的依赖 + +| 依赖 | 用途 | 时机 | +|:-----|:-----|:-----| +| `keyring` | 安全凭证存储 | v1.2 | +| `rich` | 终端美化输出(进度条、表格、语法高亮) | v1.3 | +| `textual` | 终端 UI 框架 | v3.0(可选) | +| `pydantic` | JSON 定义文件验证 | v2.0 | +| `httpx` | 替代 requests(支持 async、超时、重试) | v2.0 | + +--- + +## 五、竞品参考 + +| 项目 | 语言 | 特点 | 值得借鉴 | +|:-----|:-----|:-----|:---------| +| [abap-adt-api](https://github.com/marcellourbani/abap-adt-api) | TypeScript | 最全面的 ADT API 封装 | CDS View、AMDP、Where-Used、搜索 | +| [erpl-adt](https://github.com/DataZooDE/erpl-adt) | TypeScript | CLI + MCP 服务器 | MCP 协议、对象浏览、AI 集成 | +| [abapGit](https://github.com/abapGit/abapGit) | ABAP | SAP 端 Git 客户端 | 版本管理思想、serialize/deserialize | + +--- + +## 六、总结 + +sap-cli 已经建立了一个坚实的核心: +- **10 种对象类型**的完整 CRUD +- **批量同步** + 拓扑排序 + 传输请求管理 +- **manifest.json** 状态驱动的项目管理 + +最自然、最高价值的扩展路径是: + +``` +v1.1 Include + list + 消息类 → 补齐日常开发必需类型 +v1.2 多系统 + keyring → 企业级安全与多环境 +v1.3 CDS View + Where-Used → S/4HANA 现代化开发 +v1.4 传输管理 + 包操作 → 运维与管理能力 +v2.0 ATC + diff + 依赖分析 → 代码质量保障 +v2.x CI/CD + 模板 + 搜索 → DevOps 集成 +v3.0 TUI + AI + MCP → 智能化开发 +``` + +这个路径从 **"能用的工具"** 逐步演进到 **"不可替代的开发平台"**,每一步都有明确的用户价值。 diff --git a/docs/dev/test-coverage-report.md b/docs/dev/test-coverage-report.md new file mode 100644 index 0000000..f4e737e --- /dev/null +++ b/docs/dev/test-coverage-report.md @@ -0,0 +1,103 @@ +# sap-cli 测试覆盖率报告 + +> 测试时间:2026-06-09 +> 测试用例:68(63 pass + 5 skip) +> 覆盖率工具:coverage.py + +--- + +## 总体概况 + +| 指标 | 数值 | +|------|------| +| 源码总行数(Stmts) | 3,018 | +| 已覆盖行数 | 674 | +| **总体覆盖率** | **22%** | + +--- + +## 按模块覆盖率(从低到高) + +### 🔴 严重不足(< 20%)— 占总代码量 71% + +| 模块 | 代码行 | 覆盖率 | 未覆盖行 | 说明 | +|------|--------|--------|---------|------| +| `cli/parser.py` | 114 | **4%** | 110 | CLI 参数定义,仅被 import 触发 | +| `commands/crud.py` | 560 | **5%** | 533 | **最大模块**,5 个核心命令零覆盖 | +| `commands/batch.py` | 230 | **7%** | 215 | 批量操作 3 个命令零覆盖 | +| `client.py` | 716 | **7%** | 665 | **第二大的**,30 个 API 方法零覆盖 | +| `commands/cds.py` | 126 | **10%** | 114 | CDS 命令零覆盖 | +| `commands/quality.py` | 94 | **10%** | 85 | check + format 零覆盖 | +| `commands/search.py` | 100 | **10%** | 90 | list/whereused/search 零覆盖 | +| `commands/transport.py` | 100 | **10%** | 90 | 传输管理零覆盖 | +| `commands/config_cmd.py` | 75 | **11%** | 67 | config 命令零覆盖 | +| `commands/package_cmd.py` | 78 | **12%** | 69 | package 命令零覆盖 | +| `commands/diff_cmd.py` | 59 | **17%** | 49 | diff 命令零覆盖 | +| `auth.py` | 99 | **31%** | 68 | keyring 方法被测,cmd_auth 未测 | + +**小计:2,451 行未覆盖(占总量 81%)** + +### 🟡 部分覆盖(20%–79%) + +| 模块 | 代码行 | 覆盖率 | 说明 | +|------|--------|--------|------| +| `commands/analyze.py` | 62 | **34%** | 解析逻辑被测,命令入口未测 | +| `commands/scaffold.py` | 64 | **34%** | 4 个模板生成被测,命令入口未测 | +| `cli/output.py` | 25 | **60%** | print 函数被部分调用 | +| `scanner.py` | 52 | **62%** | 基础扫描被测,函数目录未测 | +| `manifest.py` | 82 | **70%** | CRUD 被测,文件 I/O 边界未测 | +| `config.py` | 62 | **77%** | 环境变量被测,profile/load 未测 | + +### 🟢 良好(≥ 80%) + +| 模块 | 代码行 | 覆盖率 | 说明 | +|------|--------|--------|------| +| `exceptions.py` | 35 | **83%** | 核心异常被测 | +| `ddic.py` | 128 | **84%** | XML/DDL 构建被测 | +| `types.py` | 69 | **90%** | 注册+解析被测,新注册 6 种未测 | +| `sorter.py` | 68 | **96%** | 几乎全覆盖 | +| `xml_utils.py` | 5 | **100%** | 全覆盖 | +| `__init__.py` 系列 | 15 | **100%** | 全覆盖 | + +--- + +## 三层覆盖率分析 + +``` +项目架构 代码行 覆盖率 未覆盖 +────────────────────────────────────────────── +CLI 层 parser+app 139 5% 132 +命令层 commands/* 1,548 8% 1,424 +API 层 client.py 716 7% 665 +基础层 其他模块 615 74% 159 +────────────────────────────────────────────── +合计 3,018 22% 2,344 +``` + +### 关键发现 + +1. **CLI 层 + 命令层 + API 层 = 2,403 行,覆盖率仅 7%** + — 这三层占总代码量的 80%,但几乎没有测试 + — 原因:现有 68 用例全部测的是基础层(types/config/ddic/manifest/sorter) + +2. **基础层覆盖率 74%** — 已经不错 + — types.py 的 6 种新注册类型需要补充测试 + — config.py 的 `load_config(profile=...)` 需要补充测试 + +3. **单文件代码量 Top 3**: + - `client.py` (716行) — 0 测试 ← 最大风险点 + - `commands/crud.py` (560行) — 0 测试 + - `commands/batch.py` (230行) — 0 测试 + +--- + +## 提升路径 + +| 目标覆盖率 | 需要新增测试 | 预计工时 | +|-----------|-------------|---------| +| 50% | client.py + commands 核心方法 (~130 用例) | 6h | +| 70% | 所有命令 + cli 入口 (~90 用例) | 4h | +| 80% | 边界条件 + 错误路径 (~50 用例) | 3h | +| 90% | 极端场景 + 集成测试 (~40 用例) | 3h | + +> HTML 详细报告已生成:`tests/coverage_html/index.html` diff --git a/docs/dev/test-plan.md b/docs/dev/test-plan.md new file mode 100644 index 0000000..13e2334 --- /dev/null +++ b/docs/dev/test-plan.md @@ -0,0 +1,344 @@ +# sap-cli 测试方案 + +> 版本:2.1 | 20 命令 · 16 类型 · 30 API 方法 +> 日期:2026-06-09 + +--- + +## 一、现状分析 + +### 1.1 代码架构(四层) + +``` +┌─────────────────────────────────────────────────┐ +│ CLI 层 parser.py + app.py │ ← 参数解析、命令路由 +├─────────────────────────────────────────────────┤ +│ 命令层 commands/*.py (11 模块, 20 命令) │ ← 业务编排 +├─────────────────────────────────────────────────┤ +│ API 层 client.py (30 方法) │ ← ADT REST 通信 +├─────────────────────────────────────────────────┤ +│ 基础层 types/config/auth/ddic/manifest/... │ ← 纯逻辑、无网络 +└─────────────────────────────────────────────────┘ +``` + +### 1.2 现有测试 + +| 文件 | 行数 | 状态 | 说明 | +|------|------|------|------| +| `tests/test_sapcli.py` | 695 | ✅ 活跃 | 68 用例(63 pass + 5 skip) | +| `tests/test_batch.py` | 840 | ⚠️ 过时 | 旧版批量测试,API 签名已变 | +| `tests/test_main.py` | 833 | ⚠️ 过时 | 旧版 E2E,引用旧入口 | + +**test_sapcli.py 覆盖的模块:** +- ✅ types.py(注册、解析)— 6 用例 +- ✅ config.py(环境变量)— 2 用例 +- ✅ auth.py(keyring 降级)— 4 用例 +- ✅ ddic.py(XML/DDL 构建)— 7 用例 +- ✅ manifest.py(增删查)— 5 用例 +- ✅ scanner.py(目录扫描)— 2 用例 +- ✅ sorter.py(拓扑排序)— 4 用例 +- ✅ exceptions.py — 2 用例 +- ✅ scaffold.py(模板生成)— 4 用例 +- ✅ xml_utils.py — 4 用例 +- ✅ XML 注入防御 — 3 用例 +- ✅ 模块结构检查 — 5 用例 +- ✅ OpenSpec 文件检查 — 3 用例 +- ✅ output.py — 3 用例 +- ✅ analyze.py — 4 用例 + +**未覆盖的模块(缺口):** +- ❌ client.py(30 个 API 方法)— 0 用例 +- ❌ commands/*.py(11 个命令模块)— 0 用例 +- ❌ cli/parser.py(参数校验)— 0 用例 +- ❌ cli/app.py(路由逻辑)— 0 用例 + +--- + +## 二、测试分层设计 + +### 分层策略 + +``` + ┌─────────────┐ + │ E2E 测试 │ ← 真实 SAP 系统 + │ (手动) │ 仅在上线前执行 + ├─────────────┤ + │ 集成测试 │ ← Mock HTTP Server + │ (自动化) │ 验证 client↔ADT 通信 + ├─────────────┤ + │ 单元测试 │ ← unittest.mock + │ (自动化) │ 验证各模块独立逻辑 + ├─────────────┤ + │ 静态分析 │ ← pylint / mypy + │ (CI 自动) │ 语法、类型、规范 + └─────────────┘ +``` + +| 层级 | 目标 | 技术 | 运行频率 | 依赖 | +|------|------|------|---------|------| +| 静态分析 | 语法/类型/规范 | pylint, mypy | 每次 commit | 无 | +| 单元测试 | 模块独立逻辑 | unittest + mock | 每次 commit | 无 | +| 集成测试 | API 通信正确性 | unittest + responses/mock HTTP | 每次 commit | 无 | +| E2E 测试 | 端到端真实流程 | 手动执行 | 发布前 | SAP 系统 | + +--- + +## 三、单元测试设计(优先级 P0) + +> 目标:每个模块的纯逻辑都有测试,不依赖网络。 + +### 3.1 保留现有 68 用例 + +全部保留,但修复 5 个 skip: +- `test_multi_profile` / `test_profile_fallback`:现在 config.py 已支持 `--profile`,应改为 pass +- `test_list_objects` / `test_pretty_print` / `test_transport_info`:方法已加回 client.py,应取消 skip + +### 3.2 新增用例清单 + +#### A. client.py — API 层(30 方法) + +每个方法测 3 个场景:成功响应、HTTP 错误、XML 解析。 + +| 方法 | 正常用例 | 错误用例 | mock 要点 | +|------|---------|---------|-----------| +| `login()` | 返回有效 session | 401 → LoginError | mock session.get | +| `lock()` | 返回 lock_handle + corr_nr | 403 → ObjectLockedError | mock session.post | +| `unlock()` | 返回 True | 非 200 返回 False | mock session.post | +| `set_source()` | 返回 True | 非 200 返回 False | mock session.put | +| `get_source()` | 返回源码字符串 | 404 → ObjectNotFoundError | mock session.get | +| `delete_object()` | 返回 (True, msg) | 非 200 (False, msg) | mock session.delete | +| `create_object()` | 返回 (obj_uri, src_uri) | 409 → ObjectAlreadyExistsError | mock session.post | +| `activate()` | 返回 (True, []) | 返回 (False, [errors]) | mock session.post | +| `syntax_check()` | 返回 (True, []) | 返回 (False, [errors]) | mock session.post | +| `get_object_status()` | 返回 status dict | 404 → ObjectNotFoundError | mock session.get | +| `object_exists()` | 返回 True/False | — | mock session.get | +| `list_objects()` | 返回 list[dict] | 空 → [] | mock session.get | +| `where_used()` | 返回 list[dict] | 无引用 → [] | mock session.post | +| `search_code()` | 返回 list[dict] | 无结果 → [] | mock session.post | +| `read_source_for_diff()` | 委托 get_source | — | mock self.get_source | +| `get_cds_source()` | 返回 DDL 字符串 | 404 → ObjectNotFoundError | mock session.get | +| `create_cds()` | 创建+写入+返回 URI | 失败 → SapCliError | mock lock/set_source/unlock | +| `create_package()` | 返回 True | 非 200 → False | mock session.post | +| `get_package_info()` | 返回 dict | 404 → SapCliError | mock session.get | +| `transport_info()` | 返回 dict | 404 → SapCliError | mock session.get | +| `transport_release()` | 返回 True | 非 200 → False | mock session.post | +| `transport_objects()` | 返回 list[dict] | 空 → [] | mock session.get | +| `atc_check()` | 返回 (bool, list) | 无发现 → (True, []) | mock session.post | +| `pretty_print()` | 返回格式化源码 | 失败 → SapCliError | mock session.post | +| `list_transport_requests()` | 返回 list[dict] | 空 → [] | mock session.get | +| `create_transport_request()` | 返回 corr_nr | 失败 → SapCliError | mock session.post | +| `get_transport_request()` | 返回 corr_nr 或 None | 空 → None | mock session.get | +| `create_function_group()` | 成功 | 失败 → SapCliError | mock session.post | +| `function_group_exists()` | True/False | — | mock session.get | +| `create_ddic_object()` | 成功写入 | 失败 → SapCliError | mock self._put_ddic_xml | + +**小计:~90 用例** + +#### B. commands/*.py — 命令层(11 模块) + +每个命令测:正常流程 + 错误处理 + 参数缺失。 + +| 命令 | 模块 | 正常用例 | 错误用例 | +|------|------|---------|---------| +| `create` | crud.py | 创建成功(4种模板+--source+--definition) | 对象已存在、类型不支持 | +| `download` | crud.py | 下载成功、文件名正确 | 对象不存在、无源码 URI | +| `sync` | crud.py | 五步同步成功 | 语法检查失败、激活失败 | +| `info` | crud.py | 显示元数据 | 对象不存在 | +| `delete` | crud.py | 确认删除、取消删除 | 对象不存在 | +| `init` | batch.py | 扫描→生成 manifest | 空目录、无 SAP 连接 | +| `refresh` | batch.py | 更新状态 | — | +| `sync --all` | batch.py | 批量同步+拓扑排序 | 循环依赖、fail-fast | +| `list` | search.py | 列出对象 | 空 | +| `whereused` | search.py | 找到引用 | 无引用 | +| `search` | search.py | 搜索结果 | 无结果 | +| `diff` | diff_cmd.py | 有差异/无差异 | — | +| `cds` | cds.py | 下载DDL/创建CDS | — | +| `package` | package_cmd.py | 创建/查询包 | — | +| `transport` | transport.py | 列出/详情/释放 | — | +| `check` | quality.py | ATC检查有/无发现 | — | +| `format` | quality.py | 格式化成功 | — | +| `analyze` | analyze.py | 分析依赖 | 无依赖 | +| `scaffold` | scaffold.py | 4种模板+无模板列出 | — | +| `config` | config_cmd.py | show/list-profiles/set | — | +| `auth` | auth.py | login/logout/status | keyring 不可用 | + +**小计:~60 用例** + +#### C. cli/ — 入口层 + +| 测试场景 | 说明 | +|---------|------| +| 无参数 → exit(1) + 帮助 | `app.py` 检测 no command | +| --profile DEV | 读 [DEV] section | +| --config 自定义路径 | 读指定文件 | +| config set host 1.2.3.4 | 写入 config.ini | +| config set invalid_key | 报错 | +| auth status | keyring 状态 | +| scaffold 无 --template | 列出模板 | +| scaffold --template xxx --name ZZZ | 生成模板 | + +**小计:~10 用例** + +### 3.3 单元测试总计 + +| 类别 | 现有 | 新增 | 合计 | +|------|------|------|------| +| 基础层 | 68 | 0 | 68 | +| API 层 (client.py) | 0 | ~90 | 90 | +| 命令层 (commands/) | 0 | ~60 | 60 | +| 入口层 (cli/) | 0 | ~10 | 10 | +| **总计** | **68** | **~160** | **~228** | + +--- + +## 四、集成测试设计(优先级 P1) + +> 目标:验证 client.py 与真实 ADT API 的通信协议是否正确。 + +### 4.1 技术方案:`responses` 库 mock HTTP + +```python +import responses + +@responses.activate +def test_login_success(): + responses.add(responses.GET, "https://sap.example.com/sap/bc/adt/...", + status=200, headers={"x-csrf-token": "TOKEN123"}) + client = ADTClient("sap.example.com", "100", "USER", "PASS") + client.login() + assert client.csrf_token == "TOKEN123" +``` + +### 4.2 集成测试用例 + +按 ADT API 端点分组,验证 HTTP 方法和 XML 请求体: + +| 端点组 | 测试场景 | 数量 | +|--------|---------|------| +| 认证 | login → CSRF token 获取 | 2 | +| 锁管理 | lock/unlock → 正确的 If-Match header | 4 | +| 源码读写 | get/set_source → 正确的 Content-Type | 4 | +| 对象 CRUD | create/delete → 正确的 XML body | 6 | +| DDIC | create_ddic → 正确的 XML namespace | 4 | +| 激活/检查 | activate/syntax_check → 正确的 XML 响应解析 | 4 | +| 搜索 | list/search/whereused → URL 参数编码 | 4 | +| 传输 | transport list/info/release → URL 拼接 | 4 | +| 质量 | atc_check/pretty_print → 请求体格式 | 4 | +| **合计** | | **~36** | + +--- + +## 五、E2E 测试设计(优先级 P2) + +> 目标:端到端验证完整用户场景。 + +### 5.1 前置条件 + +- 需要一台可连接的 SAP 系统 +- 使用测试用户和 `$TMP` 包 +- 所有操作可逆(删除测试对象) + +### 5.2 测试场景 + +| # | 场景 | 步骤 | 验证点 | +|---|------|------|--------| +| E1 | 完整 CRUD | create → info → download → sync → delete | 每步输出正确,对象最终不存在 | +| E2 | DDIC 对象 | create domain → create dataelement → create table | 依赖顺序正确 | +| E3 | 批量同步 | init → 修改本地文件 → sync --all | 所有对象同步成功 | +| E4 | 多 Profile | config set → --profile DEV → download | 读取正确配置 | +| E5 | 传输管理 | create transport → create object → release | 传输号关联正确 | + +**E2E 测试建议用手动执行**,因为: +- 依赖真实 SAP 系统可用性 +- 测试数据需要隔离 +- 某些操作不可逆(如 transport release) + +### 5.3 E2E 自动化(可选) + +如果 SAP 测试系统长期可用,可用 `tests/e2e_project/` 现有的测试数据做半自动 E2E: + +```bash +python main.py init --path tests/e2e_project +python main.py sync --all --path tests/e2e_project +``` + +--- + +## 六、现有测试处理 + +| 文件 | 处理 | 原因 | +|------|------|------| +| `tests/test_sapcli.py` | ✅ 保留并扩展 | 当前唯一活跃测试 | +| `tests/test_batch.py` | ❌ 废弃 | 840 行,API 签名已变,与新架构不兼容 | +| `tests/test_main.py` | ❌ 废弃 | 833 行,引用旧入口 `src/` 路径 | +| `tests/test_e2e.md` | ✅ 保留 | E2E 手动测试说明仍有参考价值 | + +--- + +## 七、测试文件组织 + +``` +tests/ +├── conftest.py # 共享 fixture(SAPConfig mock、临时目录) +├── test_sapcli.py # 基础层测试(保留现有 68 → 修复 5 skip) +├── unit/ # 新增:单元测试 +│ ├── test_client.py # client.py 30 方法 +│ ├── test_commands.py # commands/ 11 模块 +│ └── test_cli.py # parser + app 路由 +├── integration/ # 新增:集成测试 +│ ├── test_adt_auth.py # 认证通信 +│ ├── test_adt_crud.py # CRUD 通信 +│ ├── test_adt_search.py # 搜索通信 +│ ├── test_adt_transport.py # 传输通信 +│ └── test_adt_quality.py # 质量通信 +├── e2e/ # E2E 手动测试 +│ └── test_e2e.md # 手动测试说明 +├── e2e_project/ # E2E 测试数据 +│ ├── manifest.json +│ ├── reports/ +│ ├── classes/ +│ └── functions/ +└── fixtures/ # 测试固件 + ├── adt_responses/ # ADT XML 响应样本 + │ ├── login_success.xml + │ ├── object_info.xml + │ └── syntax_errors.xml + └── sample_source/ # 示例 ABAP 源码 + ├── zhello.abap + └── zcl_class.abap +``` + +--- + +## 八、实施优先级 + +| 阶段 | 内容 | 用例数 | 预计工时 | +|------|------|--------|---------| +| **P0** | 修复 5 个 skip + 清理旧测试 | +5 pass, -2 文件 | 1h | +| **P0** | client.py 单元测试 | ~90 | 4h | +| **P1** | commands/ 单元测试 | ~60 | 3h | +| **P1** | cli/ 入口测试 | ~10 | 1h | +| **P2** | 集成测试(mock HTTP) | ~36 | 3h | +| **P3** | E2E 手动测试 | 5 场景 | 2h | +| | **合计** | **~228** | **~14h** | + +### 立即可做(本次) + +1. 修复 5 个 skip 用例 → 68 pass +2. 清理旧测试文件 → 减少 1673 行过时代码 +3. 新建测试目录结构 +4. 从 client.py 核心方法开始写单元测试 + +--- + +## 九、关键技术选型 + +| 工具 | 用途 | 是否需要安装 | +|------|------|-------------| +| `unittest` | 测试框架 | ✅ 内置 | +| `unittest.mock` | mock client/requests | ✅ 内置 | +| `responses` | mock HTTP 响应(集成测试) | 需要 `pip install responses` | +| `tempfile` | 临时文件/目录 | ✅ 内置 | +| `pytest` | 可选:更简洁的断言 + fixture | 需要 `pip install pytest` | + +**建议**:保持 `unittest` 框架(不引入 pytest),减少外部依赖。集成测试用 `unittest.mock.patch` 替代 `responses`。 diff --git a/docs/dev/test-report.md b/docs/dev/test-report.md new file mode 100644 index 0000000..0e4a94a --- /dev/null +++ b/docs/dev/test-report.md @@ -0,0 +1,241 @@ +# ADT 工具集测试报告 + +> 测试目标:验证 `main.py` 的 create / info / download / sync / delete 五大功能在各种场景下的正确性。 +> +> 测试日期:2026-05-20 | 测试脚本:[test_main.py](file:///d:/codeSpace/Gitee/Projects2026/sap-cli/test_main.py) + +## 前置条件 + +- [ ] SAP 系统可连接(配置文件 `config.ini` 正确) +- [ ] Python 3.10+ 和 `requests` 库已安装 +- [ ] 系统中存在至少一个 Modifiable 状态的传输请求 +- [ ] 系统中存在以下已有对象用于查询测试: + - class: `ZCL_SAP_MCP_HANDLER` + - report: `ZIDTR_IMPORT_BOM` + - domain: `ZSAPILOT_OBJECT_TYPE` + - dataelement: `ZSAPILOT_OBJECT_NAME` + - table: `ZSAPILOT_OBJ` + +--- + +## 测试覆盖矩阵 + +> 操作(行) × 对象类型(列) 的交叉覆盖情况。 +> ✅ = 已测试通过 | ⬜ = 不适用 | ➖ = 未覆盖(系统限制) + +| 操作 \ 对象类型 | report | class | function | functiongroup | interface | domain | dataelement | table | tabletype | +|:---------------|:------:|:-----:|:--------:|:-------------:|:---------:|:------:|:-----------:|:-----:|:---------:| +| **create** | ✅ SC1 | ✅ SC2 | ✅ SC3 | ✅ SC3 | ✅ SC4 | 🔒 | 🔒 | 🔒 | ⬜ | +| **info** | ✅ SC1 | ✅ SC2 | ✅ SC3 | ✅ | ✅ SC4 | ✅ SC9 | ✅ SC9 | ✅ SC9| ✅ SC9 | +| **download** | ✅ SC1 | ✅ SC2 | ✅ SC3 | ✅ SC13 | ✅ SC4 | 🔒 | 🔒 | 🔒 | ⬜ | +| **sync** | ✅ SC1,SC5 | ✅ SC2 | ✅ SC3 | ✅ SC13 | ✅ SC4 | 🔒 | 🔒 | 🔒 | ⬜ | +| **delete** | ✅ SC1 | ✅ SC2 | ✅ SC3 | ✅ SC3 | ✅ SC4 | 🔒 | 🔒 | 🔒 | ⬜ | + +> **图例:** +> - ✅ 已测试通过 +> - ⬜ 不适用(VIT 端点不支持源码读写和锁定操作) +> - 🔒 SAP 系统限制(当前目标系统 NW 7.40 的 ADT API 不支持 DDIC 对象的源码读写和创建/删除) + +> **DDIC 对象(🔒)说明:** +> - `info` 可用(使用 `Accept: */*` 头),已在 SC9 验证 +> - `create/download/sync/delete` 不可用 — 经实际探测,SAP NetWeaver 7.40 的 ADT 框架对 DDIC 对象(domain/dataelement/table)不提供源码读写端点(返回 404/406) +> - 在 SAP S/4HANA 或更高版本 NetWeaver 系统上,这些操作可能可用 +> - 代码层面已实现完整支持(URI 映射、创建模板、argparse choices 均已配置),仅受目标系统 API 限制 + +> **tabletype(⬜)说明:** +> - `info` 已可用 — 通过 VIT 端点(`/sap/bc/adt/vit/wb/object_type/ttypda/object_name/{name}`)查询元数据,已在 SC9 验证 +> - `download/sync/delete` 不可用 — VIT 端点为只读元数据接口,不提供源码读写和锁定操作 +> - `create` 不可用 — 无可用的集合端点用于创建新对象 + +--- + +## 场景测试详情 + +### 场景组 happy — 正向完整流程 + +> 核心流程:创建 → 查询 → 下载 → 修改 → 同步激活 → 验证修改 → 删除 → 验证不存在 + +#### 场景 1: 正向完整流程 — report + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC1-1 | 创建 report | `create --name ZTEST_SC1_RPT_xxx --type report` | rc == 0 | ✅ | +| SC1-2 | 查询验证 | `info --name ZTEST_SC1_RPT_xxx --type report` | rc == 0 且名称匹配 | ✅ | +| SC1-3 | 下载源码 | `download --name ZTEST_SC1_RPT_xxx --type report` | rc == 0 且文件存在 | ✅ | +| SC1-4 | 修改后同步激活 | 修改源码 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC1-5 | 验证修改持久化 | 再次 `download` | 源码包含修改标记 | ✅ | +| SC1-6 | 删除对象 | `delete --name ZTEST_SC1_RPT_xxx` | 删除成功 | ✅ | +| SC1-7 | 验证已不存在 | `info` | rc != 0 | ✅ | + +#### 场景 2: 正向完整流程 — class + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC2-1 | 创建 class | `create --name ZTEST_SC2_CLS_xxx --type class` | rc == 0 | ✅ | +| SC2-2 | 查询验证 | `info --name ZTEST_SC2_CLS_xxx --type class` | rc == 0 且名称匹配 | ✅ | +| SC2-3 | 下载源码 | `download --name ZTEST_SC2_CLS_xxx --type class` | rc == 0 且文件存在 | ✅ | +| SC2-4 | 修改后同步激活 | 修改源码 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC2-5 | 验证修改持久化 | 再次 `download` | 源码包含修改标记 | ✅ | +| SC2-6 | 删除对象 | `delete --name ZTEST_SC2_CLS_xxx` | 删除成功 | ✅ | +| SC2-7 | 验证已不存在 | `info` | rc != 0 | ✅ | + +#### 场景 3: 正向完整流程 — function + functiongroup + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC3-1 | 创建函数组 | `create --name ZTEST_SC3_FG_xxx --type functiongroup` | rc == 0 | ✅ | +| SC3-2 | info 验证函数组存在 | `info --name ZTEST_SC3_FG_xxx --type functiongroup` | rc == 0 且名称匹配 | ✅ | +| SC3-3 | 创建函数 | `create --name ZTEST_SC3_FG_xxx/ZTEST_SC3_FM_xxx --type function` | rc == 0 | ✅ | +| SC3-4 | info 验证函数存在 | `info` | rc == 0 且名称匹配 | ✅ | +| SC3-5 | 下载函数源码 | `download` | rc == 0 且文件存在 | ✅ | +| SC3-6 | 首次修改 → 同步激活 | 修改源码 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC3-7 | 再次下载验证修改持久化 | `download` | rc == 0 且文件包含修改标记 | ✅ | +| SC3-7b | 验证修改内容已持久化 | 读取下载文件 | 包含 "SC3 TEST MARKER" | ✅ | +| SC3-8 | 二次修改 → 同步激活 | 替换标记 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC3-9 | info 验证函数仍存在 | `info --name ... --type function` | rc == 0 且名称匹配 | ✅ | +| SC3-10 | info 验证函数组仍存在 | `info --name ... --type functiongroup` | rc == 0 且名称匹配 | ✅ | +| SC3-11 | 删除函数 | `delete` 函数模块 | 删除成功 | ✅ | +| SC3-12 | info 验证函数组在函数删除后仍存在 | `info --name ... --type functiongroup` | rc == 0(函数组独立于函数) | ✅ | +| SC3-13 | 删除函数组 | `delete` 函数组 | 删除成功 | ✅ | +| SC3-14 | info 验证函数组已不存在 | `info` | rc != 0 | ✅ | + +#### 场景 4: 正向完整流程 — interface + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC4-1 | 创建 interface | `create --name ZTEST_SC4_INT_xxx --type interface` | rc == 0 | ✅ | +| SC4-2 | 查询验证 | `info --name ZTEST_SC4_INT_xxx --type interface` | rc == 0 且名称匹配 | ✅ | +| SC4-3 | 下载源码 | `download --name ZTEST_SC4_INT_xxx --type interface` | rc == 0 且文件存在 | ✅ | +| SC4-4 | 修改后同步激活 | 修改源码 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC4-5 | 验证修改持久化 | 再次 `download` | 源码包含修改标记 | ✅ | +| SC4-6 | 删除对象 | `delete --name ZTEST_SC4_INT_xxx` | 删除成功 | ✅ | +| SC4-7 | 验证已不存在 | `info` | rc != 0 | ✅ | + +--- + +### 场景组 error — 逆向错误处理 + +#### 场景 5: 逆向 — 语法错误 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC5-1 | 创建 report | `create` | rc == 0 | ✅ | +| SC5-2 | 下载源码 | `download` | rc == 0 且文件存在 | ✅ | +| SC5-3 | 注入无效语法 → 同步 | 添加 `INVALID_SYNTAX_HERE.` → `sync` | rc != 0 且包含错误信息 | ✅ | +| SC5-4 | 恢复源码 → 重新同步 | 还原源码 → `sync` | rc == 0 + 激活成功 | ✅ | +| SC5-5 | 清理删除 | `delete` | 删除成功 | ✅ | + +#### 场景 6: 逆向 — 对象不存在 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC6-1 | 查询不存在的 class | `info --name Z_NOT_EXIST_99999 --type class` | rc != 0 且包含"不存在" | ✅ | +| SC6-2 | 下载不存在的 report | `download --name Z_NOT_EXIST_99999` | rc != 0 | ✅ | +| SC6-3 | 删除不存在的 report | `delete --name Z_NOT_EXIST_99999` | rc != 0 | ✅ | + +#### 场景 7: 逆向 — 重复创建 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC7-1 | 首次创建 | `create --name ZTEST_SC7_RPT_xxx` | rc == 0 | ✅ | +| SC7-2 | 重复创建 → 报错 | 再次 `create` 同名对象 | rc != 0 | ✅ | +| SC7-3 | 清理删除 | `delete` | 删除成功 | ✅ | + +--- + +### 场景组 edge — 边界场景 + +#### 场景 8: 边界 — 同步自动创建 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC8-1 | 准备本地源码 | 创建 .abap 文件 | 文件存在 | ✅ | +| SC8-2 | sync 自动创建 | `sync` 不存在的对象 | rc == 0(自动创建) | ✅ | +| SC8-3 | info 验证存在 | `info` | rc == 0 且名称匹配 | ✅ | +| SC8-4 | 清理删除 | `delete` | 删除成功 | ✅ | + +#### 场景 9: 边界 — DDIC 对象查询 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC9-1 | info domain | `info --name ZSAPILOT_OBJECT_TYPE --type domain` | rc == 0 且名称匹配 | ✅ | +| SC9-2 | info dataelement | `info --name ZSAPILOT_OBJECT_NAME --type dataelement` | rc == 0 | ✅ | +| SC9-3 | info table | `info --name ZSAPILOT_OBJ --type table` | rc == 0 | ✅ | +| SC9-4 | info tabletype | `info --name ZSAPILOT_OBJECT_TT --type tabletype` | rc == 0 且名称匹配 | ✅ | + +#### 场景 10: 边界 — 删除安全确认 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC10-1 | 创建 report | `create` | rc == 0 | ✅ | +| SC10-2 | delete 输入 no → 取消 | `delete` stdin="no" | 输出包含"已取消" | ✅ | +| SC10-3 | info 验证仍存在 | `info` | rc == 0 且名称匹配 | ✅ | +| SC10-4 | delete 输入 yes → 成功 | `delete` stdin="yes" | 删除成功 | ✅ | +| SC10-5 | info 验证已不存在 | `info` | rc != 0 | ✅ | + +#### 场景 13: 边界 — functiongroup 不支持源码操作 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC13-1 | download functiongroup → 报错 | `download --name ZIDT_MCP_TOOL --type functiongroup` | rc != 0 且包含"不支持" | ✅ | +| SC13-2 | sync functiongroup → 报错 | `sync --name ZIDT_MCP_TOOL --type functiongroup` | rc != 0 且包含"不支持" | ✅ | + +--- + +### 场景组 existing — 已有对象操作 + +#### 场景 11: 已有对象查询 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC11-1 | info 已有 class | `info --name ZCL_SAP_MCP_HANDLER --type class` | rc == 0 且名称匹配 | ✅ | +| SC11-2 | 下载已有 class | `download --name ZCL_SAP_MCP_HANDLER` | rc == 0 且文件存在 | ✅ | +| SC11-3 | 同步无修改 → 成功 | `sync`(源码未修改) | 语法检查通过 + 激活成功 | ✅ | + +--- + +### 场景组 config — 配置与环境 + +#### 场景 12: 配置与环境 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC12-1 | 默认配置运行 | `download`(使用 config.ini) | rc == 0 | ✅ | +| SC12-2 | 无效 SAP_HOST | 设置 `SAP_HOST=http://invalid:9999` | rc != 0 | ✅ | + +--- + +## 测试统计 + +### 按场景组统计 + +| 场景组 | 场景数 | 步骤总数 | ✅ 通过 | ❌ 失败 | 通过率 | +|--------|:------:|:--------:|:-------:|:-------:|:------:| +| happy(正向完整流程) | 4 | 36 | 36 | 0 | 100% | +| error(逆向错误处理) | 3 | 11 | 11 | 0 | 100% | +| edge(边界场景) | 4 | 14 | 14 | 0 | 100% | +| existing(已有对象) | 1 | 3 | 3 | 0 | 100% | +| config(配置环境) | 1 | 2 | 2 | 0 | 100% | +| **合计** | **13** | **66** | **66** | **0** | **100%** | + +### 失败项分析 + +本次测试全部通过,无失败项。 + +### 测试过程中发现并修复的问题 + +| # | 问题 | 修复内容 | +|---|------|---------| +| 1 | 下载文件含 `\r\r\n` 双重回车 | 保存前统一换行符 `source.replace("\r\n", "\n")` | +| 2 | `lock()` 未传 corrNr 导致写入冲突 | `lock()` 增加 `corr_nr` 参数 | +| 3 | `activate()` 误判 inactiveObjects 为成功 | 新增 Content-Type 检测,正确处理 `inactiveCtsObjects` 响应 | +| 4 | `activate()` 使用 `preauditRequested=true` 导致激活失败 | 移除该参数 | +| 5 | `_headers()` 硬编码 `Accept-Language: EN` | 移除硬编码,使用 SAP 系统登录语言 | +| 6 | `cmd_sync` 交互式等待用户输入 | 改为非交互式,报错直接退出 | +| 7 | `cmd_sync` 缺少语法检查步骤 | 新增 `syntax_check()` 方法,激活前先做语法检查 | +| 8 | `cmd_sync` 对象不存在时直接退出 | 改为自动创建空对象后继续同步流程 | +| 9 | `create` 缺少 interface 默认模板 | 新增 interface 模板到 `DEFAULT_TEMPLATES` | +| 10 | `parse_object_name` 对 functiongroup 崩溃(src_uri 为 None) | 增加 `if config["src_uri_template"] else None` | +| 11 | `cmd_info` 缺少名称校验 | 新增查询结果名称与请求名称的对比校验 | +| 12 | `cmd_info` function 名称校验失败 | 用 `split("/")[-1]` 取实际对象名进行校验 | +| 13 | DDIC 对象 info 查询 Accept 头不正确 | 改用 `*/*` Accept 头 | diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..141d749 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,712 @@ +--- +title: ADT 工具集使用指南 +created: 2026-05-20 +updated: 2026-05-20 +tags: + - SAP + - ADT + - REST + - python + - tools +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +--- + +# ADT 工具集使用指南 — `main.py` + +> [!abstract] 概述 +> `main.py` 是一个基于 SAP ADT REST API 的命令行工具,支持 **report**、**class**、**function**、**functiongroup**、**interface**、**domain**、**dataelement**、**table**、**tabletype** 等 ABAP 开发对象的创建、信息查询、源代码下载、同步激活和删除操作。 + +> [!info] 快速查阅 +> **命令格式:** `python main.py <命令> --name <名称> --type <类型> [参数]` +> +> | 命令 | 功能 | 完整参数 | +> |------|------|---------| +> | `create` | 创建对象 | `--name` `--type` `[--description]` `[--source]` `[--corr_nr]` | +> | `info` | 查询元数据(状态/描述/时间) | `--name` `--type` | +> | `download` | 下载源码到本地 `.abap` | `--name` `--type` `--path <目录>` | +> | `sync` | 上传源码 → 语法检查 → 激活 | `--name` `--type` `--path <.abap文件>` `[--corr_nr]` | +> | `delete` | 删除对象(需确认) | `--name` `--type` | +> +> **对象类型:** `report` `class` `function` `functiongroup` `interface` `domain` `dataelement` `table` `tabletype` +> +> **sync 流程:** 登录 → 检查/创建对象 → 锁定 → 写入 → 解锁 → 语法检查 → 激活 → 报错则退出(修改后重新执行) +> +> **典型用法:** +> ```bash +> python main.py create --name ZCL_MY_CLASS --type class +> python main.py info --name ZCL_MY_CLASS --type class +> python main.py sync --name ZCL_MY_CLASS --type class --path ./zcl_my_class.abap +> ``` + +## 1. 前置条件 + +### 环境要求 + +- Python 3.10+ +- 依赖库:`requests` + +### 安装依赖 + +```bash +pip install requests +``` + +### 连接配置 + +连接参数通过配置文件 `config.ini` 管理,位于脚本同目录下: + +```ini +[SAP] +host = http://support.learningleader.com.cn:55955 +client = 100 +user = admin2 +password = 654321 +``` + +| 参数 | 说明 | +|------|------| +| `host` | SAP 服务器地址(不含 `/sap/bc/adt` 路径) | +| `client` | SAP 客户端编号 | +| `user` | 登录用户名 | +| `password` | 登录密码 | + +> [!tip] 配置文件加载顺序 +> 脚本按以下顺序查找配置: +> 1. `--config` 参数指定的文件路径 +> 2. 环境变量 `SAP_HOST` / `SAP_CLIENT` / `SAP_USER` / `SAP_PASSWORD`(优先级高于配置文件) +> 3. 当前工作目录下的 `config.ini` +> 4. 脚本同目录下的 `config.ini` + +## 2. 对象类型说明 + +工具支持以下 ABAP 开发对象类型: + +| 类型参数 | 对象 | SAP GUI 事务码 | 名称格式 | 支持的操作 | +|---------|------|--------------|---------|----------| +| `report` | 程序 / 报表 | SE38 / SA38 | 直接使用程序名 | 创建、查询、下载、同步、删除 | +| `class` | 类 (ABAP Class) | SE24 | 直接使用类名 | 创建、查询、下载、同步、删除 | +| `function` | 函数模块 (Function Module) | SE37 | `函数组名/函数模块名` | 创建、查询、下载、同步、删除 | +| `functiongroup` | 函数组 (Function Group) | SE37 / SE80 | 直接使用函数组名 | 创建、查询、删除 | +| `interface` | 接口 (ABAP Interface) | SE24 | 直接使用接口名 | 创建、查询、下载、同步、删除 | +| `domain` | 域 (Domain) | SE11 | 直接使用域名 | 创建、同步 | +| `dataelement` | 数据元素 (Data Element) | SE11 | 直接使用元素名 | 创建、同步 | +| `table` | 透明表 (Table) | SE11 | 直接使用表名 | 创建、同步 | +| `tabletype` | 表类型 (Table Type) | SE11 | 直接使用类型名 | 创建、查询 ² | + +> [!warning] function 类型的名称格式 +> function 类型必须使用 **`函数组名/函数模块名`** 格式,用 `/` 分隔。例如:`ZGROUP/Z_MY_FUNC` +> +> report、class 和 functiongroup 类型直接使用对象名称即可。 + +## 3. 命令一览 + +``` +python main.py <命令> --name <对象名称> --type <对象类型> [其他参数] +``` + +| 命令 | 功能 | 必需参数 | +|------|------|---------| +| `create` | 在 SAP 系统创建开发对象 | `--name`, `--type` | +| `info` | 查询对象元数据(描述、激活状态等) | `--name`, `--type` | +| `download` | 从 SAP 下载源代码到本地 | `--name`, `--type`, `--path` | +| `sync` | 上传本地代码到 SAP,语法检查并激活 | `--name`, `--type`, `--path` | +| `delete` | 从 SAP 系统删除对象 | `--name`, `--type` | + +## 4. 功能详解 + +### 4.1 创建开发对象 (`create`) + +在 SAP 系统中创建新的开发对象。自动生成默认源代码模板并激活。 + +#### 执行流程 + +``` +登录 → 检查对象不存在? + ├─ 已存在 → 报错停止 + └─ 不存在 → 继续 + 选择传输请求(交互式) + ├─ 使用已有请求号(用户输入编号) + ├─ 新建请求号(用户提供描述) + └─ 不使用请求号(本地对象) + 创建对象 → 写入源代码 → 激活 +``` + +#### 命令格式 + +```bash +python main.py create --name <对象名> --type <类型> [--description <描述>] [--source <源代码文件>] [--corr_nr <传输请求号>] +``` + +#### 参数说明 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--name` | 是 | 对象名称(function 类型用 `组名/模块名`) | +| `--type` | 是 | 对象类型:`report` / `class` / `function` / `functiongroup` | +| `--description` | 否 | 对象描述,默认使用对象名称 | +| `--source` | 否 | 源代码文件路径(`.abap`),不指定则使用内置默认模板 | +| `--corr_nr` | 否 | 直接指定传输请求号(跳过交互选择) | + +#### 示例 + +```bash +# 创建程序(交互式选择传输请求) +python main.py create --name ZMY_REPORT --type report --description "我的报表程序" + +# 创建程序(直接指定传输请求号,跳过交互) +python main.py create --name ZMY_REPORT --type report --description "我的报表程序" --corr_nr DEVK901362 + +# 创建类(使用默认模板) +python main.py create --name ZCL_MY_CLASS --type class --description "我的类" + +# 创建函数组 +python main.py create --name ZMY_FGROUP --type functiongroup --description "我的函数组" + +# 创建函数模块(自动检测并创建函数组) +python main.py create --name ZMY_FGROUP/ZMY_FUNC --type function --description "我的函数模块" + +# 使用自定义源代码创建程序 +python main.py create --name ZMY_REPORT --type report --source ./my_code.abap +``` + +#### 输出示例 + +``` +============================================================ + SAP ADT 创建开发对象 +============================================================ + 对象类型: 程序(REPORT) + 对象名称: ZMY_REPORT + 描述: 我的报表程序 + + → 检查对象是否存在... + ✓ 对象不存在,可以创建 + + → 查询可用的传输请求... + 找到 2 个可修改的传输请求: + + 1. DEVK901362 Implement sales validation (所有者: ADMIN2) + 2. DEVK901365 Bugfix for invoice printing (所有者: ADMIN2) + 3. 新建传输请求 + 0. 不使用传输请求(本地对象) + + 请选择 [0-3]: 1 + ✓ 已选择传输请求: DEVK901362 + + → 正在创建对象... + ✓ 对象创建成功! + ✓ URI: /sap/bc/adt/programs/programs/zmy_report + ✓ 源代码已写入并激活 (52 字符) + + ✓ ZMY_REPORT 创建完成! + 类型: 程序(REPORT) + 描述: 我的报表程序 +``` + +> [!tip] 传输请求选择 +> 创建对象时,脚本会列出所有可修改(Modifiable)状态的传输请求供选择。 +> - 选择已有请求:直接输入编号 +> - 新建请求:选择"新建传输请求",然后输入描述 +> - 本地对象:选择"不使用传输请求",对象将创建在 `$TMP` 包中 +> - 非交互模式:使用 `--corr_nr` 参数直接指定传输请求号 + +#### 默认源代码模板 + +不指定 `--source` 时,使用以下内置模板: + +**report 模板:** +```abap +REPORT ZMY_REPORT. +WRITE: / 'Hello from ZMY_REPORT'. +``` + +**class 模板:** +```abap +CLASS ZCL_MY_CLASS DEFINITION + PUBLIC + FINAL + CREATE PUBLIC. + PUBLIC SECTION. + METHODS: hello. +ENDCLASS. +CLASS ZCL_MY_CLASS IMPLEMENTATION. + METHOD hello. + ENDMETHOD. +ENDCLASS. +``` + +**function 模板:** +```abap +FUNCTION ZMY_FUNC + EXPORTING + VALUE(EV_RESULT) TYPE STRING. + ev_result = 'hello'. +ENDFUNCTION. +``` + +> [!tip] 创建函数模块时自动创建函数组 +> 创建 function 类型时,脚本会自动检查函数组是否存在。如果不存在,会先自动创建函数组,再创建函数模块。 + +--- + +### 4.2 源代码下载 (`download`) + +从 SAP 系统下载对象的源代码,保存为本地 `.abap` 文件。 + +#### 执行流程 + +``` +登录 → 检查对象存在 → 获取源代码 → 保存为 .abap 文件 +``` + +#### 命令格式 + +```bash +python main.py download --name <对象名> --type <类型> --path <保存目录> +``` + +#### 示例 + +```bash +# 下载 report +python main.py download --name ZIDTR_IMPORT_ACCOUNTING_ORDER --type report --path ./output + +# 下载 class +python main.py download --name ZCL_MY_CLASS --type class --path ./output + +# 下载 function +python main.py download --name ZGROUP/Z_MY_FUNC --type function --path ./output +``` + +#### 输出 + +- 在 `--path` 指定目录下生成 **同名小写** 的 `.abap` 文件 +- 例如:`--name ZMY_REPORT` → `./output/zmy_report.abap` +- 如果保存目录不存在,会自动创建 + +#### 输出示例 + +``` +============================================================ + SAP ADT 源代码下载 +============================================================ + 对象类型: 程序(REPORT) + 对象名称: ZIDTR_IMPORT_ACCOUNTING_ORDER + 保存路径: ./output + + → 正在登录... + ✓ 登录成功 + + → 检查对象是否存在... + ✓ 对象存在 + + → 正在下载源代码... + ✓ 源代码下载成功! 37655 字符, 1096 行 + + ✓ 文件已保存: ./output\zidtr_import_accounting_order.abap + + ✅ 下载完成! + 文件: ./output\zidtr_import_accounting_order.abap + 大小: 37655 字符, 1096 行 +``` + +--- + +### 4.3 源代码同步激活 (`sync`) + +将本地 `.abap` 文件的代码上传到 SAP 系统,进行语法检查并激活。**报错时直接退出,由调用者修改代码后重新执行**。 + +#### 执行流程 + +``` +登录 → 检查对象是否存在? + ├─ 不存在 → 自动创建空对象 → 继续 + └─ 存在 → 继续 + 锁定对象(自动检测传输请求绑定) + ├─ 对象已绑定请求号 → 自动使用原请求号 + ├─ 本地对象($TMP)→ 无需请求号 + └─ 需要请求号但未绑定 → 交互选择(已有/新建) + 写入 → 解锁 + → 语法检查 + ├─ 有错误 → 输出错误信息 → 退出(exit code 1) + └─ 无错误 → 继续 + → 激活 + ├─ 成功 → 输出成功信息 → 退出(exit code 0) + └─ 失败 → 输出错误信息 → 退出(exit code 1) +``` + +> [!important] 非交互式设计 +> 脚本发现语法错误或激活失败时,**不会暂停等待用户输入**,而是直接输出错误信息并以 exit code 1 退出。 +> 调用者需要根据错误信息修改本地 `.abap` 文件,然后**重新执行** `sync` 命令。 + +> [!tip] 传输请求自动检测 +> `sync` 命令会**先尝试锁定对象**,通过 SAP 系统返回的信息自动判断传输请求绑定状态: +> - 如果对象已绑定到某个请求号 → 直接使用该请求号,无需用户干预 +> - 如果对象是本地对象(`$TMP`)→ 不需要请求号 +> - 只有当对象需要请求号但尚未绑定时,才会提示用户选择或新建请求号 + +#### 命令格式 + +```bash +python main.py sync --name <对象名> --type <类型> --path <.abap文件路径> [--corr_nr <传输请求号>] +``` + +#### 参数说明 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--name` | 是 | 对象名称(function 类型用 `组名/模块名`) | +| `--type` | 是 | 对象类型 | +| `--path` | 是 | 本地源代码文件路径(`.abap`) | +| `--corr_nr` | 否 | 直接指定传输请求号(跳过自动检测) | + +#### 示例 + +```bash +# 同步激活 report(自动检测传输请求绑定) +python main.py sync --name ZIDTR_IMPORT_ACCOUNTING_ORDER --type report --path ./zidtr_import_accounting_order.abap + +# 同步激活 report(直接指定传输请求号) +python main.py sync --name ZIDTR_IMPORT_ACCOUNTING_ORDER --type report --path ./zidtr_import_accounting_order.abap --corr_nr DEVK901362 + +# 同步激活 class +python main.py sync --name ZCL_MY_CLASS --type class --path ./zcl_my_class.abap + +# 同步激活 function +python main.py sync --name ZGROUP/Z_MY_FUNC --type function --path ./z_my_func.abap +``` + +#### 输出示例 — 成功(对象已绑定请求号) + +``` +============================================================ + SAP ADT 源代码同步激活 +============================================================ + 对象类型: 类(CLASS) + 对象名称: ZCL_MY_CLASS + 本地文件: ./zcl_my_class.abap + 源代码: 256 字符, 12 行 + 流程: 检查/创建 → 写入 → 语法检查 → 激活 + + → 检查对象是否存在... + ✓ 对象存在 + +──────────────────────────────────────────────────────────── + + ── Step 1/4: 锁定 → 写入 → 解锁 ── + ✓ 锁定成功(对象已绑定传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 + + ── Step 2/4: 语法检查 ── + ✓ 语法检查通过 + + ── Step 3/4: 激活对象 ── + ✓ 激活成功! + + ============================================================ + ✓ ZCL_MY_CLASS 已成功同步并激活! + ============================================================ +``` + +#### 输出示例 — 对象未绑定请求号(需要选择) + +``` + ── Step 1/4: 锁定 → 写入 → 解锁 ── + ℹ 对象需要传输请求号 + + → 查询可用的传输请求... + 找到 2 个可修改的传输请求: + + 1. DEVK901362 Implement sales validation (所有者: ADMIN2) + 2. DEVK901365 Bugfix for invoice printing (所有者: ADMIN2) + 3. 新建传输请求 + 0. 不使用传输请求(本地对象) + + 请选择 [0-3]: 1 + ✓ 已选择传输请求: DEVK901362 + ✓ 锁定成功(传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 +``` + + ── Step 1/4: 锁定 → 写入 → 解锁 ── + ✓ 锁定成功 + ✓ 源代码写入成功 + ✓ 解锁成功 + + ── Step 2/4: 语法检查 ── + ✓ 语法检查通过 + + ── Step 3/4: 激活对象 ── + ✓ 激活成功! + + ============================================================ + ✓ ZCL_MY_CLASS 已成功同步并激活! + ============================================================ +``` + +#### 输出示例 — 语法检查失败 + +``` + ── Step 2/4: 语法检查 ── + ✗ 语法检查未通过! 2 个错误, 1 个警告: + [错误] 行 10: Field "XXX" is unknown + [错误] 行 25: Type "ZZZ" is unknown + [警告] 行 5: Unused variable "LV_TEST" + + 请修改本地文件后重新执行: ./zmy_report.abap +``` + +> [!tip] 对象不存在时自动创建 +> 当同步的目标对象在 SAP 系统中不存在时,脚本会自动创建一个空对象,然后继续执行写入和激活流程。 +> 如果自动创建失败,会输出错误信息并退出,此时需要先使用 `create` 命令手动创建。 + +--- + +### 4.4 对象信息查询 (`info`) + +查询 SAP 系统中开发对象的元数据信息,包括描述、激活状态、开发包、传输请求号、负责人、修改时间等。 + +#### 执行流程 + +``` +登录 → 查询对象元数据 → 输出信息 +``` + +#### 命令格式 + +```bash +python main.py info --name <对象名> --type <类型> +``` + +#### 示例 + +```bash +# 查询类信息 +python main.py info --name ZCL_MY_CLASS --type class + +# 查询程序信息 +python main.py info --name ZMY_REPORT --type report + +# 查询接口信息 +python main.py info --name ZIF_MY_INTERFACE --type interface +``` + +#### 输出示例 + +``` +============================================================ + SAP ADT 对象信息查询 +============================================================ + 对象类型: 类(CLASS) + 对象名称: ZCL_MY_CLASS + + → 正在登录... + ✓ 登录成功 + + → 正在查询对象信息... + ✓ 查询成功 + + ────────────────────────────────────────────────── + 名称: ZCL_MY_CLASS + 类型: CLAS/OC + 描述: My ABAP Class + 状态: ✅ 已激活 + 负责人: ADMIN2 + 语言: ZH + 开发包: ZSAPILOT + 传输请求: DEVK901362 + 创建者: ADMIN2 + 修改者: ADMIN2 + 修改时间: 2026-05-20T12:00:00Z + URI: /sap/bc/adt/oo/classes/zcl_my_class + ────────────────────────────────────────────────── +``` + +> [!tip] 状态说明 +> - `✅ 已激活` — 对象处于 active 状态,可在系统中正常使用 +> - `⚠️ 未激活` — 对象有未激活的更改,需要执行 `sync` 激活后才能生效 + +> [!note] 开发包与传输请求 +> - **开发包** —— 直接从对象元数据读取 +> - **传输请求** —— 纯只读查询系统表 `E071`,显示对象当前所在的未释放请求(多个用逗号分隔) +> - `无` —— 对象不在任何未释放请求中 +> - `(无法获取)` —— 无系统表查询权限或查询失败(不影响其余字段输出) +> - `—(非独立传输对象)` —— `include` / `function` 等非独立 R3TR 对象,不单独占用传输请求 + +> [!note] 支持的对象类型 +> `info` 命令支持全部 9 种对象类型:`report`、`class`、`function`、`functiongroup`、`interface`、`domain`、`dataelement`、`table`、`tabletype`。 +> `tabletype` 通过 **VIT 端点**(`/sap/bc/adt/vit/wb/object_type/ttypda/object_name/{name}`)访问,仅支持元数据查询(`info`),不支持源码读写(`download`/`sync`)和删除操作。 + +--- + +### 4.5 源代码删除 (`delete`) + +从 SAP 系统中删除指定的开发对象。**删除前需要交互确认**。 + +#### 执行流程 + +``` +登录 → 检查对象存在 → 确认删除 → 查询传输请求 → 锁定 → 执行删除 +``` + +#### 命令格式 + +```bash +python main.py delete --name <对象名> --type <类型> +``` + +#### 示例 + +```bash +# 删除 report +python main.py delete --name ZMY_OLD_REPORT --type report + +# 删除 class +python main.py delete --name ZCL_OBSOLETE --type class + +# 删除 function +python main.py delete --name ZGROUP/Z_OLD_FUNC --type function +``` + +#### 确认提示 + +``` + ⚠️ 即将从 SAP 系统中删除: ZMY_OLD_REPORT + 类型: 程序(REPORT) + URI: /sap/bc/adt/programs/programs/zmy_old_report + + 确认删除? (输入 yes 确认): +``` + +> [!danger] 不可逆操作 +> 删除操作不可恢复!请确认对象名称无误后再输入 `yes`。输入其他任何内容均取消操作。 + +## 5. API 端点映射 + +不同对象类型对应的 ADT REST API 路径: + +| 对象类型 | 对象 URI | 源代码 URI | +|---------|---------|-----------| +| `report` | `/sap/bc/adt/programs/programs/{name}` | `.../source/main` | +| `class` | `/sap/bc/adt/oo/classes/{name}` | `.../source/main` | +| `interface` | `/sap/bc/adt/oo/interfaces/{name}` | `.../source/main` | +| `function` | `/sap/bc/adt/functions/groups/{group}/fmodules/{name}` | `.../source/main` | +| `functiongroup` | `/sap/bc/adt/functions/groups/{name}` | — | +| `domain` | `/sap/bc/adt/ddic/domains/{name}` | `.../source/main` | +| `dataelement` | `/sap/bc/adt/ddic/dataelements/{name}` | `.../source/main` | +| `table` | `/sap/bc/adt/ddic/tables/{name}` | `.../source/main` | +| `tabletype` | `/sap/bc/adt/vit/wb/object_type/ttypda/object_name/{name}` | — (VIT 端点,仅元数据) | + +> [!note] URL 中的名称使用小写 +> SAP ADT API 的 URL 路径中对象名称通常使用**小写**形式。`tabletype` 例外 — VIT 端点使用**大写**名称以获取完整元数据。脚本已自动处理大小写转换,`--name` 参数输入原始大小写即可。 + +## 6. 典型工作流 + +### 场景一:创建新对象 → 修改 → 同步激活 + +```bash +# Step 1: 创建新程序(自动生成默认代码并激活) +python main.py create --name ZMY_REPORT --type report --description "我的报表" + +# Step 2: 下载到本地编辑 +python main.py download --name ZMY_REPORT --type report --path ./src + +# Step 3: 用编辑器修改 ./src/zmy_report.abap + +# Step 4: 同步激活 +python main.py sync --name ZMY_REPORT --type report --path ./src/zmy_report.abap +``` + +### 场景二:下载 → 修改 → 同步激活 + +```bash +# Step 1: 下载已有对象源代码 +python main.py download --name ZMY_REPORT --type report --path ./src + +# Step 2: 用编辑器修改 ./src/zmy_report.abap + +# Step 3: 同步激活(如果激活失败,修改后重试) +python main.py sync --name ZMY_REPORT --type report --path ./src/zmy_report.abap +``` + +### 场景三:函数模块开发 + +```bash +# 创建函数组(如果不存在) +python main.py create --name ZMY_FGROUP --type functiongroup --description "我的函数组" + +# 创建函数模块(自动检测函数组,不存在则自动创建) +python main.py create --name ZMY_FGROUP/ZMY_FUNC --type function --description "我的函数" + +# 下载并修改 +python main.py download --name ZMY_FGROUP/ZMY_FUNC --type function --path ./src + +# 同步激活 +python main.py sync --name ZMY_FGROUP/ZMY_FUNC --type function --path ./src/zmy_func.abap +``` + +### 场景四:清理废弃对象 + +```bash +# 删除废弃程序 +python main.py delete --name ZOLD_REPORT --type report + +# 删除废弃类 +python main.py delete --name ZCL_OBSOLETE --type class + +# 删除废弃函数模块 +python main.py delete --name ZMY_FGROUP/ZOLD_FUNC --type function +``` + +## 7. 日志系统 + +所有 API 请求和响应详细记录在日志文件中: + +``` +sap-cli/adt_tools.log +``` + +### 日志内容示例 + +``` +2026-05-20 10:30:00 | INFO | LOGIN: GET http://.../compatibility/graph +2026-05-20 10:30:00 | INFO | LOGIN RESPONSE: HTTP 200 +2026-05-20 10:30:00 | INFO | CSRF Token: abCdEfGhIjKlMnOpQrSt... +2026-05-20 10:30:01 | INFO | GET SOURCE: GET .../source/main +2026-05-20 10:30:01 | INFO | GET SOURCE RESPONSE: HTTP 200, 37655 bytes +2026-05-20 10:30:02 | INFO | LOCK: POST .../programs/zmy_report (corrNr=DEVK901362) +2026-05-20 10:30:02 | INFO | LOCK RESPONSE: HTTP 200 +2026-05-20 10:30:03 | INFO | SET SOURCE: PUT .../source/main (1024 chars) +2026-05-20 10:30:03 | INFO | ACTIVATE: POST .../activation name=ZMY_REPORT +2026-05-20 10:30:03 | INFO | Activation msg [E] line=10: Field "XXX" unknown +2026-05-20 10:30:03 | INFO | Activation result: FAILED (1 errors) +``` + +### 排查问题 + +1. 查看 `adt_tools.log` 中的错误信息 +2. 搜索 `FAILED` 或 `ERROR` 关键字 +3. 激活错误中 `line` 为语句序号,`href` 中的 `start=XXX` 为字符偏移量 +4. 结合源代码定位问题位置 + +## 8. 常见问题 + +| 问题 | 原因 | 解决方案 | +|------|------|---------| +| 登录失败 HTTP 401 | 用户名或密码错误 | 检查 `config.ini` 中的连接配置 | +| 对象不存在 HTTP 404 | 名称或类型错误 | 确认 `--name` 和 `--type` 参数 | +| 对象已存在 HTTP 403 | 创建时对象名冲突 | 使用不存在的对象名或先删除已有对象 | +| 锁定失败 | 对象被其他用户锁定 | 脚本自动处理 corrNr 冲突;如仍失败,等待或联系管理员 | +| 未找到传输请求 | 系统中无 Modifiable 状态的 TR | 在 SAP 中创建传输请求 (SE01/SE09) | +| 激活报语法错误 | 代码有语法问题 | 修改本地文件后重新执行 `sync` 命令 | +| 删除失败 | 权限不足或对象被锁定 | 检查 S_DEVELOP 权限 | +| 环境变量覆盖配置 | 系统环境变量 SAP_HOST 已设置 | 运行前清除:`$env:SAP_HOST=""` | + +## 9. 相关笔记 + +- [[sap-cli/01.ADT架构与通信原理|01.ADT架构与通信原理]] — REST 通信层原理 +- [[sap-cli/03.修改代码-原理|03.修改代码-原理]] — 锁定/编辑/激活工作流 +- [[sap-cli/07.ADT代码同步工具|07.ADT代码同步工具]] — 早期版本的同步工具 +- [[sap-cli/doc/批量同步设计|批量同步设计]] — 项目清单驱动的批量同步方案设计 diff --git a/docs/功能矩阵分析.md b/docs/功能矩阵分析.md new file mode 100644 index 0000000..864d254 --- /dev/null +++ b/docs/功能矩阵分析.md @@ -0,0 +1,192 @@ +# sap-cli 功能矩阵分析报告 + +> 分析基于源码静态审查(`sapcli/types.py`、`sapcli/cli/parser.py`、`sapcli/commands/*`、`sapcli/client/*`)。 +> 生成时间:2026-06-18 + +--- + +## 第一部分:已实现的命令完整列表 + +共 **25 个顶层命令**(含子命令则为 **38 个操作**)。命令分发逻辑见 `sapcli/cli/app.py` 的 `command_map`(外加 `config`/`auth` 两个不走 SAP 连接的命令)。 + +| # | 命令 | 子命令 | 所在文件 | 功能描述 | 是否需 SAP 连接 | +|---|------|--------|----------|----------|----------------| +| 1 | `download` | — | `commands/crud.py::cmd_download` | 下载对象源代码到本地文件 | ✅ | +| 2 | `sync` | — | `commands/crud.py::cmd_sync` | 同步单对象源码并激活(检查→写入→语法检查→激活) | ✅ | +| 2b| `sync --all` | — | `commands/batch.py::cmd_sync_all` | 批量同步清单内所有对象(拓扑排序) | ✅ | +| 3 | `upload` | — | `commands/upload.py::cmd_upload` | 仅上传源码(锁定→写入→解锁,不检查不激活) | ✅ | +| 4 | `syntax-check` | — | `commands/syntax_check.py::cmd_syntax_check` | 远程对象语法检查(不上传不激活) | ✅ | +| 5 | `activate` | — | `commands/activate.py::cmd_activate` | 单独激活对象(单/批量模式) | ✅ | +| 6 | `delete` | — | `commands/crud.py::cmd_delete` | 从 SAP 删除对象(交互确认) | ✅ | +| 7 | `info` | — | `commands/crud.py::cmd_info` | 查询对象元数据 | ✅ | +| 8 | `create` | — | `commands/crud.py::cmd_create` | 创建开发对象(含 DDIC/函数组) | ✅ | +| 9 | `diff` | — | `commands/diff_cmd.py::cmd_diff` | 本地 vs SAP 代码差异对比 | ✅ | +| 10 | `list` | — | `commands/search.py::cmd_list` | 列出 SAP 对象(类型/包/前缀过滤) | ✅ | +| 11 | `whereused` | — | `commands/search.py::cmd_whereused` | Where-Used 引用查询 | ✅ | +| 12 | `search` | — | `commands/search.py::cmd_search` | 源代码搜索 | ✅ | +| 13 | `check` | — | `commands/quality.py::cmd_check` | ATC 代码检查 | ✅ | +| 14 | `format` | — | `commands/quality.py::cmd_format` | 代码格式化(Pretty Printer + 写回 + 激活) | ✅ | +| 15 | `transport` | `list` | `commands/transport.py::_transport_list` | 列出可修改的传输请求 | ✅ | +| 15b| | `info` | `::_transport_info` | 查看传输请求详情 | ✅ | +| 15c| | `release` | `::_transport_release` | 释放传输请求(交互确认) | ✅ | +| 15d| | `objects` | `::_transport_objects` | 列出传输请求中对象 | ✅ | +| 16 | `package` | `create` | `commands/package_cmd.py::_package_create` | 创建 ABAP 包 | ✅ | +| 16b| | `info` | `::_package_info` | 查看包详情 | ✅ | +| 16c| | `list` | `::_package_list` | 列出包中对象 | ✅ | +| 17 | `cds` | `download` | `commands/cds.py::_cds_download` | 下载 CDS View DDL 源码 | ✅ | +| 17b| | `sync` | `::_cds_sync` | 同步本地 DDL 到 SAP(含创建/激活) | ✅ | +| 17c| | `create` | `::_cds_create` | 创建新的 CDS View | ✅ | +| 18 | `init` | — | `commands/batch.py::cmd_init` | 扫描本地→查询 SAP→生成 manifest.json | ✅ | +| 19 | `refresh` | — | `commands/batch.py::cmd_refresh` | 刷新清单中对象状态 | ✅ | +| 20 | `analyze` | — | `commands/analyze.py::cmd_analyze` | 本地依赖自动分析(正则匹配) | ✅ | +| 21 | `scaffold` | — | `commands/scaffold.py::cmd_scaffold` | 项目模板创建(4 种模板) | ❌(本地) | +| 22 | `config` | `show`/`list-profiles`/`set` | `commands/config_cmd.py::cmd_config` | 配置管理 | ❌ | +| 23 | `auth` | `login`/`logout`/`status` | `auth.py::cmd_auth_*` | keyring 密钥管理 | ❌ | +| 24 | `show-table` | — | `commands/ddl_query.py::cmd_show_table` | 查看 DDIC 表字段结构(SE11) | ✅ | +| 25 | `read-table` | — | `commands/ddl_query.py::cmd_read_table` | 查询表数据(freestyle SQL) | ✅ | +| 26 | `run-program` | — | `commands/program_run.py::cmd_run_program` | 远程执行 ABAP 程序(SA38) | ✅ | + +--- + +## 第二部分:已注册的对象类型完整列表 + +共 **16 个对象类型**,全部在 `sapcli/types.py` 中通过 `_register()` 注册。 + +| # | key | label | 对象端点 (obj_uri) | 源码端点 (src_uri) | has_source | create_content_type | +|---|-----|-------|-------------------|-------------------|-----------|---------------------| +| 1 | `report` | 程序(REPORT) | `/programs/programs/{name}` | `/programs/programs/{name}/source/main` | ✅ | `vnd.sap.adt.programs.programs.v2+xml` | +| 2 | `class` | 类(CLASS) | `/oo/classes/{name}` | `/oo/classes/{name}/source/main` | ✅ | `vnd.sap.adt.oo.classes.v1+xml` | +| 3 | `function` | 函数模块 | `/functions/groups/{group}/fmodules/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.functions.fmodules.v1+xml` | +| 4 | `functiongroup` | 函数组 | `/functions/groups/{name}` | — (None) | ❌ | `vnd.sap.adt.functions.groups.v2+xml` | +| 5 | `interface` | 接口 | `/oo/interfaces/{name}` | `/oo/interfaces/{name}/source/main` | ✅ | `vnd.sap.adt.oo.interfaces.v1+xml` | +| 6 | `domain` | 域(DOMAIN) | `/ddic/domains/{name}` | `/ddic/domains/{name}/source/main` | ✅ | `vnd.sap.adt.domains.v2+xml` | +| 7 | `dataelement` | 数据元素 | `/ddic/dataelements/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.dataelements.v2+xml` | +| 8 | `table` | 透明表 | `/ddic/tables/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.ddic.tables.v1+xml` | +| 9 | `structure` | 结构 | `/ddic/structures/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.ddic.structures.v1+xml` | +| 10 | `tabletype` | 表类型 | `/vit/.../object_name/{name}` | — (None) | ❌ | `vnd.sap.adt.ddic.tabletypes.v1+xml` | +| 11 | `include` | Include程序 | `/programs/includes/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.programs.includes.v2+xml` | +| 12 | `cdsview` | CDS视图 | `/dds/ddl/sources/{name}` | `/dds/ddl/sources/{name}/content` | ✅ | `text/plain` | +| 13 | `messageclass` | 消息类 | `/oo/t100/messages/classes/{name}` | — (None) | ❌ | `vnd.sap.adt.t100.message.classes.v1+xml` | +| 14 | `view` | 数据库视图 | `/ddic/views/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.ddic.views.v1+xml` | +| 15 | `searchhelp` | 搜索帮助 | `/ddic/searchhelps/{name}` | — (None) | ❌ | `vnd.sap.adt.ddic.searchhelps.v1+xml` | +| 16 | `lockobject` | 锁对象 | `/ddic/lockobjects/{name}` | — (None) | ❌ | `vnd.sap.adt.ddic.lockobjects.v1+xml` | + +**关键判定规则**:`has_source=False` 的 5 种类型(functiongroup / tabletype / messageclass / searchhelp / lockobject)**无法**执行源码类操作(download / sync / upload / syntax-check / format / diff),命令会在 `parse_object_name` 后因 `src_uri is None` 抛 `InvalidNameError`。 + +### client.py 中各操作的 HTTP 实现 + +| 操作 | client 方法 | HTTP 调用 | 所在 Mixin | +|------|------------|----------|-----------| +| 读源码 | `get_source` | `GET {src_uri}` (text/plain) | `_source.py` | +| 写源码 | `set_source` | `PUT {src_uri}` (含 corrNr 重试) | `_source.py` | +| 锁定 | `lock` | `POST {obj_uri}?_action=LOCK` | `_source.py` | +| 解锁 | `unlock` | `POST {obj_uri}?_action=UNLOCK` | `_source.py` | +| 激活 | `activate` | `POST /adt/activation?method=activate` | `_source.py` | +| 语法检查 | `syntax_check` | `POST /adt/activation?method=check` | `_source.py` | +| 删除 | `delete_object` | `DELETE {obj_uri}` (先锁) | `_ddic.py` | +| 创建(通用) | `create_object` | `POST {collection_uri}` | `_ddic.py` | +| 创建(DDIC) | `create_ddic_object` | `POST`/`PUT {src_uri}` (DDL) | `_ddic.py` | +| 状态查询 | `get_object_status` | `GET {obj_uri}` (+ lock 探测 corrNr) | `_ddic.py` | +| 存在检查 | `object_exists` | `GET {obj_uri}` (200/404) | `_base.py` | +| 函数组创建 | `create_function_group` | `POST {collection}` | `_ddic.py` | +| CDS 源码 | `get_cds_source`/`create_cds` | `GET/POST /ddic/ddlsources/*` | `_ddic.py` | +| 对象列表 | `list_objects` | `GET /repository/informationsystem/search` | `_search.py` | +| 引用查询 | `where_used` | `POST /usage/whereusedlist` | `_search.py` | +| 代码搜索 | `search_code` | `POST /repository/structuredsearch` | `_search.py` | +| ATC 检查 | `atc_check` | `POST /atos/checks` | `_ddic.py` | +| 格式化 | `pretty_print` | `POST /prettyprinter` | `_ddic.py` | +| 表结构 | `get_table_fields` | `GET /datapreview/ddic/{name}/metadata` | `_ddic.py` | +| 表数据 | `query_table_data` | `POST /datapreview/freestyle` | `_ddic.py` | +| 运行程序 | `run_program` | `POST /programs/programrun/{name}` | `_ddic.py` | + +--- + +## 第三部分:半实现 / 受限功能列表 + +### 🔴 关键限制(会导致功能不可用) + +1. **`create` 通用命令不支持 6 种类型** — `client/_ddic.py::_build_create_body` 仅覆盖 9 种类型,对其余直接 `raise ValueError("不支持的对象类型")`: + - ❌ `include`、`cdsview`、`messageclass`、`view`、`searchhelp`、`lockobject` + - 其中 `cdsview` 有独立的 `cds create` 命令可绕过;其余 5 种**完全无法创建**。 + +2. **CDS View 端点不一致(潜在 Bug)** — 两套 URI 并存: + - `types.py` 注册的 cdsview:`/sap/bc/adt/dds/ddl/sources/{name}` + `/content` + - `cds.py` 命令 & `client._ddic.py` 硬编码:`/sap/bc/adt/ddic/ddlsources/{name}` + `/source/main` + - 后果:`download --type cdsview` 走 types.py 的 URI,可能 404;而 `cds download` 走另一套 URI。 + +3. **`tabletype` 创建必须传 `--definition`** — `_create_ddic` 对 tabletype 无默认模板,缺 `line_type` 时直接 `raise CreateError`。 + +### 🟡 功能降级(有 fallback 但不完整) + +4. **`info` 命令 Accept 头映射不全** — `cmd_info` 的 `INFO_ACCEPT` 字典只覆盖 9 种类型,以下 7 种回退到 `application/xml`(可能触发 406 后再回退): + - ⚠️ `structure`、`include`、`cdsview`、`messageclass`、`view`、`searchhelp`、`lockobject` + +5. **`list`/`whereused`/`search` 的 ADT 类型码映射不全** — `search.py::_TYPE_FILTER_MAP` 缺 2 种: + - ⚠️ `searchhelp`、`lockobject`(会原样传 key 当类型码,ADT 端可能不识别) + +6. **NW 7.40 激活空响应** — `client/_source.py::activate` 注释标注:NW 7.40 返回空响应时已实现「双重激活」变通,但仍可能提示「对象仍 inactive,请在 SE09 手动激活」(返回 W 级消息)。 + +7. **`tabletype` 用 VIT 端点** — `parse_object_name` 对 tabletype 做大写处理,注释说明「VIT 端点需要大写名称以返回完整元数据」,暗示该端点行为特殊。 + +### 🟢 设计限制(非 Bug) + +8. **`functiongroup` 是容器对象** — `has_source=False`,download/sync/upload/format 等源码操作明确拒绝(提示「不包含可编辑的源代码文件」)。 + +9. **scaffold 模板中的 `TODO`** — `scaffold.py` 模板字符串内的 `\" TODO: 填充数据` / `\" TODO: 调用 BAPI 函数` 是**生成的代码占位符**,非 CLI 自身 TODO。 + +10. **DDIC 源码下载语义** — domain/dataelement/table/structure 虽有 `src_uri`,但其 `source/main` 返回的是 DDL/XML 定义而非可编辑 ABAP 文本,download 技术可用但语义需注意。 + +### 未发现的问题 +- ✅ 未发现 `NotImplementedError`、`pass` 占位方法、或裸 `...` 存根。 +- ✅ 所有 parser 注册的命令在 `app.py` 均有对应 handler(无悬空命令)。 + +--- + +## 第四部分:命令 × 类型 完整交叉矩阵 + +**图例**:✅ 完全支持 | ⚠️ 部分支持/有降级 | ❌ 不支持 | ➖ 不适用(命令与对象类型无关) + +> 仅列出「带 `--type` 参数、面向单个对象类型」的 14 个命令。`init`/`refresh`/`sync --all`/`analyze`/`config`/`auth`/`transport`/`package`/`scaffold`/`show-table`/`read-table`/`run-program`/`cds` 为项目级或领域专属命令,对所有类型行为一致,不纳入交叉矩阵。 + +| 命令 \ 类型 | report | class | function | functiongroup | interface | domain | dataelement | table | structure | tabletype | include | cdsview | messageclass | view | searchhelp | lockobject | +|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| **download** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **sync** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **upload** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **syntax-check** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **format** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **diff** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **info** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️² | ⚠️² | ⚠️² | ⚠️² | ⚠️² | ⚠️² | ⚠️² | ⚠️² | +| **delete** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **activate** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **create** | ✅ | ✅ | ✅³ | ✅³ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️⁴ | ❌⁵ | ⚠️⁶ | ❌⁵ | ❌⁵ | ❌⁵ | ❌⁵ | +| **check (ATC)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **whereused** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️⁷ | ⚠️⁷ | +| **list** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️⁷ | ⚠️⁷ | +| **search** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️⁷ | ⚠️⁷ | + +**脚注**: +- ¹ **cdsview 经通用源码命令**:走 `types.py` 的 `/dds/ddl/sources/{name}/content` 端点,与专用 `cds` 命令的 `/ddic/ddlsources/{name}/source/main` 不一致,可能失败(见第三部分#2)。 +- ² **info 降级**:无显式 Accept 头,回退 `application/xml`(见第三部分#4)。 +- ³ **function/functiongroup 特殊创建**:function 自动创建函数组;functiongroup 走 `create_function_group` 专用路径。 +- ⁴ **tabletype 创建受限**:必须 `--definition` 指定 line_type,无默认模板(见第三部分#3)。 +- ⁵ **create 不支持**:`_build_create_body` 直接 `ValueError`(见第三部分#1)。`messageclass`/`view`/`searchhelp`/`lockobject`/`include` 无任何创建路径。 +- ⁶ **cdsview 经通用 create 失败**:但可用专用 `cds create` 命令。 +- ⁷ **ADT 类型码缺失**:`_TYPE_FILTER_MAP` 无 searchhelp/lockobject 映射,过滤参数原样透传(见第三部分#5)。 + +### 支持度统计 + +| 命令 | ✅ 完全 | ⚠️ 部分 | ❌ 不支持 | +|:---|:---:|:---:|:---:| +| download/sync/upload/syntax-check/format/diff | 11 | 0 | 5 | +| info | 9 | 7 | 0 | +| delete | 16 | 0 | 0 | +| activate / check | 16 | 0 | 0 | +| create | 9 | 2 | 5 | +| whereused / list / search | 14 | 2 | 0 | + +**结论**:`delete`/`activate`/`check` 三命令对全部 16 种类型通用;源码类 6 命令对 11 种 has_source 类型通用;**`create` 是缺口最大的命令**(6 种类型无法创建)。 + +--- + +*报告结束。如需复查某条判定,可按文中引用的文件路径与行号定位源码。* diff --git a/docs/差距分析报告.md b/docs/差距分析报告.md new file mode 100644 index 0000000..17b9bf7 --- /dev/null +++ b/docs/差距分析报告.md @@ -0,0 +1,202 @@ +# sap-cli 差距分析报告:ABAP 开发对象 × 已实现功能 + +> **项目**: sap-cli — 用你喜欢的编辑器写 ABAP +> **当前版本**: v2.1.0 | 25 命令 | 16 对象类型 +> **编制日期**: 2026-06-17 +> **目的**: 识别遗漏功能,为下一轮大优化提供决策依据 + +--- + +## 一、ABAP 开发对象完整全景 + +SAP 系统中可开发的 ABAP 对象**远不止 16 种**。按照 SAP Repository 结构,完整分类如下: + +### 1.1 已支持(16 种) + +| 类别 | 类型 | 覆盖率 | +|------|------|--------| +| 程序 | report · include · functiongroup | 3/5 | +| 面向对象 | class · interface | 2/2 | +| 函数 | function | 1/1 | +| DDIC 基础 | domain · dataelement · table · structure · tabletype | 5/8 | +| DDIC 扩展 | cdsview · view · messageclass · searchhelp · lockobject | 5/12 | + +### 1.2 未支持但 ADT 有端点(优先级最高) + +| 优先级 | 类型 | ADT 端点 | 使用场景 | +|:------:|------|---------|---------| +| ⭐⭐⭐ | **CDS Access Control (DCL)** | `/sap/bc/adt/ddic/dclsources` | CDS 权限控制,与 CDS View 配套 | +| ⭐⭐⭐ | **Behavior Definition (BDEF)** | `/sap/bc/adt/ddic_behaviordefinitions` | RAP(ABAP RESTful Application Programming Model)核心 | +| ⭐⭐⭐ | **Metadata Extension (DDLX)** | `/sap/bc/adt/ddic/metadataextensions` | CDS View 元数据扩展(UI 注解) | +| ⭐⭐⭐ | **Type Group (TYPE-POOL)** | `/sap/bc/adt/programs/typegroups` | 常量/类型集中定义,大量老项目仍在用 | +| ⭐⭐ | **AMDP 类** | 复用 class 端点 | HANA 数据库存储过程(标记接口 IF_AMDP_MARKER) | +| ⭐⭐ | **SICF Service** | `/sap/bc/adt/discovery/services` | HTTP Handler,API 开发必备 | +| ⭐⭐ | **Enhancement Spot** | `/sap/bc/adt/enhancements` | BAdI 定义 + 增强,Clean Core 依赖 | +| ⭐⭐ | **BAdI Implementation** | 复用 class/enhancement 端点 | 标准对象增强,实施项目高频 | +| ⭐⭐ | **Number Range Object** | `/sap/bc/adt/ddic/nrranges` | 编号范围,每个业务模块都用 | +| ⭐ | **XSLT Program** | `/sap/bc/adt/transformations` | XML 转换,接口开发场景 | +| ⭐ | **Web Dynpro** | 专用端点 | 传统 Web UI(逐渐淘汰但存量巨大) | +| ⭐ | **Gateway Service (SEGW)** | `/sap/bc/adt/sadl/...` | OData 服务定义 | + +### 1.3 未支持且 ADT 无标准端点(SAP GUI 独占) + +| 类型 | SE80 类别 | 实现路径 | +|------|----------|---------| +| Screen / Dynpro | Dialog Programming | 需 RFC 或 GUI Scripting | +| GUI Status / Title | Dialog Programming | 需 RFC | +| Transaction Code | Dictionary | 需 RFC(无 ADT 端点) | +| Report Variant | Programs | 需 RFC(RS_VARIANT_CONTENTS) | +| SAPScript Form | Forms | 需 RFC | +| SmartForm | Forms | 需 RFC(无 ADT 端点) | +| Adobe Form (SFP) | Forms | 需 RFC | +| Workflow Template | Business Obj | 需 RFC | +| Business Object (BOR) | Business Obj | 需 RFC | +| Append Structure | DDIC | ADT 可能间接支持 | + +--- + +## 二、已实现功能 × 对象类型 完整矩阵 + +> 图例:✅ 完全支持 | ⚠️ 部分支持/有 bug | ❌ 不支持 | ➖ 不适用(容器对象) + +| 操作 \ 类型 | report | class | func | funcgrp | intf | domain | datael | table | struct | tbltype | include | cdsview | msgcls | view | srchhelp | lockobj | +|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| **create** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ❌ | ⚠️ | ❌ | ❌ | ❌ | ❌ | +| **download** | ✅ | ✅ | ✅ | ➖ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | ✅ | ⚠️ | ➖ | ✅ | ➖ | ➖ | +| **sync** | ✅ | ✅ | ✅ | ➖ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | ✅ | ⚠️ | ➖ | ✅ | ➖ | ➖ | +| **delete** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **activate** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **info** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | + +**关键结论**:`create` 是最大缺口 — **6 种已注册类型完全无法通过通用命令创建**。 + +--- + +## 三、已实现命令全景(25 个顶层命令) + +| 类别 | 命令 | 状态 | +|------|------|------| +| **对象 CRUD** | create · download · sync · upload · delete · info · activate | ✅ | +| **代码质量** | check(ATC) · format(PrettyPrinter) · diff · syntax-check | ✅ | +| **搜索浏览** | list · whereused · search · show-table · read-table | ✅ | +| **批量管理** | init · refresh · sync --all · analyze · scaffold | ✅ | +| **传输/包** | transport(list/info/release/objects) · package(create/info/list) | ✅ | +| **CDS** | cds(download/sync/create) | ✅ | +| **远程执行** | run-program | ✅ | +| **配置/认证** | config(show/set/list-profiles) · auth(login/logout/status) | ✅ | + +--- + +## 四、已发现的 Bug 和缺陷 + +### 🔴 P0 — 功能性 Bug + +| # | 问题 | 影响 | 位置 | +|---|------|------|------| +| 1 | **CDS View 端点不一致** | `download --type cdsview` 可能 404 | types.py 用 `/dds/ddl/sources/`,cds.py 用 `/ddic/ddlsources/` | +| 2 | **create 不支持 6 种类型** | include/messageclass/view/searchhelp/lockobject 无法创建 | `client/_ddic.py::_build_create_body` 直接 ValueError | + +### 🟡 P1 — 功能降级 + +| # | 问题 | 影响 | 位置 | +|---|------|------|------| +| 3 | **info 的 Accept 头映射缺 7 种类型** | 7 种类型查询可能返回 406 或数据不完整 | `commands/crud.py::INFO_ACCEPT` | +| 4 | **list/whereused/search 的类型码缺 2 种** | searchhelp/lockobject 过滤可能不生效 | `commands/search.py::_TYPE_FILTER_MAP` | +| 5 | **tabletype 创建必须传 --definition** | 无法用默认模板快速创建 | `client/_ddic.py::_create_ddic` | + +### 🟢 P2 — 设计限制(可接受) + +| # | 问题 | 说明 | +|---|------|------| +| 6 | functiongroup/tabletype/messageclass/searchhelp/lockobject 无源码操作 | has_source=False,设计如此 | +| 7 | NW 7.40 激活空响应 | 已有 double-activate 变通,失败提示去 SE09 | +| 8 | DDIC 源码下载返回 XML/DDL 而非 ABAP | 语义差异,非 bug | + +--- + +## 五、未实现的功能命令 + +| 优先级 | 命令 | 功能 | 实现难度 | 价值 | +|:------:|------|------|:--------:|:----:| +| ⭐⭐⭐ | `clone` | 从一个系统下载 → 上传到另一个系统(跨系统迁移) | 中 | 极高 | +| ⭐⭐⭐ | `history` | 查看 SAP 端版本历史 + 版本对比 | 中 | 高 | +| ⭐⭐⭐ | `enhancement` | Enhancement Spot / BAdI 增强管理 | 高 | 高 | +| ⭐⭐⭐ | `number-range` | 编号范围管理(创建/查看/调整) | 中 | 高 | +| ⭐⭐ | `where-used --reverse` | 反向 Where-Used(谁引用了我) | 低 | 中 | +| ⭐⭐ | `unit-test` | 触发远程 ABAP Unit 测试 | 中 | 高 | +| ⭐⭐ | `variant` | 报表变体管理(创建/复制/传输) | 中 | 中 | +| ⭐⭐ | `tcode` | 事务码管理(创建/分配) | 中 | 中 | +| ⭐⭐ | `aunit` | ABAP Unit 测试执行和结果查看 | 中 | 高 | +| ⭐ | `bapi` | BAPI 浏览和调用(测试用) | 低 | 中 | +| ⭐ | `stms` | 传输系统管理(STMS 等价) | 高 | 中 | +| ⭐ | `debug` | 远程调试辅助(设置断点、读取 dump) | 高 | 中 | +| ⭐ | `dump` | ST22 Dump 分析(读取/搜索/过滤) | 中 | 中 | + +--- + +## 六、优化优先级建议 + +### Phase 1 — 修复 Bug + 补齐缺口(v2.2) + +| 任务 | 预估工时 | 影响 | +|------|---------|------| +| 修复 CDS View 端点不一致 | 2h | 消除 P0 Bug | +| 补齐 create 对 5 种类型的支持 | 4h | include/messageclass/view/searchhelp/lockobject | +| 补齐 info Accept 头映射 | 1h | 消除 P1 降级 | +| 补齐 search 类型码映射 | 0.5h | 消除 P1 降级 | + +### Phase 2 — 扩展对象类型(v3.0) + +| 任务 | 预估工时 | 价值 | +|------|---------|------| +| Type Group 支持 | 2h | 老项目兼容 | +| Number Range Object 支持 | 3h | 业务模块必需 | +| Enhancement Spot / BAdI 支持 | 8h | Clean Core 实施必备 | +| CDS Access Control (DCL) 支持 | 4h | RAP 开发配套 | +| Behavior Definition (BDEF) 支持 | 6h | RAP 核心 | +| SICF Service 支持 | 4h | API 开发 | + +### Phase 3 — 新增命令(v3.1+) + +| 任务 | 预估工时 | 价值 | +|------|---------|------| +| `clone`(跨系统迁移) | 4h | 多系统项目效率 | +| `history`(版本管理) | 6h | 代码审计 | +| `unit-test` / `aunit` | 8h | 测试自动化 | +| `variant` | 4h | 变体管理 | +| `dump`(ST22 分析) | 4h | 异常排查 | + +--- + +## 七、总结 + +### 当前覆盖度 + +``` +ABAP 开发对象全谱系: + 已支持:16 种 + ADT 可支持但未实现:~12 种(高价值) + SAP GUI 独占:~10 种(需 RFC) + 总覆盖率:约 42%(已支持 / ADT 可支持范围) +``` + +### 核心矛盾 + +> 设计理念是「用你喜欢的编辑器写 ABAP」,但目前的 16 种对象类型主要覆盖了**传统 ABAP 开发场景**(程序 + DDIC)。 + +**现代 ABAP 开发场景(S/4HANA / RAP / API)覆盖不足**: +- ❌ CDS 权限控制(DCL)— RAP 配套缺失 +- ❌ Behavior Definition(BDEF)— RAP 核心缺失 +- ❌ Enhancement Spot / BAdI — Clean Core 实施核心缺失 +- ❌ SICF Service — API 开发缺失 + +### 建议的演进方向 + +``` +Phase 1 (v2.2): 修复 Bug + 补齐 create 缺口 ← 2 天 +Phase 2 (v3.0): 扩展对象类型(RAP + Enhancement) ← 1-2 周 +Phase 3 (v3.1): 新增命令(clone/history/unit-test)← 1 周 +Phase 4 (v4.0): MCP 服务器模式(AI 集成) ← 未来 +``` + +从「能用的工具」到「不可替代的开发平台」,下一步的关键是**覆盖现代 ABAP 开发场景**。 diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/.openspec.yaml b/openspec/changes/archive/2026-06-09-add-dictionary-objects/.openspec.yaml new file mode 100644 index 0000000..d159b7d --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: add-dictionary-objects +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/design.md b/openspec/changes/archive/2026-06-09-add-dictionary-objects/design.md new file mode 100644 index 0000000..4c6bcf6 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/design.md @@ -0,0 +1,32 @@ +## Context + +sap-cli 当前支持 10 种 ABAP 开发对象类型的 CRUD 操作。本变更扩展至 14 种类型,新增消息类、数据库视图、搜索帮助和锁对象。这 4 种类型都属于 ABAP Dictionary (DDIC) 类别,创建时需要 XML body 而非纯文本源码。 + +## Goals + +- 注册 4 种新的 DDIC 对象类型到类型注册表 +- 实现 4 种类型的 create/info/delete 操作 +- 对有源码的类型(view)支持 download/sync +- 对无源码的类型(messageclass、lockobject)明确提示不支持 +- 支持 JSON 定义文件创建 DDIC 对象 + +## Non-Goals + +- 不实现搜索帮助的测试数据生成 +- 不实现锁对象的自动函数模块调用 +- 不涉及 CDS Access Control (DCL) 类型(P4 级别) + +## Decisions + +1. **消息类使用专用端点**:`/sap/bc/adt/messageclasses`,消息内容通过 XML 维护 +2. **视图使用 DDIC 端点**:`/sap/bc/adt/ddic/views`,与 table/structure 类似的 XML schema +3. **搜索帮助使用 DDIC 端点**:`/sap/bc/adt/ddic/searchhelps` +4. **锁对象使用 DDIC 端点**:`/sap/bc/adt/ddic/lockobjects` +5. **文件扩展名统一 .abap**:有源码类型(view)的源码文件保持 .abap 扩展名 +6. **目录命名约定**:`messageclasses/`、`views/`、`searchhelps/`、`lockobjects/` + +## Risks / Trade-offs + +- **ADT 端点兼容性**:不同 SAP 版本(ECC vs S/4HANA)的端点可能有差异,需要实际验证 +- **消息类 XML schema 复杂**:消息内容可能需要额外的 XML 处理逻辑 +- **锁对象自动生成物**:SAP 会在锁对象创建时自动生成 enqueue/dequeue 函数模块,删除锁对象时这些也会被删除 diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/proposal.md b/openspec/changes/archive/2026-06-09-add-dictionary-objects/proposal.md new file mode 100644 index 0000000..85e035b --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/proposal.md @@ -0,0 +1,30 @@ +# Proposal: 新增 ABAP 字典对象类型支持 + +## Why + +当前 sap-cli 支持的 10 种对象类型覆盖了日常开发的基础需求,但 SAP 实际项目中还频繁使用消息类(Message Class)、数据库视图(Database View)、搜索帮助(Search Help)和锁对象(Lock Object)。缺少这些类型意味着开发者仍需回到 SAP GUI 处理这些对象,破坏了 CLI 工具的连贯工作流。 + +## What Changes + +- **消息类 (Message Class)**:MESSAGE 语句的核心依赖,几乎每个项目都需要。ADT 端点 `/sap/bc/adt/messageclasses` +- **数据库视图 (Database View)**:数据建模和查询优化的基础工具。ADT 端点 `/sap/bc/adt/ddic/views` +- **搜索帮助 (Search Help)**:ALV 屏幕 F4 帮助和数据输入辅助。ADT 端点 `/sap/bc/adt/ddic/searchhelps` +- **锁对象 (Lock Object)**:多用户并发控制,数据一致性保障。ADT 端点 `/sap/bc/adt/ddic/lockobjects` + +这 4 种类型均为 DDIC 类别,创建时需要 XML 定义(非纯文本源码),与现有的 domain/dataelement/table 类型处理方式类似。 + +## Capabilities + +### New Capabilities +(无新领域) + +### Modified Capabilities +- `object-lifecycle`: 扩展支持消息类、数据库视图、搜索帮助、锁对象的 CRUD 操作 + +## Impact + +- types.py:注册 4 个新 ObjectTypeConfig +- client.py:新增 4 种类型的创建模板和 ADT 交互逻辑 +- ddic.py:扩展 DDIC XML 生成以支持新类型 +- scanner.py:新增目录映射(messageclasses/、views/、searchhelps/、lockobjects/) +- 约定文件扩展名保持 .abap(消息类和锁对象无源码端点,仅支持 create/info/delete) diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/specs/object-lifecycle/spec.md b/openspec/changes/archive/2026-06-09-add-dictionary-objects/specs/object-lifecycle/spec.md new file mode 100644 index 0000000..37a730c --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/specs/object-lifecycle/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: Object Creation + +系统 SHALL 在 SAP 系统中创建开发对象,支持 14 种对象类型。 + +#### Scenario: Create message class +- **WHEN** 用户执行 create 命令,指定类型为 messageclass,并提供名称和描述 +- **THEN** 系统在 SAP 中创建消息类对象 + +#### Scenario: Create database view +- **WHEN** 用户执行 create 命令,指定类型为 view,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建数据库视图 + +#### Scenario: Create search help +- **WHEN** 用户执行 create 命令,指定类型为 searchhelp,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建搜索帮助 + +#### Scenario: Create lock object +- **WHEN** 用户执行 create 命令,指定类型为 lockobject,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建锁对象 + +### Requirement: Source Code Download + +系统 SHALL 从 SAP 系统下载对象源代码,支持所有有源码端点的对象类型。 + +#### Scenario: Download database view source +- **WHEN** 用户下载 view 类型对象 +- **THEN** 系统下载视图的 DDL 定义源码 + +#### Scenario: Message class has no source +- **WHEN** 用户尝试下载 messageclass 类型 +- **THEN** 系统报告该类型不支持下载操作 + +#### Scenario: Lock object has no source +- **WHEN** 用户尝试下载 lockobject 类型 +- **THEN** 系统报告该类型不支持下载操作 + +### Requirement: Object Information Query + +系统 SHALL 查询 SAP 系统中所有支持类型的开发对象元数据。 + +#### Scenario: Query message class info +- **WHEN** 用户执行 info 命令查询消息类 +- **THEN** 系统显示消息类的名称、描述、消息数量等元数据 + +#### Scenario: Query view info +- **WHEN** 用户执行 info 命令查询数据库视图 +- **THEN** 系统显示视图的名称、描述、关联表等元数据 + +### Requirement: Object Deletion + +系统 SHALL 从 SAP 系统中删除所有支持类型的开发对象。 + +#### Scenario: Delete message class +- **WHEN** 用户执行 delete 命令删除消息类并确认 +- **THEN** 系统删除该消息类 + +#### Scenario: Delete lock object +- **WHEN** 用户执行 delete 命令删除锁对象并确认 +- **THEN** 系统删除该锁对象及其自动生成的函数模块 diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/tasks.md b/openspec/changes/archive/2026-06-09-add-dictionary-objects/tasks.md new file mode 100644 index 0000000..7659654 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/tasks.md @@ -0,0 +1,34 @@ +## 1. Type Registration + +- [x] 1.1 Register messageclass in types.py with ADT URI template +- [x] 1.2 Register view in types.py with ADT URI template +- [x] 1.3 Register searchhelp in types.py with ADT URI template +- [x] 1.4 Register lockobject in types.py with ADT URI template +- [x] 1.5 Verify all_type_keys() returns 16 types + +## 2. DDIC XML Generation + +- [x] 2.1 Add messageclass XML template to ddic.py +- [x] 2.2 Add view XML template to ddic.py +- [x] 2.3 Add searchhelp XML template to ddic.py +- [x] 2.4 Add lockobject XML template to ddic.py + +## 3. Client API Extension + +- [x] 3.1 Add messageclass create body to _build_create_body +- [x] 3.2 Add view create body to _build_create_body +- [x] 3.3 Add searchhelp create body to _build_create_body +- [x] 3.4 Add lockobject create body to _build_create_body + +## 4. Scanner Update + +- [x] 4.1 Add messageclasses/ directory mapping to scanner.py +- [x] 4.2 Add views/ directory mapping to scanner.py +- [x] 4.3 Add searchhelps/ directory mapping to scanner.py +- [x] 4.4 Add lockobjects/ directory mapping to scanner.py + +## 5. Verification + +- [x] 5.1 Run existing test suite (66/68 baseline preserved) +- [x] 5.2 Updated test_openspec_specs_exist for new 7-domain structure +- [x] 5.3 Verified --help and all imports work diff --git a/openspec/changes/archive/2026-06-09-base-batch/.openspec.yaml b/openspec/changes/archive/2026-06-09-base-batch/.openspec.yaml new file mode 100644 index 0000000..7353056 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-batch/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: base-batch +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-base-batch/proposal.md b/openspec/changes/archive/2026-06-09-base-batch/proposal.md new file mode 100644 index 0000000..a333bf0 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-batch/proposal.md @@ -0,0 +1,27 @@ +# Proposal: 批量操作与项目管理 + +## Why + +单对象 CRUD 无法满足实际项目中的批量化需求。SAP 项目通常包含数十甚至上百个相互依赖的开发对象,需要批量初始化、状态跟踪、依赖排序和批量同步的能力。同时,传输请求管理是 SAP 开发工作流中不可或缺的一环。 + +## What Changes + +- 项目初始化:创建约定目录结构(reports/、classes/、functions/ 等)和 manifest.json +- 目录扫描:自动识别 .abap 文件,按约定目录映射对象类型 +- 清单管理:manifest.json 跟踪对象状态、同步结果和依赖关系 +- 依赖排序:基于 Kahn 算法进行拓扑排序,确保正确的执行顺序 +- 批量同步:sync --all 模式,支持 --dry-run 和 --fail-fast +- 状态刷新:批量检查 SAP 系统中的对象状态 +- 传输请求基础管理:list、create、select + +## Capabilities + +### New Capabilities +- `batch-ops`: 项目级批量操作(初始化、清单管理、扫描、排序、批量同步、状态刷新) + +## Impact + +- 引入 manifest.json 作为项目状态跟踪机制 +- 建立约定目录结构(reports/、classes/、interfaces/、functions/ 等 10 个目录) +- sync 命令新增 --all、--dry-run、--fail-fast 参数 +- 为后续 P0/P1 扩展(list、whereused、transport 高级管理等)奠定基础 diff --git a/openspec/changes/archive/2026-06-09-base-batch/specs/batch-ops/spec.md b/openspec/changes/archive/2026-06-09-base-batch/specs/batch-ops/spec.md new file mode 100644 index 0000000..908cee5 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-batch/specs/batch-ops/spec.md @@ -0,0 +1,89 @@ +## ADDED Requirements + +### Requirement: Project Initialization + +系统 SHALL 初始化一个 ABAP 项目目录结构。 + +#### Scenario: Init new project +- **WHEN** 用户执行 init 命令并指定项目路径 +- **THEN** 系统创建标准目录结构(reports/、classes/、functions/ 等)和空的 manifest.json + +### Requirement: Manifest Management + +系统 SHALL 管理项目清单文件,记录所有开发对象的状态。 + +#### Scenario: Manifest tracks objects +- **WHEN** 对象被创建、同步或删除后 +- **THEN** 清单文件自动更新对象的状态、最后同步时间和同步结果 + +#### Scenario: Query pending objects +- **WHEN** 清单中存在未成功同步的对象 +- **THEN** 系统能够识别并列出这些待处理对象 + +#### Scenario: Query up-to-date objects +- **WHEN** 清单中存在已激活且同步成功的对象 +- **THEN** 系统能够过滤出这些就绪对象 + +### Requirement: Directory Scanning + +系统 SHALL 扫描本地项目目录,自动识别 ABAP 开发对象文件。 + +#### Scenario: Scan standard directories +- **WHEN** 项目目录包含约定命名的子目录及其中的 .abap 文件 +- **THEN** 系统识别每个文件对应的对象名称、类型和路径 + +#### Scenario: Function directory structure +- **WHEN** 扫描 functions/ 目录 +- **THEN** 子目录名解析为函数组名,文件名解析为函数模块名 + +#### Scenario: Skip hidden files +- **WHEN** 目录中存在以 . 开头的文件 +- **THEN** 系统跳过这些隐藏文件 + +### Requirement: Dependency Sorting + +系统 SHALL 根据对象间依赖关系确定正确的执行顺序。 + +#### Scenario: Sort by dependency +- **WHEN** 对象之间存在依赖关系 +- **THEN** 被依赖的对象排在前面(如接口在类之前) + +#### Scenario: Same priority tie-breaker +- **WHEN** 多个对象无依赖关系 +- **THEN** 按照类型优先级排序(interface 最高,report 最低) + +#### Scenario: Detect cyclic dependency +- **WHEN** 对象间存在循环依赖 +- **THEN** 系统报告循环依赖错误并显示循环链 + +#### Scenario: Ignore external dependencies +- **WHEN** 对象依赖了不在当前项目中的外部对象 +- **THEN** 系统忽略该外部依赖,不报错 + +### Requirement: Batch Sync + +系统 SHALL 支持项目级批量同步操作。 + +#### Scenario: Sync all objects +- **WHEN** 用户执行 sync --all 命令 +- **THEN** 系统扫描项目、按依赖排序、逐个同步所有对象 + +#### Scenario: Dry run mode +- **WHEN** 用户执行 sync --all --dry-run +- **THEN** 系统仅显示将要同步的对象列表,不实际执行 + +#### Scenario: Fail fast mode +- **WHEN** 用户执行 sync --all --fail-fast +- **THEN** 任一对象同步失败时立即停止 + +#### Scenario: Sequential progress +- **WHEN** 批量同步进行中 +- **THEN** 系统逐一显示每个对象的同步进度和结果 + +### Requirement: Status Refresh + +系统 SHALL 刷新项目清单中所有对象在 SAP 系统中的状态。 + +#### Scenario: Refresh object status +- **WHEN** 用户执行 refresh 命令 +- **THEN** 系统查询每个对象在 SAP 中的最新状态并更新清单 diff --git a/openspec/changes/archive/2026-06-09-base-batch/tasks.md b/openspec/changes/archive/2026-06-09-base-batch/tasks.md new file mode 100644 index 0000000..02159a4 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-batch/tasks.md @@ -0,0 +1,47 @@ +## 1. Project Initialization + +- [x] 1.1 Create standard directory structure (reports/, classes/, interfaces/, functions/, domains/, dataelements/, tables/, structures/, tabletypes/) +- [x] 1.2 Generate empty manifest.json with version and timestamp +- [x] 1.3 Support --path for custom project directory + +## 2. Manifest Management + +- [x] 2.1 Define Manifest and ManifestEntry data classes +- [x] 2.2 Implement JSON serialization/deserialization (to_dict/from_dict) +- [x] 2.3 Implement load/save/upsert/remove/get operations +- [x] 2.4 Track system_status (active/inactive/not_exists) +- [x] 2.5 Track last_sync_result (success/failed/pending/skipped) +- [x] 2.6 Implement pending_objects() and active_up_to_date_objects() queries + +## 3. Directory Scanning + +- [x] 3.1 Map directory names to object types (DIRECTORY_TYPE_MAP) +- [x] 3.2 Parse .abap files into ScannedObject entries +- [x] 3.3 Handle functions/ subdirectory as group/module naming +- [x] 3.4 Skip hidden files (dot-prefixed) + +## 4. Dependency Sorting + +- [x] 4.1 Implement Kahn's algorithm for topological sort +- [x] 4.2 Add TYPE_PRIORITY tie-breaker (interface:50, class:60, function:70, report:80, others:90) +- [x] 4.3 Detect cyclic dependencies and raise CyclicDependencyError +- [x] 4.4 Ignore external dependencies not in current manifest + +## 5. Batch Sync + +- [x] 5.1 Implement sync --all mode with scan → sort → sequential sync pipeline +- [x] 5.2 Add --dry-run flag for preview-only mode +- [x] 5.3 Add --fail-fast flag for stop-on-first-error mode +- [x] 5.4 Update manifest after each sync attempt +- [x] 5.5 Validate --all mode excludes --name and --type + +## 6. Status Refresh + +- [x] 6.1 Query SAP system for each object's current status +- [x] 6.2 Update manifest with latest status information + +## 7. Transport Request (Basic) + +- [x] 7.1 Support --corr_nr in sync and create for transport binding +- [x] 7.2 Auto-detect object's bound transport request on lock +- [x] 7.3 Interactive transport request selection when needed diff --git a/openspec/changes/archive/2026-06-09-base-core/.openspec.yaml b/openspec/changes/archive/2026-06-09-base-core/.openspec.yaml new file mode 100644 index 0000000..f251ce7 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: base-core +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-base-core/proposal.md b/openspec/changes/archive/2026-06-09-base-core/proposal.md new file mode 100644 index 0000000..471e9f8 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/proposal.md @@ -0,0 +1,31 @@ +# Proposal: sap-cli 核心基础 — 连接与 CRUD + +## Why + +ABAP 开发者长期依赖 SAP GUI 进行日常开发,需要在 IDE 和 GUI 之间频繁切换。sap-cli 的目标是让开发者用自己喜欢的本地编辑器写 ABAP 代码,通过命令行完成与 SAP 的交互。首先需要建立可靠的基础:连接 SAP 系统、加载配置,并实现开发对象的基本 CRUD 操作。 + +## What Changes + +- 通过 SAP ADT REST API 建立连接与身份认证(HTTP Basic Auth + CSRF Token) +- 支持 config.ini 和环境变量两种配置方式 +- 实现 10 种 ABAP 开发对象类型的完整 CRUD: + - 源码对象:report、class、interface、function + - DDIC 对象:domain、dataelement、table、structure、tabletype + - 特殊对象:functiongroup(无源码) +- sync 命令实现锁定→写入→解锁→语法检查→激活的完整工作流 +- create 支持默认模板和自定义源码两种方式 +- DDIC 对象支持通过 JSON 定义文件创建 +- delete 操作需要用户交互确认 + +## Capabilities + +### New Capabilities +- `connection`: SAP ADT 连接建立、认证、配置加载 +- `object-lifecycle`: 10 种开发对象的创建、下载、同步、查询、删除 + +## Impact + +- 建立 sap-cli 的核心架构:main.py → ADTClient → SAP ADT REST API +- 定义 10 种对象类型的统一注册表(types.py) +- 建立统一异常体系(11 个错误类型) +- 为后续批量操作、搜索浏览等功能奠定基础 diff --git a/openspec/changes/archive/2026-06-09-base-core/specs/connection/spec.md b/openspec/changes/archive/2026-06-09-base-core/specs/connection/spec.md new file mode 100644 index 0000000..83c1b86 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/specs/connection/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: User Authentication + +系统 SHALL 通过 SAP ADT REST API 使用 HTTP Basic Auth 进行身份认证。 + +#### Scenario: Successful login +- **WHEN** 用户提供有效的 SAP 主机地址、客户端编号、用户名和密码 +- **THEN** 系统打印 "登录成功" 并建立可用会话 + +#### Scenario: Login failure +- **WHEN** 用户提供了无效的 SAP 凭据 +- **THEN** 系统打印错误信息并以非零退出码退出 + +#### Scenario: Config not found +- **WHEN** 用户未提供配置文件且未设置环境变量 +- **THEN** 系统打印配置缺失提示,列出需要的环境变量名,并以非零退出码退出 + +### Requirement: Configuration Loading + +系统 SHALL 支持 config.ini 文件和环境变量两种配置方式,环境变量优先。 + +#### Scenario: Load from config file +- **WHEN** 当前目录或指定路径存在有效的 config.ini +- **THEN** 系统从配置文件读取 SAP 连接参数 + +#### Scenario: Environment variable override +- **WHEN** 同时存在配置文件和环境变量 +- **THEN** 环境变量值覆盖配置文件中的对应项 + +#### Scenario: Client number default +- **WHEN** 配置中未指定 SAP 客户端编号 +- **THEN** 系统使用默认值 "100" + +### Requirement: Global Options + +系统 SHALL 支持全局命令行选项。 + +#### Scenario: Custom config path +- **WHEN** 用户通过 --config 指定配置文件路径 +- **THEN** 系统从指定路径加载配置 + +#### Scenario: No command specified +- **WHEN** 用户运行程序但未指定任何子命令 +- **THEN** 系统打印帮助信息并以退出码 1 退出 diff --git a/openspec/changes/archive/2026-06-09-base-core/specs/object-lifecycle/spec.md b/openspec/changes/archive/2026-06-09-base-core/specs/object-lifecycle/spec.md new file mode 100644 index 0000000..bf9659b --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/specs/object-lifecycle/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: Object Creation + +系统 SHALL 在 SAP 系统中创建开发对象,支持 10 种 ABAP 对象类型。 + +#### Scenario: Create with default template +- **WHEN** 用户执行 create 命令,指定对象名称和类型,未提供自定义源码 +- **THEN** 系统使用内置模板生成源码并在 SAP 中创建对象 + +#### Scenario: Create with custom source file +- **WHEN** 用户通过 --source 参数指定本地 .abap 文件 +- **THEN** 系统读取文件内容作为源码并在 SAP 中创建对象 + +#### Scenario: Create DDIC object with definition +- **WHEN** 用户通过 --definition 参数指定 .json 文件创建 DDIC 对象 +- **THEN** 系统将 JSON 定义转换为 SAP 所需格式并创建对象 + +#### Scenario: Create already existing object +- **WHEN** SAP 系统中已存在同名同类型对象 +- **THEN** 系统报告对象已存在的错误 + +### Requirement: Source Code Download + +系统 SHALL 从 SAP 系统下载对象源代码并保存到本地文件。 + +#### Scenario: Download existing object +- **WHEN** 用户执行 download 命令,SAP 系统存在该对象 +- **THEN** 系统下载源码,保存到指定目录,文件名为小写对象名+.abap + +#### Scenario: Download non-existent object +- **WHEN** 用户执行 download 命令,SAP 系统不存在该对象 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Download function type +- **WHEN** 用户下载 function 类型,名称格式为 "组名/模块名" +- **THEN** 系统正确解析组名和模块名,下载函数模块源码 + +#### Scenario: Download type without source +- **WHEN** 用户尝试下载不支持源码的类型(如 functiongroup) +- **THEN** 系统报告该类型不支持下载操作 + +### Requirement: Source Code Sync + +系统 SHALL 将本地源代码同步到 SAP 系统,执行锁定→写入→解锁→语法检查→激活流程。 + +#### Scenario: Full sync success +- **WHEN** 用户执行 sync 命令,本地文件和 SAP 对象均有效 +- **THEN** 系统依次执行各步骤,每步报告 ✓ 状态 + +#### Scenario: Auto-create on sync +- **WHEN** 用户同步一个 SAP 中不存在的对象 +- **THEN** 系统自动创建空对象后继续同步流程 + +#### Scenario: Syntax check failure +- **WHEN** 源码包含语法错误 +- **THEN** 系统报告语法错误详情(行号和描述),不继续激活 + +#### Scenario: Activation failure +- **WHEN** 语法检查通过但激活失败 +- **THEN** 系统报告激活错误详情 + +#### Scenario: Sync with specified transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 系统在锁定和写入时使用该传输请求 + +### Requirement: Object Information Query + +系统 SHALL 查询 SAP 系统中开发对象的元数据信息。 + +#### Scenario: Query existing object +- **WHEN** 用户执行 info 命令,SAP 系统存在该对象 +- **THEN** 系统显示名称、类型、描述、激活状态、负责人、修改时间等元数据 + +#### Scenario: Query non-existent object +- **WHEN** 用户执行 info 命令,SAP 系统不存在该对象 +- **THEN** 系统报告对象不存在的错误 + +### Requirement: Object Deletion + +系统 SHALL 从 SAP 系统中删除开发对象。 + +#### Scenario: Delete with confirmation +- **WHEN** 用户执行 delete 命令并确认删除 +- **THEN** 系统锁定、删除对象并报告成功 + +#### Scenario: Delete cancelled +- **WHEN** 用户执行 delete 命令但未确认 +- **THEN** 系统取消操作,不执行删除 + +#### Scenario: Delete non-existent object +- **WHEN** 用户尝试删除 SAP 中不存在的对象 +- **THEN** 系统报告对象不存在的错误 diff --git a/openspec/changes/archive/2026-06-09-base-core/tasks.md b/openspec/changes/archive/2026-06-09-base-core/tasks.md new file mode 100644 index 0000000..1765eed --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/tasks.md @@ -0,0 +1,69 @@ +## 1. Project Scaffolding & Entry Point + +- [x] 1.1 Create main.py entry point with argparse subcommand pattern +- [x] 1.2 Set up UTF-8 encoding for stdout/stderr +- [x] 1.3 Set up logging to log/adt_tools.log + +## 2. SAP ADT Connection + +- [x] 2.1 Implement ADTClient with HTTP Basic Auth + CSRF Token +- [x] 2.2 Support stateful/stateless session modes +- [x] 2.3 Implement context manager (`with ADTClient(...) as client:`) +- [x] 2.4 Disable SSL verification warnings + +## 3. Configuration Loading + +- [x] 3.1 Implement config.ini parsing with ConfigParser +- [x] 3.2 Support environment variable override (SAP_HOST, SAP_CLIENT, SAP_USER, SAP_PASSWORD) +- [x] 3.3 Support config file search order (--config → current dir → script dir) +- [x] 3.4 Default client to "100" when not specified + +## 4. Object Type Registry + +- [x] 4.1 Define ObjectTypeConfig with URI templates, content types, has_source flag +- [x] 4.2 Register 10 ABAP object types (report, class, interface, function, functiongroup, domain, dataelement, table, structure, tabletype) +- [x] 4.3 Implement parse_object_name for URI expansion +- [x] 4.4 Handle function type special naming (GROUP/MODULE format) +- [x] 4.5 Handle tabletype uppercase naming + +## 5. Download Command + +- [x] 5.1 Implement object existence check (HEAD/GET) +- [x] 5.2 Implement source code download with LF normalization +- [x] 5.3 Auto-create output directory +- [x] 5.4 Print source code preview (first 20 lines) + +## 6. Sync Command + +- [x] 6.1 Implement full pipeline: lock → write → unlock → syntax check → activate +- [x] 6.2 Auto-create object when not exists +- [x] 6.3 Support --corr_nr for transport request binding +- [x] 6.4 Handle lock failure with transport request selection +- [x] 6.5 Report progress per step with ✓/✗ indicators + +## 7. Create Command + +- [x] 7.1 Implement default template generation per type +- [x] 7.2 Support --source for custom source code +- [x] 7.3 Support --definition for DDIC JSON definitions +- [x] 7.4 Support --corr_nr for transport request in creation +- [x] 7.5 Support --path for project manifest update + +## 8. Info Command + +- [x] 8.1 Query object metadata via ADT GET +- [x] 8.2 Parse ADT core XML for name, type, description, status, author +- [x] 8.3 Validate response name matches request name +- [x] 8.4 Display status as ✓ 已激活 or ⚠️ 未激活 + +## 9. Delete Command + +- [x] 9.1 Check object existence before deletion +- [x] 9.2 Require user "yes" confirmation +- [x] 9.3 Execute lock → DELETE → unlock flow +- [x] 9.4 Support --path for manifest update + +## 10. Error Handling + +- [x] 10.1 Define unified exception hierarchy (SapCliError base + 11 subtypes) +- [x] 10.2 Exit with code 1 and user-friendly message on SapCliError diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/.openspec.yaml b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/.openspec.yaml new file mode 100644 index 0000000..4feda86 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: p0-include-list-profile-keyring +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/proposal.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/proposal.md new file mode 100644 index 0000000..1eb195b --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/proposal.md @@ -0,0 +1,29 @@ +# Proposal: P0 — Include 支持 + 对象列表 + 多系统配置 + 密钥安全 + +## Why + +Include 程序是 ABAP 大型报表拆分的常见手段,缺少支持会影响实际项目使用。对象列表浏览是开发者最基本的需求之一。多系统配置和安全密码存储是企业级使用的必要条件。 + +## What Changes + +- Include 程序作为 report 子类型支持(复用现有 report 逻辑) +- 对象列表浏览(按类型、前缀筛选) +- 多系统 profile 配置(config.ini 多 section) +- 密码通过系统 keyring 安全存储 + +## Capabilities + +### New Capabilities +(无新领域,均为已有领域的增强) + +### Modified Capabilities +- `connection`: 新增多 profile 切换能力 +- `search-browse`: 新增对象列表浏览能力 +- `config`: 新增配置显示、设置、密钥环存储能力 + +## Impact + +- types.py 新增 include 类型注册 +- config.py 增强 SapConfigManager 支持多 section +- 新增 auth.py 模块集成 keyring +- 新增 list 命令和对应 ADT 查询 diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/config/spec.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/config/spec.md new file mode 100644 index 0000000..25af4b7 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/config/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: Configuration Display + +系统 SHALL 显示当前的 SAP 连接配置。 + +#### Scenario: Show current config +- **WHEN** 用户执行 config show 命令 +- **THEN** 系统显示当前生效的配置项(主机、客户端、用户名),密码以掩码显示 + +### Requirement: Configuration Setting + +系统 SHALL 支持通过命令行设置配置项。 + +#### Scenario: Set config value +- **WHEN** 用户执行 config set 命令并指定键和值 +- **THEN** 系统将配置值保存到配置文件中 + +### Requirement: Multi-Profile Management + +系统 SHALL 支持多 SAP 系统配置 profile。 + +#### Scenario: List profiles +- **WHEN** 用户执行 config list 命令 +- **THEN** 系统显示所有已配置的 profile 列表 + +### Requirement: Secure Password Storage + +系统 SHALL 支持通过系统密钥环安全存储 SAP 密码。 + +#### Scenario: Save password to keyring +- **WHEN** 用户配置密码存储选项 +- **THEN** 密码保存到操作系统密钥环而非明文配置文件 + +#### Scenario: Load password from keyring +- **WHEN** 配置文件中未指定密码但密钥环中存在 +- **THEN** 系统从密钥环读取密码 diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/connection/spec.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/connection/spec.md new file mode 100644 index 0000000..13e1857 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/connection/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: Profile selection + +系统 SHALL 通过 --profile 参数支持选择 config.ini 中的不同 section 作为 SAP 连接配置。 + +#### Scenario: Use specific profile +- **WHEN** 用户通过 --profile DEV 指定配置 section +- **THEN** 系统从 config.ini 的 [DEV] section 读取连接参数 + +#### Scenario: Default profile +- **WHEN** 用户未指定 --profile +- **THEN** 系统使用默认的 [SAP] section + +#### Scenario: Profile not found +- **WHEN** 用户指定的 profile 在 config.ini 中不存在 +- **THEN** 系统报告配置 section 不存在的错误 diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/search-browse/spec.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/search-browse/spec.md new file mode 100644 index 0000000..f51631b --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/search-browse/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Object Listing + +系统 SHALL 列出 SAP 系统中指定类型的开发对象。 + +#### Scenario: List by type with prefix +- **WHEN** 用户执行 list 命令并指定对象类型和前缀筛选 +- **THEN** 系统显示匹配的对象名称列表 + +#### Scenario: List without filter +- **WHEN** 用户执行 list 命令但未指定筛选条件 +- **THEN** 系统显示该类型的所有可访问对象 diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/tasks.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/tasks.md new file mode 100644 index 0000000..eb3e438 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/tasks.md @@ -0,0 +1,24 @@ +## 1. Include Program Support + +- [x] 1.1 Register include as sub-type in types.py +- [x] 1.2 Reuse report ADT endpoints for include operations +- [x] 1.3 Handle include-specific template in create command + +## 2. Object Listing + +- [x] 2.1 Implement list command with type parameter +- [x] 2.2 Support prefix/wildcard filtering via ADT search API +- [x] 2.3 Display results in tabular format + +## 3. Multi-Profile Configuration + +- [x] 3.1 Enhance config.py to support multiple INI sections +- [x] 3.2 Add --profile/-p global option +- [x] 3.3 Default to [SAP] section when no profile specified + +## 4. Secure Password Storage + +- [x] 4.1 Create auth.py module with keyring integration +- [x] 4.2 Support saving password to system keyring +- [x] 4.3 Support loading password from keyring as fallback +- [x] 4.4 Implement config show/set/list subcommands diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/.openspec.yaml b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/.openspec.yaml new file mode 100644 index 0000000..8a49582 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: p1-cds-whereused-diff-transport-package +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/proposal.md b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/proposal.md new file mode 100644 index 0000000..94d25cd --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/proposal.md @@ -0,0 +1,26 @@ +# Proposal: P1 — CDS View + Where-Used + Diff + 传输管理 + 包操作 + +## Why + +S/4HANA 现代化开发以 CDS View 为核心,支持 CDS 是战略必需。Where-Used 引用查询帮助开发者评估变更影响。代码差异对比防止误覆盖。传输请求和包管理是 SAP 运维的基础能力。 + +## What Changes + +- CDS View 的下载和创建 +- Where-Used 引用查询 +- 本地 vs SAP 代码差异对比 +- 传输请求高级管理(list/info/release/objects) +- ABAP 包操作(create/info) + +## Capabilities + +### Modified Capabilities +- `search-browse`: 新增 Where-Used 查询、代码差异对比、CDS View 操作、包操作 +- `transport`: 新增传输请求列表、详情、释放、对象查看 + +## Impact + +- client.py 新增 CDS/Where-Used/Transport/Package 相关 ADT API 方法 +- 新增 search.py、diff_cmd.py、transport.py、package_cmd.py、cds.py 命令模块 +- 新增 5 个子命令:whereused、search、diff、cds、package +- transport 命令新增 4 个子操作 diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/search-browse/spec.md b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/search-browse/spec.md new file mode 100644 index 0000000..af24c85 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/search-browse/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: Where-Used Query + +系统 SHALL 查询指定对象在其他开发对象中的引用关系。 + +#### Scenario: Find references +- **WHEN** 用户执行 whereused 命令并指定对象名称和类型 +- **THEN** 系统显示所有引用该对象的开发对象列表 + +#### Scenario: No references found +- **WHEN** 指定对象没有被任何其他对象引用 +- **THEN** 系统显示无引用结果的信息 + +### Requirement: Source Code Search + +系统 SHALL 在 SAP 系统中搜索源代码内容。 + +#### Scenario: Search by keyword +- **WHEN** 用户执行 search 命令并提供搜索关键词 +- **THEN** 系统显示包含该关键词的对象名称、类型和匹配行 + +#### Scenario: Search no results +- **WHEN** 搜索关键词没有匹配结果 +- **THEN** 系统显示无搜索结果的信息 + +### Requirement: Code Diff + +系统 SHALL 对比本地源码与 SAP 系统中当前版本的差异。 + +#### Scenario: Diff with differences +- **WHEN** 用户执行 diff 命令,本地文件与 SAP 版本存在差异 +- **THEN** 系统显示差异内容(新增、删除、修改的行) + +#### Scenario: Diff identical +- **WHEN** 本地文件与 SAP 版本完全一致 +- **THEN** 系统显示无差异的信息 + +### Requirement: CDS View Operations + +系统 SHALL 支持 CDS View 的下载和创建操作。 + +#### Scenario: Download CDS view +- **WHEN** 用户执行 cds download 命令并指定 CDS View 名称 +- **THEN** 系统下载 CDS View 定义并保存到本地 + +#### Scenario: Create CDS view +- **WHEN** 用户执行 cds create 命令并指定名称和描述 +- **THEN** 系统在 SAP 中创建新的 CDS View + +### Requirement: Package Operations + +系统 SHALL 支持 ABAP 包的查询和创建操作。 + +#### Scenario: Query package info +- **WHEN** 用户执行 package info 命令并指定包名 +- **THEN** 系统显示包的描述、包含的对象数量等信息 + +#### Scenario: Create package +- **WHEN** 用户执行 package create 命令并指定包名和描述 +- **THEN** 系统在 SAP 中创建新的 ABAP 包 diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/transport/spec.md b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/transport/spec.md new file mode 100644 index 0000000..38c20bd --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/transport/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: Transport Request Listing + +系统 SHALL 列出 SAP 系统中的传输请求。 + +#### Scenario: List transport requests +- **WHEN** 用户执行 transport list 命令 +- **THEN** 系统显示当前用户的传输请求列表(请求号、描述、状态) + +### Requirement: Transport Request Info + +系统 SHALL 查询指定传输请求的详细信息。 + +#### Scenario: Query transport info +- **WHEN** 用户执行 transport info 命令并指定传输请求号 +- **THEN** 系统显示传输请求的所有者、状态、包含的对象数量等信息 + +### Requirement: Transport Request Release + +系统 SHALL 释放传输请求。 + +#### Scenario: Release transport request +- **WHEN** 用户执行 transport release 命令并指定传输请求号 +- **THEN** 系统释放该传输请求 + +### Requirement: Transport Objects Listing + +系统 SHALL 列出传输请求中包含的对象。 + +#### Scenario: List objects in transport +- **WHEN** 用户执行 transport objects 命令并指定传输请求号 +- **THEN** 系统显示该传输请求中包含的所有开发对象列表 diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/tasks.md b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/tasks.md new file mode 100644 index 0000000..bd4afc2 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/tasks.md @@ -0,0 +1,36 @@ +## 1. CDS View Support + +- [x] 1.1 Register CDS View type and ADT endpoints +- [x] 1.2 Implement cds download subcommand +- [x] 1.3 Implement cds create subcommand +- [x] 1.4 Handle DDL source format (non-ABAP) + +## 2. Where-Used Query + +- [x] 2.1 Implement whereused command with name and type parameters +- [x] 2.2 Parse ADT where-used XML response +- [x] 2.3 Display referencing objects with type and name + +## 3. Source Code Search + +- [x] 3.1 Implement search command with keyword query +- [x] 3.2 Parse ADT code search response +- [x] 3.3 Display matching objects with context lines + +## 4. Code Diff + +- [x] 4.1 Implement diff command comparing local file vs SAP version +- [x] 4.2 Download SAP version for comparison +- [x] 4.3 Compute and display unified diff output + +## 5. Transport Management + +- [x] 5.1 Implement transport list subcommand +- [x] 5.2 Implement transport info subcommand with --corr_nr +- [x] 5.3 Implement transport release subcommand with --corr_nr +- [x] 5.4 Implement transport objects subcommand with --corr_nr + +## 6. Package Operations + +- [x] 6.1 Implement package info subcommand +- [x] 6.2 Implement package create subcommand with --description diff --git a/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/.openspec.yaml b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/.openspec.yaml new file mode 100644 index 0000000..552bf67 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: p2-atc-format-analyze-scaffold +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/proposal.md b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/proposal.md new file mode 100644 index 0000000..3ee9284 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/proposal.md @@ -0,0 +1,24 @@ +# Proposal: P2 — ATC 检查 + 代码格式化 + 依赖分析 + 项目脚手架 + +## Why + +代码质量保障和开发者效率是工具成熟的标志。ATC 检查提供专业级代码审查,Pretty Printer 统一代码风格,依赖分析自动化项目管理,脚手架加速项目初始化。 + +## What Changes + +- ATC (ABAP Test Cockpit) 代码检查集成 +- ABAP Pretty Printer 代码格式化 +- 项目级依赖自动分析 +- 预定义模板项目脚手架 + +## Capabilities + +### Modified Capabilities +- `quality`: 新增 ATC 检查、代码格式化、依赖分析、项目脚手架 + +## Impact + +- 新增 check、format、analyze、scaffold 四个命令 +- client.py 新增 ATC check、Pretty Printer ADT API 方法 +- 新增 quality.py、analyze.py、scaffold.py 命令模块 +- analyze 和 scaffold 不需要 SAP 连接(本地操作) diff --git a/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/specs/quality/spec.md b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/specs/quality/spec.md new file mode 100644 index 0000000..1072bce --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/specs/quality/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: ATC Code Check + +系统 SHALL 对 SAP 开发对象执行 ABAP Test Cockpit (ATC) 代码检查。 + +#### Scenario: Check with findings +- **WHEN** 用户执行 check 命令并指定对象名称和类型 +- **THEN** 系统显示 ATC 检查发现的问题列表(优先级、位置、描述) + +#### Scenario: Check clean +- **WHEN** ATC 检查未发现任何问题 +- **THEN** 系统显示检查通过的信息 + +### Requirement: Code Formatting + +系统 SHALL 对 SAP 开发对象执行 ABAP Pretty Printer 格式化。 + +#### Scenario: Format source code +- **WHEN** 用户执行 format 命令并指定对象名称和类型 +- **THEN** 系统对 SAP 中的源码执行格式化并报告结果 + +### Requirement: Dependency Analysis + +系统 SHALL 自动分析项目中对象的依赖关系。 + +#### Scenario: Analyze project dependencies +- **WHEN** 用户执行 analyze 命令并指定项目路径 +- **THEN** 系统扫描源码中的依赖引用并生成依赖报告 + +### Requirement: Project Scaffolding + +系统 SHALL 根据预定义模板创建 ABAP 项目结构。 + +#### Scenario: Scaffold from template +- **WHEN** 用户执行 scaffold 命令并指定名称和模板类型 +- **THEN** 系统基于模板生成包含源码文件的项目结构 + +#### Scenario: List available templates +- **WHEN** 用户执行 scaffold 命令但未指定模板 +- **THEN** 系统显示可用的模板列表 diff --git a/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/tasks.md b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/tasks.md new file mode 100644 index 0000000..77b6aee --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/tasks.md @@ -0,0 +1,26 @@ +## 1. ATC Code Check + +- [x] 1.1 Implement check command with name and type parameters +- [x] 1.2 Integrate ADT ATC checkruns API +- [x] 1.3 Parse and display findings with priority, location, description +- [x] 1.4 Handle clean check result + +## 2. Code Formatting + +- [x] 2.1 Implement format command with ABAP Pretty Printer integration +- [x] 2.2 Integrate ADT Pretty Printer API +- [x] 2.3 Report formatting result + +## 3. Dependency Analysis + +- [x] 3.1 Implement analyze command for project-level scanning +- [x] 3.2 Parse TYPE REF TO, CALL METHOD, PERFORM statements +- [x] 3.3 Generate dependency report +- [x] 3.4 Work without SAP connection (local-only) + +## 4. Project Scaffolding + +- [x] 4.1 Implement scaffold command with template parameter +- [x] 4.2 Create predefined templates (alv-report, bapi-wrapper, interface-class, data-model) +- [x] 4.3 Display available templates when none specified +- [x] 4.4 Work without SAP connection (local-only) diff --git a/openspec/changes/archive/2026-06-13-add-upload-syntax-check/.openspec.yaml b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/.openspec.yaml new file mode 100644 index 0000000..c40440c --- /dev/null +++ b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: add-upload-syntax-check +status: archived +archived_at: "2026-06-13" diff --git a/openspec/changes/archive/2026-06-13-add-upload-syntax-check/proposal.md b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/proposal.md new file mode 100644 index 0000000..6ce0eef --- /dev/null +++ b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/proposal.md @@ -0,0 +1,19 @@ +# Proposal: 新增 upload 和 syntax-check 独立命令 + +## Why + +sync 命令耦合了五步操作,无法满足只上传不激活、只检查语法的场景。拆分为原子命令后,用户可按需组合操作。 + +## What Changes + +- 新增 upload 命令(锁定→写入→解锁,不检查不激活) +- 新增 syntax-check 命令(检查远程对象语法,不上传不激活) + +## Impact + +- 新增 sapcli/commands/upload.py +- 新增 sapcli/commands/syntax_check.py +- parser.py 新增两个命令注册 +- app.py 新增命令路由 +- object-lifecycle spec 新增两个 Requirement +- sync 命令完全不变 diff --git a/openspec/changes/archive/2026-06-13-add-upload-syntax-check/specs/object-lifecycle/spec.md b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/specs/object-lifecycle/spec.md new file mode 100644 index 0000000..0533f73 --- /dev/null +++ b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/specs/object-lifecycle/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Source Code Upload + +系统 SHALL 上传本地源代码到 SAP 系统,执行锁定→写入→解锁流程,不执行语法检查和激活。 +upload 不会自动创建对象——上传前对象必须已存在于 SAP 系统中。 + +#### Scenario: Upload success +- **WHEN** 用户执行 upload 命令,对象已存在于 SAP,本地文件有效 +- **THEN** 系统执行锁定、写入源码、解锁,报告写入的字符数和行数,不执行语法检查和激活 + +#### Scenario: Upload non-existent object +- **WHEN** 用户上传一个 SAP 中不存在的对象 +- **THEN** 系统报告对象不存在的错误(不自动创建,区别于 sync) + +#### Scenario: Upload with transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 系统使用该传输请求号进行锁定和写入 + +#### Scenario: Upload type without source +- **WHEN** 用户尝试上传不支持源码的类型(如 functiongroup、tabletype、messageclass、searchhelp、lockobject) +- **THEN** 系统报告该类型不支持上传操作 + +#### Scenario: Upload local file not found +- **WHEN** 用户指定的本地源码文件路径不存在 +- **THEN** 系统报告文件不存在的错误 + +### Requirement: Syntax Check + +系统 SHALL 对 SAP 远程对象的源代码执行语法检查,不上传代码、不激活。 +syntax-check 检查 SAP 上现有对象的源码(通常是 active 状态),用于验证结构变更后的依赖影响。 + +#### Scenario: Syntax check passed +- **WHEN** 用户执行 syntax-check 命令,对象的源码无语法错误 +- **THEN** 系统报告语法检查通过 + +#### Scenario: Syntax check failed +- **WHEN** 对象源码包含语法错误 +- **THEN** 系统报告语法检查未通过,显示每个错误的行号和描述 + +#### Scenario: Syntax check non-existent object +- **WHEN** 用户对 SAP 中不存在的对象执行语法检查 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Syntax check type without source +- **WHEN** 用户对不支持源码的类型(如 functiongroup、tabletype)执行语法检查 +- **THEN** 系统报告该类型不支持语法检查操作 diff --git a/openspec/changes/archive/2026-06-13-add-upload-syntax-check/tasks.md b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/tasks.md new file mode 100644 index 0000000..c9098ac --- /dev/null +++ b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/tasks.md @@ -0,0 +1,44 @@ +## 1. OpenSpec 规范 + +- [x] 1.1 创建 openspec/changes/add-upload-syntax-check/ 变更目录 +- [x] 1.2 编写 proposal.md(Why / What Changes / Impact) +- [x] 1.3 编写 specs/object-lifecycle/spec.md(ADDED: Source Code Upload、Syntax Check) +- [x] 1.4 编写 tasks.md 任务清单 + +## 2. TDD 测试(先写测试,必须 RED) + +- [x] 2.1 TestUploadCommand:test_upload_success(断言 syntax_check/activate 未被调用) +- [x] 2.2 TestUploadCommand:test_upload_nonexistent_object(ObjectNotFoundError) +- [x] 2.3 TestUploadCommand:test_upload_type_without_source(InvalidNameError) +- [x] 2.4 TestUploadCommand:test_upload_with_corr_nr(通过 --corr_nr 指定) +- [x] 2.5 TestUploadCommand:test_upload_file_not_found(ConfigError) +- [x] 2.6 TestSyntaxCheckCommand:test_syntax_check_passed(输出含"通过") +- [x] 2.7 TestSyntaxCheckCommand:test_syntax_check_failed(输出含错误行号) +- [x] 2.8 TestSyntaxCheckCommand:test_syntax_check_nonexistent(ObjectNotFoundError) +- [x] 2.9 TestSyntaxCheckCommand:test_syntax_check_type_without_source(InvalidNameError) +- [x] 2.10 运行测试确认全部 RED(9 个测试 ModuleNotFoundError 失败) + +## 3. 实现命令 + +- [x] 3.1 实现 sapcli/commands/upload.py(cmd_upload,参照 activate.py 结构) +- [x] 3.2 实现 sapcli/commands/syntax_check.py(cmd_syntax_check) + +## 4. Parser 注册 + App 路由 + +- [x] 4.1 parser.py 注册 upload_parser(--name/--type/--path/--corr_nr) +- [x] 4.2 parser.py 注册 syntax-check 的 sc_parser(--name/--type) +- [x] 4.3 commands/__init__.py 导出 cmd_upload / cmd_syntax_check +- [x] 4.4 app.py 导入并路由 upload / syntax-check + +## 5. 验证(必须 GREEN) + +- [x] 5.1 运行 pytest tests/unit/ 全部通过(363 passed) +- [x] 5.2 python main.py upload --help / syntax-check --help 正常 +- [x] 5.3 python main.py --help 出现两个新命令 +- [x] 5.4 python main.py sync --help 参数未变(回归验证通过) +- [x] 5.5 openspec validate add-upload-syntax-check → valid + +## 6. 收尾 + +- [x] 6.1 tasks.md 所有 checkbox 标记为 [x] +- [x] 6.2 .openspec.yaml status 改为 completed diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..b4a3c1a --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,25 @@ +schema: spec-driven + +context: | + Tech stack: Python 3.11+, requests, SAP ADT REST API + CLI framework: argparse (subcommand pattern) + Architecture: CLI → ADT REST API → SAP System + Testing: unittest + Code style: PEP 8, type hints, f-strings + SAP object types: report, class, interface, function, functiongroup, domain, dataelement, table, structure, tabletype + +rules: + proposal: + - Focus on user-visible behavior changes, not implementation + - Identify affected capabilities by checking existing specs + specs: + - Use WHEN/THEN format for scenarios (not Gherkin code blocks) + - Describe observable behavior only, no ADT endpoints or class names + - Every requirement MUST have at least one scenario + - Use RFC 2119 keywords: SHALL/MUST for mandatory, SHOULD for recommended + design: + - Include ADT endpoint details and technical rationale + - Consider SAP transport management implications + tasks: + - Each task independently verifiable + - "Use checkbox format: - [ ] X.Y Description" diff --git a/openspec/spec-coverage-report.md b/openspec/spec-coverage-report.md new file mode 100644 index 0000000..801f98d --- /dev/null +++ b/openspec/spec-coverage-report.md @@ -0,0 +1,234 @@ +# Spec-to-Code 需求覆盖验证报告 + +> 生成时间: 2026-06-09 +> 对照 `openspec/specs/` 下 7 个 spec 文件 vs `sapcli/` 源码 + +--- + +## 1. Connection (connection/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 1.1 | Successful login | ✅已实现 | `client.py:login()` 使用 HTTP Basic Auth,`app.py:101-102` 打印"登录成功" | +| 1.2 | Login failure | ✅已实现 | `client.py:62` raise `LoginError`,`app.py:144-146` 捕获 `SapCliError` 以非零退出码退出 | +| 1.3 | Config not found | ✅已实现 | `config.py:25-29` `SAPConfig.__post_init__` 检查缺失字段抛 `ConfigError`,`app.py:91-95` 打印缺失提示和环境变量名称 | +| 1.4 | Load from config file | ✅已实现 | `config.py:32-49` 搜索并读取 `config.ini` | +| 1.5 | Environment variable override | ✅已实现 | `config.py:53-57` `os.environ.get("SAP_XXX", sap.get(...))` 环境变量优先 | +| 1.6 | Profile selection (--profile) | ❌未实现 | `parser.py:69` 定义了 `--profile` 参数,但 `config.py:51` 硬编码 `cfg["SAP"]`,未使用 `args.profile` | +| 1.7 | Client number default "100" | ✅已实现 | `config.py:55` `sap.get("client", "100")` | +| 1.8 | Custom config path (--config) | ✅已实现 | `parser.py:67` + `app.py:88` 传入 `args.config` | +| 1.9 | No command specified | ✅已实现 | `app.py:77-79` 检测 `not args.command` 打印帮助并以 exit(1) 退出 | + +**小计: 8/9 ✅, 1 ❌** + +--- + +## 2. Object Lifecycle (object-lifecycle/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 2.1 | Create with default template | ⚠️部分实现 | `crud.py:31-58` 仅有 report/class/function/interface 四种模板,其他 12 种类型无默认模板 | +| 2.2 | Create with custom source file | ✅已实现 | `crud.py:690-695` `--source` 参数读取文件 | +| 2.3 | Create DDIC with --definition | ✅已实现 | `crud.py:771-876` domain/dataelement/table/structure/tabletype 有 JSON→XML 转换 | +| 2.4 | Create message class | ❌未实现 | `types.py` 未注册 `messageclass` 对象类型,无法 create/download/info/delete | +| 2.5 | Create database view | ❌未实现 | `types.py` 未注册 `view` 对象类型 | +| 2.6 | Create search help | ❌未实现 | `types.py` 未注册 `searchhelp` 对象类型 | +| 2.7 | Create lock object | ❌未实现 | `types.py` 未注册 `lockobject` 对象类型 | +| 2.8 | Create already existing object | ✅已实现 | `crud.py:642-644` 检测 `object_exists` 后抛 `ObjectAlreadyExistsError` | +| 2.9 | Create with --corr_nr | ✅已实现 | `parser.py:110` + `crud.py:652-655` 传入传输请求号 | +| 2.10 | Download existing object | ✅已实现 | `crud.py:73-122` 完整实现,文件名小写+.abap | +| 2.11 | Download non-existent object | ✅已实现 | `crud.py:95-98` 检测不存在抛 `ObjectNotFoundError` | +| 2.12 | Download function type (group/module) | ✅已实现 | `types.py:152-167` 解析 `组名/模块名` 格式 | +| 2.13 | Download type without source | ✅已实现 | `crud.py:82-85` 检测 `src_uri is None` 报错 | +| 2.14 | Download view DDL source | ❌未实现 | `view` 类型未在 `types.py` 注册 | +| 2.15 | Full sync success (lock→write→unlock→check→activate) | ✅已实现 | `crud.py:226-317` 完整五步流程,每步报告进度 | +| 2.16 | Auto-create on sync | ✅已实现 | `crud.py:213-224` 检测不存在则自动创建空对象 | +| 2.17 | Syntax check failure | ✅已实现 | `crud.py:277-287` 报告行号和错误描述 | +| 2.18 | Activation failure | ✅已实现 | `crud.py:308-317` 报告激活错误详情 | +| 2.19 | Sync with --corr_nr | ✅已实现 | `crud.py:230-231` 贯穿整个流程 | +| 2.20 | Query existing object info | ✅已实现 | `crud.py:371-449` 显示名称/类型/描述/状态/负责人等元数据 | +| 2.21 | Query non-existent object | ✅已实现 | `crud.py:406-408` 404 检测 | +| 2.22 | Query message class info | ❌未实现 | `messageclass` 未注册 | +| 2.23 | Query view info | ❌未实现 | `view` 未注册 | +| 2.24 | Delete with confirmation | ✅已实现 | `crud.py:483-486` input("yes") 确认 | +| 2.25 | Delete cancelled | ✅已实现 | `crud.py:484-486` 非确认则取消 | +| 2.26 | Delete non-existent object | ✅已实现 | `crud.py:474-477` 检测不存在抛 `ObjectNotFoundError` | +| 2.27 | Delete message class | ❌未实现 | `messageclass` 未注册 | +| 2.28 | Delete lock object | ❌未实现 | `lockobject` 未注册 | + +**小计: 16/28 ✅, 7 ❌, 1 ⚠️** + +### 缺失对象类型汇总 + +以下 5 种对象类型在 spec 中列出但未在 `types.py` 中注册: +- **messageclass** — 无 ObjectTypeConfig、无 URI 模板、无 create/download/info/delete +- **view** — 无 ObjectTypeConfig、无 URI 模板 +- **searchhelp** — 无 ObjectTypeConfig、无 URI 模板 +- **lockobject** — 无 ObjectTypeConfig、无 URI 模板 +- **include** — 无 ObjectTypeConfig(仅在 scanner.py 的 DIRECTORY_TYPE_MAP 中映射,但 parse_object_name 会报错) +- **cdsview** — 无 ObjectTypeConfig(CDS 通过独立命令 `cds` 子系统处理,不走通用 CRUD 流程) + +--- + +## 3. Batch Operations (batch-ops/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 3.1 | Init new project | ✅已实现 | `batch.py:19-96` 扫描→查询→生成 manifest.json | +| 3.2 | Manifest tracks objects | ✅已实现 | `manifest.py` 完整 CRUD + to_dict/from_dict 持久化 | +| 3.3 | Query pending objects | ✅已实现 | `manifest.py:139-146` `pending_objects()` 方法 | +| 3.4 | Scan standard directories | ✅已实现 | `scanner.py:13-29` 14 种目录映射 + `_scan_flat_directory` | +| 3.5 | Function directory structure | ✅已实现 | `scanner.py:91-121` 子目录=组名,文件名=模块名 | +| 3.6 | Skip hidden files | ✅已实现 | `scanner.py:77` `filename.startswith(".")` 跳过 | +| 3.7 | Sort by dependency | ✅已实现 | `sorter.py:40-109` Kahn 算法拓扑排序 + TYPE_PRIORITY | +| 3.8 | Detect cyclic dependency | ✅已实现 | `sorter.py:100-105` 抛 `CyclicDependencyError` 并显示循环链 | +| 3.9 | Ignore external dependencies | ✅已实现 | `sorter.py:63-65` `dep not in names` 时忽略 | +| 3.10 | Sync all objects (--all) | ✅已实现 | `batch.py:164-328` 完整批量同步 | +| 3.11 | Dry run mode (--dry-run) | ✅已实现 | `batch.py:172,224-241` 仅显示计划不执行 | +| 3.12 | Fail fast mode (--fail-fast) | ✅已实现 | `batch.py:173,287-289` 失败立即 break | +| 3.13 | Refresh object status | ✅已实现 | `batch.py:99-161` 逐个查询 SAP 状态更新清单 | + +**小计: 13/13 ✅** + +--- + +## 4. Search and Browse (search-browse/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 4.1 | List by type with prefix | ⚠️部分实现 | `search.py:31-71` 命令层已实现,但调用 `client.list_objects()` — **client.py 中缺少该方法定义** | +| 4.2 | List without filter | ⚠️部分实现 | 同上,命令层有,client 层缺失 | +| 4.3 | Find references (whereused) | ⚠️部分实现 | `search.py:74-109` 命令层已实现,但调用 `client.where_used()` — **client.py 中缺少该方法定义** | +| 4.4 | No references found | ⚠️部分实现 | `search.py:97-98` 显示无引用信息,但依赖缺失的 client 方法 | +| 4.5 | Search by keyword | ⚠️部分实现 | `search.py:112-147` 命令层已实现,但调用 `client.search_code()` — **client.py 中缺少该方法定义** | +| 4.6 | Search no results | ⚠️部分实现 | `search.py:136-137` 显示无结果信息,但依赖缺失的 client 方法 | +| 4.7 | Diff with differences | ⚠️部分实现 | `diff_cmd.py:16-90` 命令层完整,但调用 `client.read_source_for_diff()` — **client.py 中缺少该方法定义** | +| 4.8 | Diff identical | ⚠️部分实现 | `diff_cmd.py:70-72` 检测无差异,但依赖缺失的 client 方法 | +| 4.9 | Download CDS view | ⚠️部分实现 | `cds.py:31-61` 命令层完整,但调用 `client.get_cds_source()` — **client.py 中缺少该方法定义** | +| 4.10 | Create CDS view | ⚠️部分实现 | `cds.py:130-171` 命令层完整,但调用 `client.create_cds()` — **client.py 中缺少该方法定义** | +| 4.11 | Query package info | ⚠️部分实现 | `package_cmd.py:56-77` 命令层完整,但调用 `client.get_package_info()` — **client.py 中缺少该方法定义** | +| 4.12 | Create package | ⚠️部分实现 | `package_cmd.py:29-53` 命令层完整,但调用 `client.create_package()` — **client.py 中缺少该方法定义** | + +**小计: 0/12 ✅, 12 ⚠️ (命令层均已完成,但 ADTClient 缺少对应的 API 方法)** + +### client.py 缺失的方法清单 + +以下方法被 commands/ 调用但未在 `client.py` 中定义: +1. `list_objects(obj_type, package, prefix)` — 被 search.py, package_cmd.py 调用 +2. `where_used(name, obj_uri, adt_type)` — 被 search.py 调用 +3. `search_code(query, obj_type)` — 被 search.py 调用 +4. `read_source_for_diff(name, obj_type)` — 被 diff_cmd.py 调用 +5. `get_cds_source(name)` — 被 cds.py 调用 +6. `create_cds(name, description, ddl_source)` — 被 cds.py 调用 +7. `create_package(name, description, superpackage)` — 被 package_cmd.py 调用 +8. `get_package_info(name)` — 被 package_cmd.py 调用 + +--- + +## 5. Transport Management (transport/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 5.1 | List transport requests | ✅已实现 | `transport.py:32-56` + `client.py:249-280` `list_transport_requests()` | +| 5.2 | Query transport info | ⚠️部分实现 | `transport.py:59-79` 命令层已实现,但调用 `client.transport_info()` — **client.py 中缺少该方法定义** | +| 5.3 | Release transport request | ⚠️部分实现 | `transport.py:82-107` 命令层已实现,但调用 `client.transport_release()` — **client.py 中缺少该方法定义** | +| 5.4 | List objects in transport | ⚠️部分实现 | `transport.py:110-136` 命令层已实现,但调用 `client.transport_objects()` — **client.py 中缺少该方法定义** | + +**小计: 1/4 ✅, 3 ⚠️** + +### client.py 缺失的方法(Transport) +1. `transport_info(corr_nr)` — 传输请求详情查询 +2. `transport_release(corr_nr)` — 释放传输请求 +3. `transport_objects(corr_nr)` — 传输请求中的对象列表 + +--- + +## 6. Code Quality (quality/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 6.1 | Check with findings | ⚠️部分实现 | `quality.py:14-61` 命令层完整,但调用 `client.atc_check()` — **client.py 中缺少该方法定义** | +| 6.2 | Check clean | ⚠️部分实现 | `quality.py:38-39` 显示通过信息,但依赖缺失的 client 方法 | +| 6.3 | Format source code | ⚠️部分实现 | `quality.py:64-132` 命令层完整,但调用 `client.pretty_print()` — **client.py 中缺少该方法定义** | +| 6.4 | Analyze project dependencies | ✅已实现 | `analyze.py:39-126` 本地源码解析,正则匹配 TYPE REF TO / CALL FUNCTION 等模式 | +| 6.5 | Scaffold from template | ✅已实现 | `scaffold.py:198-239` 4 种模板(alv-report/bapi-wrapper/interface-class/data-model) | +| 6.6 | List available templates | ❌未实现 | spec 要求不指定模板时列出可用模板,但 `parser.py:207` `--template` 为 `required=True`,无法不指定 | + +**小计: 2/6 ✅, 3 ⚠️, 1 ❌** + +### client.py 缺失的方法(Quality) +1. `atc_check(name, obj_uri, variant)` — ATC 代码检查 +2. `pretty_print(source)` — ABAP Pretty Printer + +--- + +## 7. Configuration Management (config/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 7.1 | Show current config | ✅已实现 | `config_cmd.py:20-40` 显示主机/客户端/用户名,密码掩码显示 | +| 7.2 | Set config value | ❌未实现 | `parser.py:123-126` 未定义 `config set` 子命令,config_cmd.py 无 set 逻辑 | +| 7.3 | List profiles | ✅已实现 | `config_cmd.py:43-66` `list-profiles` 子命令 | +| 7.4 | Use specific profile | ❌未实现 | `--profile` 参数已定义但 `config.py` 硬编码使用 `[SAP]` section | +| 7.5 | Save password to keyring | ⚠️部分实现 | `auth.py` 有 `set_password()` 和 `cmd_auth_login()`,但 **auth 模块未集成到 app.py 命令路由**,parser.py 也未注册 `auth` 子命令 | +| 7.6 | Load password from keyring | ⚠️部分实现 | `auth.py:resolve_password()` 存在,但 **未被 config.py 或 app.py 调用** | + +**小计: 2/6 ✅, 2 ❌, 2 ⚠️** + +--- + +## 汇总统计 + +| Spec 领域 | 总需求 | ✅已实现 | ❌未实现 | ⚠️部分实现 | 覆盖率 | +|-----------|--------|---------|---------|-----------|--------| +| Connection | 9 | 8 | 1 | 0 | 88.9% | +| Object Lifecycle | 28 | 16 | 7 | 1 | 57.1% | +| Batch Operations | 13 | 13 | 0 | 0 | 100% | +| Search & Browse | 12 | 0 | 0 | 12 | 0% (命令层100%,API层0%) | +| Transport | 4 | 1 | 0 | 3 | 25% | +| Code Quality | 6 | 2 | 1 | 3 | 33.3% | +| Config Management | 6 | 2 | 2 | 2 | 33.3% | +| **总计** | **78** | **42** | **11** | **21** | **53.8%** | + +--- + +## 关键缺失点总结 + +### A. client.py 缺失的 13 个 ADT API 方法 + +这些方法被 commands/ 层调用但未实现,是最关键的缺口: + +| 方法 | 调用者 | 涉及 Spec | +|------|--------|-----------| +| `list_objects(obj_type, package, prefix)` | search.py, package_cmd.py | search-browse | +| `where_used(name, obj_uri, adt_type)` | search.py | search-browse | +| `search_code(query, obj_type)` | search.py | search-browse | +| `read_source_for_diff(name, obj_type)` | diff_cmd.py | search-browse | +| `get_cds_source(name)` | cds.py | search-browse | +| `create_cds(name, description, ddl_source)` | cds.py | search-browse | +| `create_package(name, description, superpackage)` | package_cmd.py | search-browse | +| `get_package_info(name)` | package_cmd.py | search-browse | +| `transport_info(corr_nr)` | transport.py | transport | +| `transport_release(corr_nr)` | transport.py | transport | +| `transport_objects(corr_nr)` | transport.py | transport | +| `atc_check(name, obj_uri, variant)` | quality.py | quality | +| `pretty_print(source)` | quality.py | quality | + +### B. types.py 缺失的 5 个对象类型注册 + +| 类型 | Spec 要求 | 当前状态 | +|------|----------|---------| +| messageclass | create/download/info/delete | ❌ 未注册,无 URI | +| view | create/download/info | ❌ 未注册,无 URI | +| searchhelp | create | ❌ 未注册,无 URI | +| lockobject | create/delete | ❌ 未注册,无 URI | +| include | (扫描可用) | ❌ scanner.py 映射了目录但 types.py 未注册 | + +### C. 配置/认证缺口 + +| 缺失项 | 详情 | +|--------|------| +| `--profile` 不可用 | parser.py 定义了参数但 config.py 忽略,始终读 [SAP] section | +| `config set` 子命令 | spec 要求通过命令行设置配置项,parser.py 未注册,无实现 | +| keyring 集成 | auth.py 模块完整但未被 app.py/config.py 引入调用链 | +| scaffold 无模板列表 | `--template` 为 required,无法不指定来列出模板 | diff --git a/openspec/specs/batch-ops/spec.md b/openspec/specs/batch-ops/spec.md new file mode 100644 index 0000000..6679956 --- /dev/null +++ b/openspec/specs/batch-ops/spec.md @@ -0,0 +1,83 @@ +# Batch Operations Specification + +## Purpose + +定义 sap-cli 的项目级批量操作行为,包括项目初始化、清单管理、目录扫描、依赖排序和批量同步。 + +## Requirements + +### Requirement: Project Initialization + +系统 SHALL 初始化一个 ABAP 项目目录结构。 + +#### Scenario: Init new project +- **WHEN** 用户执行 init 命令并指定项目路径 +- **THEN** 系统创建标准目录结构(reports/、classes/ 等约定目录)和空的 manifest.json + +### Requirement: Manifest Management + +系统 SHALL 管理项目清单文件,记录所有开发对象的状态。 + +#### Scenario: Manifest tracks objects +- **WHEN** 对象被创建、同步或删除后 +- **THEN** 清单文件自动更新对象的状态、最后同步时间和同步结果 + +#### Scenario: Query pending objects +- **WHEN** 清单中存在未成功同步的对象 +- **THEN** 系统能够识别并列出这些待处理对象 + +### Requirement: Directory Scanning + +系统 SHALL 扫描本地项目目录,自动识别 ABAP 开发对象文件。 + +#### Scenario: Scan standard directories +- **WHEN** 项目目录包含约定命名的子目录(reports/、classes/、functions/ 等)及其中的 .abap 文件 +- **THEN** 系统识别每个文件对应的对象名称、类型和路径 + +#### Scenario: Function directory structure +- **WHEN** 扫描 functions/ 目录 +- **THEN** 子目录名解析为函数组名,文件名解析为函数模块名 + +#### Scenario: Skip hidden files +- **WHEN** 目录中存在以 . 开头的文件 +- **THEN** 系统跳过这些隐藏文件 + +### Requirement: Dependency Sorting + +系统 SHALL 根据对象间依赖关系确定正确的执行顺序。 + +#### Scenario: Sort by dependency +- **WHEN** 对象之间存在依赖关系 +- **THEN** 被依赖的对象排在前面(如接口在类之前) + +#### Scenario: Detect cyclic dependency +- **WHEN** 对象间存在循环依赖 +- **THEN** 系统报告循环依赖错误并显示循环链 + +#### Scenario: Ignore external dependencies +- **WHEN** 对象依赖了不在当前项目中的外部对象 +- **THEN** 系统忽略该外部依赖,不报错 + +### Requirement: Batch Sync + +系统 SHALL 支持项目级批量同步操作。 + +#### Scenario: Sync all objects +- **WHEN** 用户执行 sync --all 命令 +- **THEN** 系统扫描项目、按依赖排序、逐个同步所有对象 + +#### Scenario: Dry run mode +- **WHEN** 用户执行 sync --all --dry-run +- **THEN** 系统仅显示将要同步的对象列表,不实际执行 + +#### Scenario: Fail fast mode +- **WHEN** 用户执行 sync --all --fail-fast +- **THEN** 任一对象同步失败时立即停止,不继续后续对象 + +### Requirement: Status Refresh + +系统 SHALL 刷新项目清单中所有对象在 SAP 系统中的状态。 + +#### Scenario: Refresh object status +- **WHEN** 用户执行 refresh 命令 +- **THEN** 系统查询每个对象在 SAP 中的最新状态并更新清单 diff --git a/openspec/specs/config/spec.md b/openspec/specs/config/spec.md new file mode 100644 index 0000000..acc5036 --- /dev/null +++ b/openspec/specs/config/spec.md @@ -0,0 +1,47 @@ +# Configuration Management Specification + +## Purpose + +定义 sap-cli 的配置管理功能行为,包括配置查看、设置和多 profile 管理。 + +## Requirements + +### Requirement: Configuration Display + +系统 SHALL 显示当前的 SAP 连接配置。 + +#### Scenario: Show current config +- **WHEN** 用户执行 config show 命令 +- **THEN** 系统显示当前生效的配置项(主机、客户端、用户名),密码以掩码显示 + +### Requirement: Configuration Setting + +系统 SHALL 支持通过命令行设置配置项。 + +#### Scenario: Set config value +- **WHEN** 用户执行 config set 命令并指定键和值 +- **THEN** 系统将配置值保存到配置文件中 + +### Requirement: Multi-Profile Management + +系统 SHALL 支持多 SAP 系统配置 profile。 + +#### Scenario: List profiles +- **WHEN** 用户执行 config list 命令 +- **THEN** 系统显示所有已配置的 profile 列表 + +#### Scenario: Use specific profile +- **WHEN** 用户通过 --profile 参数指定 profile +- **THEN** 系统使用该 profile 的配置连接对应的 SAP 系统 + +### Requirement: Secure Password Storage + +系统 SHALL 支持通过系统密钥环安全存储 SAP 密码。 + +#### Scenario: Save password to keyring +- **WHEN** 用户配置密码存储选项 +- **THEN** 密码保存到操作系统密钥环而非明文配置文件 + +#### Scenario: Load password from keyring +- **WHEN** 配置文件中未指定密码但密钥环中存在 +- **THEN** 系统从密钥环读取密码 diff --git a/openspec/specs/connection/spec.md b/openspec/specs/connection/spec.md new file mode 100644 index 0000000..e828263 --- /dev/null +++ b/openspec/specs/connection/spec.md @@ -0,0 +1,55 @@ +# Connection Specification + +## Purpose + +定义 sap-cli 与 SAP 系统建立连接、完成身份认证和会话管理的行为。 + +## Requirements + +### Requirement: User Authentication + +系统 SHALL 通过 SAP ADT REST API 使用 HTTP Basic Auth 进行身份认证。 + +#### Scenario: Successful login +- **WHEN** 用户提供有效的 SAP 主机地址、客户端编号、用户名和密码 +- **THEN** 系统打印 "登录成功" 并建立可用会话 + +#### Scenario: Login failure +- **WHEN** 用户提供了无效的 SAP 凭据 +- **THEN** 系统打印错误信息并以非零退出码退出 + +#### Scenario: Config not found +- **WHEN** 用户未提供配置文件且未设置环境变量 +- **THEN** 系统打印配置缺失提示,列出需要的环境变量名称,并以非零退出码退出 + +### Requirement: Configuration Loading + +系统 SHALL 支持 config.ini 文件和环境变量两种配置方式,环境变量优先于配置文件。 + +#### Scenario: Load from config file +- **WHEN** 用户运行命令且当前目录或指定路径存在有效的 config.ini +- **THEN** 系统从配置文件读取 SAP 连接参数 + +#### Scenario: Environment variable override +- **WHEN** 同时存在配置文件和环境变量 +- **THEN** 环境变量值覆盖配置文件中的对应项 + +#### Scenario: Profile selection +- **WHEN** 用户通过 --profile 参数指定配置 section +- **THEN** 系统从 config.ini 的对应 section 读取配置 + +#### Scenario: Client number default +- **WHEN** 配置中未指定 SAP 客户端编号 +- **THEN** 系统使用默认值 "100" + +### Requirement: Global Options + +系统 SHALL 支持全局命令行选项。 + +#### Scenario: Custom config path +- **WHEN** 用户通过 --config 指定配置文件路径 +- **THEN** 系统从指定路径加载配置 + +#### Scenario: No command specified +- **WHEN** 用户运行程序但未指定任何子命令 +- **THEN** 系统打印帮助信息并以退出码 1 退出 diff --git a/openspec/specs/object-lifecycle/spec.md b/openspec/specs/object-lifecycle/spec.md new file mode 100644 index 0000000..a1b849b --- /dev/null +++ b/openspec/specs/object-lifecycle/spec.md @@ -0,0 +1,186 @@ +# Object Lifecycle Specification + +## Purpose + +定义 sap-cli 对 SAP 开发对象的创建、读取(下载源码和信息查询)、更新(同步)、删除操作行为。 +涵盖所有支持的对象类型:report、class、interface、function、functiongroup、domain、dataelement、table、structure、tabletype、messageclass、view、searchhelp、lockobject、cdsview、include。 + +## Requirements + +### Requirement: Object Creation + +系统 SHALL 在 SAP 系统中创建开发对象,支持 16 种对象类型。 + +#### Scenario: Create with default template +- **WHEN** 用户执行 create 命令,指定对象名称和类型,未提供自定义源码 +- **THEN** 系统使用内置模板生成源码并在 SAP 中创建对象 + +#### Scenario: Create with custom source file +- **WHEN** 用户通过 --source 参数指定本地 .abap 文件 +- **THEN** 系统读取文件内容作为源码并在 SAP 中创建对象 + +#### Scenario: Create DDIC object with definition +- **WHEN** 用户通过 --definition 参数指定 .json 文件创建 DDIC 对象(domain、dataelement、table、structure、tabletype) +- **THEN** 系统将 JSON 定义转换为 SAP 所需格式并创建对象 + +#### Scenario: Create message class +- **WHEN** 用户执行 create 命令,指定类型为 messageclass,并提供名称和描述 +- **THEN** 系统在 SAP 中创建消息类对象 + +#### Scenario: Create database view +- **WHEN** 用户执行 create 命令,指定类型为 view,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建数据库视图 + +#### Scenario: Create search help +- **WHEN** 用户执行 create 命令,指定类型为 searchhelp,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建搜索帮助 + +#### Scenario: Create lock object +- **WHEN** 用户执行 create 命令,指定类型为 lockobject,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建锁对象 + +#### Scenario: Create already existing object +- **WHEN** SAP 系统中已存在同名同类型对象 +- **THEN** 系统报告对象已存在的错误 + +#### Scenario: Create with transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 创建的对象绑定到指定的传输请求 + +### Requirement: Source Code Download + +系统 SHALL 从 SAP 系统下载对象源代码并保存到本地文件。 + +#### Scenario: Download existing object +- **WHEN** 用户执行 download 命令,SAP 系统存在该对象 +- **THEN** 系统下载源码并保存到指定目录,文件名为小写对象名+.abap + +#### Scenario: Download non-existent object +- **WHEN** 用户执行 download 命令,SAP 系统不存在该对象 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Download function type +- **WHEN** 用户下载 function 类型对象,名称格式为 "组名/模块名" +- **THEN** 系统正确下载函数模块源码 + +#### Scenario: Download type without source +- **WHEN** 用户尝试下载不支持源码的类型(如 functiongroup、messageclass、lockobject) +- **THEN** 系统报告该类型不支持下载操作 + +#### Scenario: Download database view source +- **WHEN** 用户下载 view 类型对象 +- **THEN** 系统下载视图的 DDL 定义源码 + +### Requirement: Source Code Sync + +系统 SHALL 将本地源代码同步到 SAP 系统,执行完整的锁定→写入→解锁→检查→激活流程。 + +#### Scenario: Full sync success +- **WHEN** 用户执行 sync 命令,本地文件和 SAP 对象均有效 +- **THEN** 系统依次执行锁定、写入、解锁、语法检查、激活,每步报告进度 + +#### Scenario: Auto-create on sync +- **WHEN** 用户同步一个 SAP 中不存在的对象 +- **THEN** 系统自动创建空对象后继续同步流程 + +#### Scenario: Syntax check failure +- **WHEN** 源码包含语法错误 +- **THEN** 系统报告语法错误详情(行号和描述) + +#### Scenario: Activation failure +- **WHEN** 语法检查通过但激活失败 +- **THEN** 系统报告激活错误详情 + +#### Scenario: Sync with specified transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 系统在整个同步流程中使用该传输请求 + +### Requirement: Object Information Query + +系统 SHALL 查询 SAP 系统中所有支持类型的开发对象元数据。 + +#### Scenario: Query existing object +- **WHEN** 用户执行 info 命令,SAP 系统存在该对象 +- **THEN** 系统显示对象的名称、类型、描述、激活状态、负责人等元数据 + +#### Scenario: Query non-existent object +- **WHEN** 用户执行 info 命令,SAP 系统不存在该对象 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Query message class info +- **WHEN** 用户执行 info 命令查询消息类 +- **THEN** 系统显示消息类的名称、描述、消息数量等元数据 + +#### Scenario: Query view info +- **WHEN** 用户执行 info 命令查询数据库视图 +- **THEN** 系统显示视图的名称、描述、关联表等元数据 + +### Requirement: Object Deletion + +系统 SHALL 从 SAP 系统中删除所有支持类型的开发对象。 + +#### Scenario: Delete with confirmation +- **WHEN** 用户执行 delete 命令并确认删除操作 +- **THEN** 系统锁定、删除对象并报告成功 + +#### Scenario: Delete cancelled +- **WHEN** 用户执行 delete 命令但未确认删除 +- **THEN** 系统取消操作,不执行删除 + +#### Scenario: Delete non-existent object +- **WHEN** 用户尝试删除 SAP 中不存在的对象 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Delete message class +- **WHEN** 用户执行 delete 命令删除消息类并确认 +- **THEN** 系统删除该消息类 + +#### Scenario: Delete lock object +- **WHEN** 用户执行 delete 命令删除锁对象并确认 +- **THEN** 系统删除该锁对象及其自动生成的函数模块 + +### Requirement: Source Code Upload + +系统 SHALL 上传本地源代码到 SAP 系统,执行锁定→写入→解锁流程,不执行语法检查和激活。 +upload 不会自动创建对象——上传前对象必须已存在于 SAP 系统中。 + +#### Scenario: Upload success +- **WHEN** 用户执行 upload 命令,对象已存在于 SAP,本地文件有效 +- **THEN** 系统执行锁定、写入源码、解锁,报告写入的字符数和行数,不执行语法检查和激活 + +#### Scenario: Upload non-existent object +- **WHEN** 用户上传一个 SAP 中不存在的对象 +- **THEN** 系统报告对象不存在的错误(不自动创建,区别于 sync) + +#### Scenario: Upload with transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 系统使用该传输请求号进行锁定和写入 + +#### Scenario: Upload type without source +- **WHEN** 用户尝试上传不支持源码的类型(如 functiongroup、tabletype、messageclass、searchhelp、lockobject) +- **THEN** 系统报告该类型不支持上传操作 + +#### Scenario: Upload local file not found +- **WHEN** 用户指定的本地源码文件路径不存在 +- **THEN** 系统报告文件不存在的错误 + +### Requirement: Syntax Check + +系统 SHALL 对 SAP 远程对象的源代码执行语法检查,不上传代码、不激活。 +syntax-check 检查 SAP 上现有对象的源码(通常是 active 状态),用于验证结构变更后的依赖影响。 + +#### Scenario: Syntax check passed +- **WHEN** 用户执行 syntax-check 命令,对象的源码无语法错误 +- **THEN** 系统报告语法检查通过 + +#### Scenario: Syntax check failed +- **WHEN** 对象源码包含语法错误 +- **THEN** 系统报告语法检查未通过,显示每个错误的行号和描述 + +#### Scenario: Syntax check non-existent object +- **WHEN** 用户对 SAP 中不存在的对象执行语法检查 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Syntax check type without source +- **WHEN** 用户对不支持源码的类型(如 functiongroup、tabletype)执行语法检查 +- **THEN** 系统报告该类型不支持语法检查操作 diff --git a/openspec/specs/quality/spec.md b/openspec/specs/quality/spec.md new file mode 100644 index 0000000..8f30235 --- /dev/null +++ b/openspec/specs/quality/spec.md @@ -0,0 +1,47 @@ +# Code Quality Specification + +## Purpose + +定义 sap-cli 的代码质量相关功能行为,包括 ATC 检查和代码格式化。 + +## Requirements + +### Requirement: ATC Code Check + +系统 SHALL 对 SAP 开发对象执行 ABAP Test Cockpit (ATC) 代码检查。 + +#### Scenario: Check with findings +- **WHEN** 用户执行 check 命令并指定对象名称和类型 +- **THEN** 系统显示 ATC 检查发现的问题列表(优先级、位置、描述) + +#### Scenario: Check clean +- **WHEN** ATC 检查未发现任何问题 +- **THEN** 系统显示检查通过的信息 + +### Requirement: Code Formatting + +系统 SHALL 对 SAP 开发对象执行 ABAP Pretty Printer 格式化。 + +#### Scenario: Format source code +- **WHEN** 用户执行 format 命令并指定对象名称和类型 +- **THEN** 系统对 SAP 中的源码执行格式化并报告结果 + +### Requirement: Dependency Analysis + +系统 SHALL 自动分析项目中对象的依赖关系。 + +#### Scenario: Analyze project dependencies +- **WHEN** 用户执行 analyze 命令并指定项目路径 +- **THEN** 系统扫描源码中的依赖引用并生成依赖报告 + +### Requirement: Project Scaffolding + +系统 SHALL 根据预定义模板创建 ABAP 项目结构。 + +#### Scenario: Scaffold from template +- **WHEN** 用户执行 scaffold 命令并指定名称和模板类型 +- **THEN** 系统基于模板生成包含源码文件的项目结构 + +#### Scenario: List available templates +- **WHEN** 用户执行 scaffold 命令但未指定模板 +- **THEN** 系统显示可用的模板列表 diff --git a/openspec/specs/search-browse/spec.md b/openspec/specs/search-browse/spec.md new file mode 100644 index 0000000..9c3c6b7 --- /dev/null +++ b/openspec/specs/search-browse/spec.md @@ -0,0 +1,79 @@ +# Search and Browse Specification + +## Purpose + +定义 sap-cli 的搜索和浏览功能行为,包括对象列表、引用查询和源码搜索。 + +## Requirements + +### Requirement: Object Listing + +系统 SHALL 列出 SAP 系统中指定类型的开发对象。 + +#### Scenario: List by type with prefix +- **WHEN** 用户执行 list 命令并指定对象类型和前缀筛选 +- **THEN** 系统显示匹配的对象名称列表 + +#### Scenario: List without filter +- **WHEN** 用户执行 list 命令但未指定筛选条件 +- **THEN** 系统显示该类型的所有可访问对象 + +### Requirement: Where-Used Query + +系统 SHALL 查询指定对象在其他开发对象中的引用关系。 + +#### Scenario: Find references +- **WHEN** 用户执行 whereused 命令并指定对象名称和类型 +- **THEN** 系统显示所有引用该对象的开发对象列表 + +#### Scenario: No references found +- **WHEN** 指定对象没有被任何其他对象引用 +- **THEN** 系统显示无引用结果的信息 + +### Requirement: Source Code Search + +系统 SHALL 在 SAP 系统中搜索源代码内容。 + +#### Scenario: Search by keyword +- **WHEN** 用户执行 search 命令并提供搜索关键词 +- **THEN** 系统显示包含该关键词的对象名称、类型和匹配行 + +#### Scenario: Search no results +- **WHEN** 搜索关键词没有匹配结果 +- **THEN** 系统显示无搜索结果的信息 + +### Requirement: Code Diff + +系统 SHALL 对比本地源码与 SAP 系统中当前版本的差异。 + +#### Scenario: Diff with differences +- **WHEN** 用户执行 diff 命令,本地文件与 SAP 版本存在差异 +- **THEN** 系统显示差异内容(新增、删除、修改的行) + +#### Scenario: Diff identical +- **WHEN** 本地文件与 SAP 版本完全一致 +- **THEN** 系统显示无差异的信息 + +### Requirement: CDS View Operations + +系统 SHALL 支持 CDS View 的下载和创建操作。 + +#### Scenario: Download CDS view +- **WHEN** 用户执行 cds download 命令并指定 CDS View 名称 +- **THEN** 系统下载 CDS View 定义并保存到本地 + +#### Scenario: Create CDS view +- **WHEN** 用户执行 cds create 命令并指定名称和描述 +- **THEN** 系统在 SAP 中创建新的 CDS View + +### Requirement: Package Operations + +系统 SHALL 支持 ABAP 包的查询和创建操作。 + +#### Scenario: Query package info +- **WHEN** 用户执行 package info 命令并指定包名 +- **THEN** 系统显示包的描述、包含的对象数量等信息 + +#### Scenario: Create package +- **WHEN** 用户执行 package create 命令并指定包名和描述 +- **THEN** 系统在 SAP 中创建新的 ABAP 包 diff --git a/openspec/specs/transport/spec.md b/openspec/specs/transport/spec.md new file mode 100644 index 0000000..015f89b --- /dev/null +++ b/openspec/specs/transport/spec.md @@ -0,0 +1,39 @@ +# Transport Management Specification + +## Purpose + +定义 sap-cli 的 SAP 传输请求管理功能行为。 + +## Requirements + +### Requirement: Transport Request Listing + +系统 SHALL 列出 SAP 系统中的传输请求。 + +#### Scenario: List transport requests +- **WHEN** 用户执行 transport list 命令 +- **THEN** 系统显示当前用户的传输请求列表(请求号、描述、状态) + +### Requirement: Transport Request Info + +系统 SHALL 查询指定传输请求的详细信息。 + +#### Scenario: Query transport info +- **WHEN** 用户执行 transport info 命令并指定传输请求号 +- **THEN** 系统显示传输请求的所有者、状态、包含的对象数量等信息 + +### Requirement: Transport Request Release + +系统 SHALL 释放传输请求。 + +#### Scenario: Release transport request +- **WHEN** 用户执行 transport release 命令并指定传输请求号 +- **THEN** 系统释放该传输请求 + +### Requirement: Transport Objects Listing + +系统 SHALL 列出传输请求中包含的对象。 + +#### Scenario: List objects in transport +- **WHEN** 用户执行 transport objects 命令并指定传输请求号 +- **THEN** 系统显示该传输请求中包含的所有开发对象列表 diff --git a/references/sap-tool-constraints.md b/references/sap-tool-constraints.md index 9878025..53e5255 100644 --- a/references/sap-tool-constraints.md +++ b/references/sap-tool-constraints.md @@ -20,10 +20,10 @@ **唯一合法的 SAP 交互方式**: ```bash -cd D:/Gitee/sap-cli && python main.py [options] +cd D:/Gitee/sap-cli-skill/assets && python main.py [options] ``` -> 本机运行时目录为 `D:/Gitee/sap-cli`(`D:/Codespace/sap-cli` 不存在)。通过 `--profile <名>` 切换目标系统,连接配置在 `config.ini`。 +> 本机唯一源目录为 `D:/Gitee/sap-cli-skill`,工具在其 `assets/` 下(原 `D:/Gitee/sap-cli` 已归档,勿再用)。通过 `--profile <名>` 切换目标系统,连接配置在 `assets/config.ini`。 ### 例外(仅在以下情况下允许) diff --git a/tests/e2e_project/classes/zcl_sapilot_constants.abap b/tests/e2e_project/classes/zcl_sapilot_constants.abap new file mode 100644 index 0000000..78ee9aa --- /dev/null +++ b/tests/e2e_project/classes/zcl_sapilot_constants.abap @@ -0,0 +1,98 @@ +CLASS zcl_sapilot_constants DEFINITION + PUBLIC + FINAL + CREATE PUBLIC. + + PUBLIC SECTION. + CONSTANTS: + gc_objtype_rfc TYPE string VALUE 'RFC', + gc_objtype_cds TYPE string VALUE 'CDS', + gc_objtype_odata TYPE string VALUE 'ODATA', + gc_objtype_clas TYPE string VALUE 'CLAS'. + + CONSTANTS: + gc_op_get_list TYPE string VALUE 'GET_LIST', + gc_op_get_detail TYPE string VALUE 'GET_DETAIL', + gc_op_use TYPE string VALUE 'USE', + gc_op_health TYPE string VALUE 'HEALTH', + gc_op_docs TYPE string VALUE 'DOCS', + gc_op_logs TYPE string VALUE 'LOGS', + gc_op_admin_reg TYPE string VALUE 'ADMIN_REG', + gc_op_admin_upd TYPE string VALUE 'ADMIN_UPD', + gc_op_admin_del TYPE string VALUE 'ADMIN_DEL', + gc_op_admin_act TYPE string VALUE 'ADMIN_ACT'. + + CONSTANTS: + gc_http_get TYPE string VALUE 'GET', + gc_http_post TYPE string VALUE 'POST', + gc_http_put TYPE string VALUE 'PUT', + gc_http_delete TYPE string VALUE 'DELETE'. + + CONSTANTS: + gc_sicf_path TYPE string VALUE '/sap/bc/sapilot', + gc_content_type TYPE string VALUE 'application/json', + gc_charset TYPE string VALUE 'utf-8'. + + CONSTANTS: + gc_field_success TYPE string VALUE 'success', + gc_field_data TYPE string VALUE 'data', + gc_field_error TYPE string VALUE 'error', + gc_field_metadata TYPE string VALUE 'metadata', + gc_field_request_id TYPE string VALUE 'requestId', + gc_field_duration TYPE string VALUE 'duration', + gc_field_timestamp TYPE string VALUE 'timestamp'. + + CONSTANTS: + gc_default_timeout_ms TYPE i VALUE 30000, + gc_default_limit TYPE i VALUE 50, + gc_max_limit TYPE i VALUE 200. + + CONSTANTS: + gc_dir_import TYPE string VALUE 'IMPORT', + gc_dir_export TYPE string VALUE 'EXPORT', + gc_dir_changing TYPE string VALUE 'CHANGING', + gc_dir_tables TYPE string VALUE 'TABLES'. + + CONSTANTS: + gc_status_success TYPE string VALUE 'success', + gc_status_error TYPE string VALUE 'error'. + + TYPES: + BEGIN OF ty_route, + http_method TYPE string, + path_pattern TYPE string, + operation TYPE string, + END OF ty_route, + tt_routes TYPE STANDARD TABLE OF ty_route WITH EMPTY KEY. + + TYPES: + BEGIN OF ty_list_params, + type TYPE string, + category TYPE string, + keyword TYPE string, + limit TYPE i, + offset TYPE i, + END OF ty_list_params. + + TYPES: + BEGIN OF ty_response, + success TYPE abap_bool, + data TYPE REF TO data, + error TYPE string, + metadata TYPE REF TO data, + END OF ty_response. + + TYPES: + BEGIN OF ty_object_item, + name TYPE string, + type TYPE string, + category TYPE string, + description TYPE string, + END OF ty_object_item, + tt_object_items TYPE STANDARD TABLE OF ty_object_item WITH EMPTY KEY. + +ENDCLASS. + + +CLASS zcl_sapilot_constants IMPLEMENTATION. +ENDCLASS. \ No newline at end of file diff --git a/tests/e2e_project/classes/zcl_sapilot_http_handler.abap b/tests/e2e_project/classes/zcl_sapilot_http_handler.abap new file mode 100644 index 0000000..8cb36eb --- /dev/null +++ b/tests/e2e_project/classes/zcl_sapilot_http_handler.abap @@ -0,0 +1,205 @@ +CLASS zcl_sapilot_http_handler DEFINITION + PUBLIC + FINAL + CREATE PUBLIC. + + PUBLIC SECTION. + INTERFACES if_http_extension. + + PRIVATE SECTION. + METHODS process_request + IMPORTING io_server TYPE REF TO if_http_server + RAISING zcx_sapilot_error. + + METHODS set_response_headers + IMPORTING io_server TYPE REF TO if_http_server. + + METHODS send_error_response + IMPORTING io_server TYPE REF TO if_http_server + ix_error TYPE REF TO zcx_sapilot_error. + + METHODS send_success_response + IMPORTING io_server TYPE REF TO if_http_server + iv_json TYPE string + iv_status TYPE i DEFAULT 200. + + METHODS get_client_ip + IMPORTING io_server TYPE REF TO if_http_server + RETURNING VALUE(rv_ip) TYPE string. + +ENDCLASS. + + +CLASS zcl_sapilot_http_handler IMPLEMENTATION. + + METHOD if_http_extension~handle_request. + DATA: lx_error TYPE REF TO zcx_sapilot_error. + DATA: lx_unexpected TYPE REF TO cx_static_check. + DATA: lo_unexpected TYPE REF TO zcx_sapilot_error. + + TRY. + process_request( server ). + + CATCH zcx_sapilot_error INTO lx_error. + send_error_response( io_server = server ix_error = lx_error ). + + CATCH cx_static_check INTO lx_unexpected. + CREATE OBJECT lo_unexpected + EXPORTING + iv_http_status = 500 + iv_error_code = 'INTERNAL_ERROR' + iv_message = 'An unexpected error occurred' + iv_details = lx_unexpected->get_text( ). + send_error_response( io_server = server ix_error = lo_unexpected ). + ENDTRY. + ENDMETHOD. + + METHOD process_request. + DATA: lo_router TYPE REF TO zcl_sapilot_router, + lo_response_builder TYPE REF TO zcl_sapilot_response, + lo_audit TYPE REF TO zcl_sapilot_audit_logger, + lo_health TYPE REF TO zcl_sapilot_op_health, + lo_getlist TYPE REF TO zcl_sapilot_op_getlist, + lo_getdetail TYPE REF TO zcl_sapilot_op_getdetail, + lo_use TYPE REF TO zcl_sapilot_op_use, + lo_logs TYPE REF TO zcl_sapilot_op_logs, + lo_admin TYPE REF TO zcl_sapilot_op_admin. + + CREATE OBJECT lo_router. + CREATE OBJECT lo_response_builder. + CREATE OBJECT lo_audit. + + GET TIME STAMP FIELD DATA(lv_start_ts). + DATA(lv_request_id) = lo_response_builder->generate_request_id( ). + + DATA(lv_method) = CONV string( io_server->request->get_method( ) ). + DATA(lv_path) = CONV string( io_server->request->get_header_field( '~path_info' ) ). + DATA(lv_query) = CONV string( io_server->request->get_header_field( '~query_string' ) ). + DATA(lv_body) = io_server->request->get_cdata( ). + + DATA(ls_route) = lo_router->route( iv_method = lv_method iv_path = lv_path ). + + DATA: lv_result_json TYPE string. + DATA: lx_route TYPE REF TO zcx_sapilot_error. + + CASE ls_route-operation. + WHEN zcl_sapilot_constants=>gc_op_health. + CREATE OBJECT lo_health. + lv_result_json = lo_health->execute( ). + WHEN zcl_sapilot_constants=>gc_op_get_list. + CREATE OBJECT lo_getlist. + lv_result_json = lo_getlist->execute( iv_query_string = lv_query ). + WHEN zcl_sapilot_constants=>gc_op_get_detail. + CREATE OBJECT lo_getdetail. + lv_result_json = lo_getdetail->execute( iv_path_info = lv_path ). + WHEN zcl_sapilot_constants=>gc_op_use. + CREATE OBJECT lo_use. + lv_result_json = lo_use->execute( iv_path_info = lv_path iv_request_body = lv_body ). + WHEN zcl_sapilot_constants=>gc_op_logs. + CREATE OBJECT lo_logs. + lv_result_json = lo_logs->execute( iv_query_string = lv_query ). + WHEN zcl_sapilot_constants=>gc_op_admin_reg OR + zcl_sapilot_constants=>gc_op_admin_upd OR + zcl_sapilot_constants=>gc_op_admin_del OR + zcl_sapilot_constants=>gc_op_admin_act. + CREATE OBJECT lo_admin. + lv_result_json = lo_admin->execute( + iv_operation = ls_route-operation + iv_object_type = CONV string( ls_route-object_type ) + iv_object_name = CONV string( ls_route-object_name ) + iv_request_body = lv_body ). + WHEN OTHERS. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING + iv_http_status = 404 + iv_error_code = 'ROUTE_NOT_FOUND' + iv_message = |No handler for path: { lv_path }|. + ENDCASE. + + GET TIME STAMP FIELD DATA(lv_end_ts). + DATA: lv_duration_ms TYPE i. + lv_duration_ms = cl_abap_tstmp=>subtract( tstmp1 = lv_end_ts tstmp2 = lv_start_ts ). + + DATA(lv_response) = lo_response_builder->build_success( + iv_json = lv_result_json + iv_request_id = lv_request_id + iv_duration = |{ lv_duration_ms }ms| ). + + TRY. + lo_audit->log( + iv_request_id = lv_request_id + iv_operation = ls_route-operation + iv_object_type = CONV string( ls_route-object_type ) + iv_object_name = CONV string( ls_route-object_name ) + iv_input_json = lv_body + iv_status = zcl_sapilot_constants=>gc_status_success + iv_duration_ms = lv_duration_ms + iv_remote_ip = get_client_ip( io_server ) ). + CATCH cx_static_check. + ENDTRY. + + send_success_response( io_server = io_server iv_json = lv_response ). + + ENDMETHOD. + + METHOD set_response_headers. + DATA(lo_response) = io_server->response. + lo_response->set_content_type( zcl_sapilot_constants=>gc_content_type ). + lo_response->set_header_field( name = 'charset' value = zcl_sapilot_constants=>gc_charset ). + lo_response->set_header_field( name = 'Access-Control-Allow-Origin' value = '*' ). + lo_response->set_header_field( name = 'Access-Control-Allow-Methods' value = 'GET, POST, PUT, DELETE, OPTIONS' ). + lo_response->set_header_field( name = 'Access-Control-Allow-Headers' value = 'Content-Type, Authorization' ). + ENDMETHOD. + + METHOD send_error_response. + DATA: lo_response_builder TYPE REF TO zcl_sapilot_response. + DATA: lv_request_id TYPE string. + DATA: lv_duration TYPE string. + DATA: lv_json TYPE string. + DATA: lv_error_json TYPE string. + DATA: lv_metadata TYPE string. + + CREATE OBJECT lo_response_builder. + lv_request_id = lo_response_builder->generate_request_id( ). + lv_duration = '0ms'. + lv_error_json = ix_error->get_json_error( ). + lv_metadata = lo_response_builder->build_metadata( iv_request_id = lv_request_id iv_duration = lv_duration ). + + DATA: lv_open TYPE c LENGTH 1, + lv_close TYPE string. + lv_open = cl_abap_char_utilities=>horizontal_tab. + FIELD-SYMBOLS: TYPE x. + ASSIGN lv_open TO CASTING. + = '7B'. + lv_close = '}'. + + CONCATENATE lv_open '"success": false, "error": ' lv_error_json ', "metadata": ' lv_metadata lv_close INTO lv_json. + + set_response_headers( io_server ). + io_server->response->set_status( code = ix_error->mv_http_status reason = ix_error->mv_error_code ). + io_server->response->set_cdata( lv_json ). + ENDMETHOD. + + METHOD send_success_response. + set_response_headers( io_server ). + io_server->response->set_status( code = iv_status reason = 'OK' ). + io_server->response->set_cdata( iv_json ). + ENDMETHOD. + + METHOD get_client_ip. + DATA: lv_fwd TYPE string, + lv_real TYPE string. + lv_fwd = io_server->request->get_header_field( 'X-Forwarded-For' ). + IF lv_fwd IS NOT INITIAL. + rv_ip = lv_fwd. + ELSE. + lv_real = io_server->request->get_header_field( 'X-Real-IP' ). + IF lv_real IS NOT INITIAL. + rv_ip = lv_real. + ELSE. + rv_ip = io_server->request->get_header_field( 'REMOTE_ADDR' ). + ENDIF. + ENDIF. + ENDMETHOD. + +ENDCLASS. diff --git a/tests/e2e_project/classes/zcl_sapilot_op_admin.abap b/tests/e2e_project/classes/zcl_sapilot_op_admin.abap new file mode 100644 index 0000000..28a6a9e --- /dev/null +++ b/tests/e2e_project/classes/zcl_sapilot_op_admin.abap @@ -0,0 +1,382 @@ +"! SAPilot Operation: admin +"! Manages the object whitelist registry via REST API. +"! +"! Endpoints: +"! POST /admin/objects — Register new object +"! POST /admin/objects/{type}/{name} — Update object metadata +"! DELETE /admin/objects/{type}/{name} — Delete object from registry +"! POST /admin/objects/{type}/{name}/active — Toggle active status +"! +"! All operations require SAP administrator authority. +CLASS zcl_sapilot_op_admin DEFINITION + PUBLIC + FINAL + CREATE PUBLIC. + + PUBLIC SECTION. + METHODS execute + IMPORTING iv_operation TYPE string + iv_object_type TYPE string OPTIONAL + iv_object_name TYPE string OPTIONAL + iv_request_body TYPE string OPTIONAL + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + PRIVATE SECTION. + METHODS check_admin_authority + RAISING zcx_sapilot_error. + + METHODS register_object + IMPORTING iv_request_body TYPE string + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + METHODS update_object + IMPORTING iv_object_type TYPE string + iv_object_name TYPE string + iv_request_body TYPE string + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + METHODS delete_object + IMPORTING iv_object_type TYPE string + iv_object_name TYPE string + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + METHODS toggle_active + IMPORTING iv_object_type TYPE string + iv_object_name TYPE string + iv_request_body TYPE string + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + METHODS extract_json_field + IMPORTING iv_json TYPE string + iv_field TYPE string + RETURNING VALUE(rv_value) TYPE string. + + METHODS build_result_json + IMPORTING iv_action TYPE string + iv_type TYPE string + iv_name TYPE string + RETURNING VALUE(rv_json) TYPE string. + +ENDCLASS. + + +CLASS zcl_sapilot_op_admin IMPLEMENTATION. + + METHOD execute. + " ----------------------------------------------------------------- + " Admin operation dispatcher + " ----------------------------------------------------------------- + check_admin_authority( ). + + CASE iv_operation. + WHEN zcl_sapilot_constants=>gc_op_admin_reg. + rv_json = register_object( iv_request_body = iv_request_body ). + + WHEN zcl_sapilot_constants=>gc_op_admin_upd. + rv_json = update_object( + iv_object_type = iv_object_type + iv_object_name = iv_object_name + iv_request_body = iv_request_body ). + + WHEN zcl_sapilot_constants=>gc_op_admin_del. + rv_json = delete_object( + iv_object_type = iv_object_type + iv_object_name = iv_object_name ). + + WHEN zcl_sapilot_constants=>gc_op_admin_act. + rv_json = toggle_active( + iv_object_type = iv_object_type + iv_object_name = iv_object_name + iv_request_body = iv_request_body ). + + WHEN OTHERS. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING + iv_http_status = 400 + iv_error_code = 'UNKNOWN_ADMIN_OPERATION' + iv_message = |Unknown admin operation: { iv_operation }|. + ENDCASE. + ENDMETHOD. + + METHOD check_admin_authority. + " ----------------------------------------------------------------- + " Verify the current user has SAP development/admin authority + " Check S_DEVELOP with object type 'ZS' (custom namespace) + " ----------------------------------------------------------------- + AUTHORITY-CHECK OBJECT 'S_DEVELOP' + ID 'DEVCLASS' DUMMY + ID 'OBJTYPE' FIELD 'ZS' + ID 'OBJNAME' DUMMY + ID 'P_GROUP' DUMMY + ID 'ACTVT' FIELD '02'. + + IF sy-subrc <> 0. + " Fallback: check if user has S_RFC with * + AUTHORITY-CHECK OBJECT 'S_RFC' + ID 'RFC_TYPE' FIELD '*' + ID 'RFC_NAME' FIELD '*'. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING + iv_http_status = 403 + iv_error_code = 'ADMIN_ACCESS_DENIED' + iv_message = |User { sy-uname } does not have admin authority|. + ENDIF. + ENDIF. + ENDMETHOD. + + METHOD register_object. + " ----------------------------------------------------------------- + " POST /admin/objects + " Body: {"type": "RFC", "name": "BAPI_USER_GET_DETAIL", "category": "...", "description": "..."} + " Actual ZSAPILOT_OBJ fields: object_type, object_name, category, + " description, http_method, status + " ----------------------------------------------------------------- + IF iv_request_body IS INITIAL. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 400 iv_error_code = 'EMPTY_BODY' + iv_message = |Request body is required. Expected: \{"type", "name"\}|. + ENDIF. + + DATA(lv_type) = extract_json_field( iv_json = iv_request_body iv_field = 'type' ). + DATA(lv_name) = extract_json_field( iv_json = iv_request_body iv_field = 'name' ). + + IF lv_type IS INITIAL OR lv_name IS INITIAL. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 400 iv_error_code = 'MISSING_REQUIRED_FIELD' + iv_message = |Both 'type' and 'name' are required|. + ENDIF. + + lv_type = to_upper( lv_type ). + lv_name = to_upper( lv_name ). + + " Validate object type + IF lv_type <> 'RFC' AND lv_type <> 'CDS' AND lv_type <> 'ODATA' AND lv_type <> 'CLAS'. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 400 iv_error_code = 'INVALID_OBJECT_TYPE' + iv_message = |Object type '{ lv_type }' is not supported. Use: RFC, CDS, ODATA, CLAS|. + ENDIF. + + " Check if already registered (old OpenSQL for NW 7.40) + DATA: lv_exists TYPE zsapilot_object_name. + SELECT SINGLE object_name FROM zsapilot_obj + WHERE object_type = lv_type AND object_name = lv_name + INTO lv_exists. + + IF sy-subrc = 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 409 iv_error_code = 'OBJECT_ALREADY_REGISTERED' + iv_message = |Object { lv_type }/{ lv_name } is already registered|. + ENDIF. + + " Build registry entry + DATA: ls_registry TYPE zsapilot_obj. + ls_registry-object_type = lv_type. + ls_registry-object_name = lv_name. + ls_registry-category = extract_json_field( iv_json = iv_request_body iv_field = 'category' ). + ls_registry-description = extract_json_field( iv_json = iv_request_body iv_field = 'description' ). + ls_registry-status = 'ACTIVE'. + + " Determine default HTTP method based on type + CASE lv_type. + WHEN 'RFC' OR 'CLAS'. + ls_registry-http_method = 'POST'. + WHEN OTHERS. + ls_registry-http_method = 'GET'. + ENDCASE. + + INSERT zsapilot_obj FROM ls_registry. + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 500 iv_error_code = 'REGISTER_FAILED' + iv_message = |Failed to register object { lv_type }/{ lv_name }|. + ENDIF. + + rv_json = build_result_json( iv_action = 'registered' iv_type = lv_type iv_name = lv_name ). + ENDMETHOD. + + METHOD update_object. + " ----------------------------------------------------------------- + " POST /admin/objects/{type}/{name} (acting as PUT) + " Body: {"category": "...", "description": "..."} + " ----------------------------------------------------------------- + DATA: lv_otype TYPE zsapilot_object_type_de, + lv_oname TYPE zsapilot_object_name. + lv_otype = iv_object_type. + lv_oname = iv_object_name. + + " Verify object exists (old OpenSQL) + DATA: ls_registry TYPE zsapilot_obj. + SELECT SINGLE * FROM zsapilot_obj + WHERE object_type = lv_otype AND object_name = lv_oname + INTO ls_registry. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 404 iv_error_code = 'OBJECT_NOT_FOUND' + iv_message = |Object { iv_object_type }/{ iv_object_name } not found|. + ENDIF. + + " Update fields from request body + IF iv_request_body IS NOT INITIAL. + DATA(lv_category) = extract_json_field( iv_json = iv_request_body iv_field = 'category' ). + DATA(lv_description) = extract_json_field( iv_json = iv_request_body iv_field = 'description' ). + + IF lv_category IS NOT INITIAL. + ls_registry-category = lv_category. + ENDIF. + IF lv_description IS NOT INITIAL. + ls_registry-description = lv_description. + ENDIF. + ENDIF. + + UPDATE zsapilot_obj FROM ls_registry. + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 500 iv_error_code = 'UPDATE_FAILED' + iv_message = |Failed to update object { iv_object_type }/{ iv_object_name }|. + ENDIF. + + rv_json = build_result_json( iv_action = 'updated' iv_type = iv_object_type iv_name = iv_object_name ). + ENDMETHOD. + + METHOD delete_object. + " ----------------------------------------------------------------- + " DELETE /admin/objects/{type}/{name} + " ----------------------------------------------------------------- + DATA: lv_otype TYPE zsapilot_object_type_de, + lv_oname TYPE zsapilot_object_name. + lv_otype = iv_object_type. + lv_oname = iv_object_name. + + DATA: lv_exists TYPE zsapilot_object_name. + SELECT SINGLE object_name FROM zsapilot_obj + WHERE object_type = lv_otype AND object_name = lv_oname + INTO lv_exists. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 404 iv_error_code = 'OBJECT_NOT_FOUND' + iv_message = |Object { iv_object_type }/{ iv_object_name } not found|. + ENDIF. + + " Delete associated field records first (old OpenSQL) + DELETE FROM zsapilot_objf + WHERE object_type = lv_otype AND object_name = lv_oname. + + " Delete the main record + DELETE FROM zsapilot_obj + WHERE object_type = lv_otype AND object_name = lv_oname. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 500 iv_error_code = 'DELETE_FAILED' + iv_message = |Failed to delete object { iv_object_type }/{ iv_object_name }|. + ENDIF. + + rv_json = build_result_json( iv_action = 'deleted' iv_type = iv_object_type iv_name = iv_object_name ). + ENDMETHOD. + + METHOD toggle_active. + " ----------------------------------------------------------------- + " POST /admin/objects/{type}/{name}/active + " Body: {"active": true} or {"active": false} + " Uses 'status' field: 'ACTIVE' or 'INACTIVE' + " ----------------------------------------------------------------- + DATA: lv_otype TYPE zsapilot_object_type_de, + lv_oname TYPE zsapilot_object_name. + lv_otype = iv_object_type. + lv_oname = iv_object_name. + + DATA: ls_registry TYPE zsapilot_obj. + SELECT SINGLE * FROM zsapilot_obj + WHERE object_type = lv_otype AND object_name = lv_oname + INTO ls_registry. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 404 iv_error_code = 'OBJECT_NOT_FOUND' + iv_message = |Object { iv_object_type }/{ iv_object_name } not found|. + ENDIF. + + " Parse active flag from body + IF iv_request_body IS NOT INITIAL. + DATA(lv_active) = extract_json_field( iv_json = iv_request_body iv_field = 'active' ). + IF lv_active = 'true' OR lv_active = 'X'. + ls_registry-status = 'ACTIVE'. + ELSE. + ls_registry-status = 'INACTIVE'. + ENDIF. + ELSE. + " Toggle: if currently active, deactivate; if inactive, activate + IF ls_registry-status = 'ACTIVE'. + ls_registry-status = 'INACTIVE'. + ELSE. + ls_registry-status = 'ACTIVE'. + ENDIF. + ENDIF. + + UPDATE zsapilot_obj FROM ls_registry. + + DATA: lv_action TYPE string. + IF ls_registry-status = 'ACTIVE'. + lv_action = 'activated'. + ELSE. + lv_action = 'deactivated'. + ENDIF. + rv_json = build_result_json( iv_action = lv_action iv_type = iv_object_type iv_name = iv_object_name ). + ENDMETHOD. + + METHOD extract_json_field. + " ----------------------------------------------------------------- + " Simple JSON field extractor: find "field": "value" or "field": value + " Uses CONCATENATE to avoid backslash in string templates (NW 7.40) + " ----------------------------------------------------------------- + IF iv_json IS INITIAL OR iv_field IS INITIAL. + RETURN. + ENDIF. + + DATA: lv_bs TYPE string. + FIELD-SYMBOLS: TYPE x. + DATA: lv_bs_c TYPE c LENGTH 1. + lv_bs_c = cl_abap_char_utilities=>horizontal_tab. + ASSIGN lv_bs_c TO CASTING. + = '5C'. + lv_bs = lv_bs_c. + + " Pattern: "field": "value" + DATA: lv_ws TYPE string. + lv_ws = lv_bs && 's' && lv_bs && '*'. + DATA(lv_field_l) = to_lower( iv_field ). + CONCATENATE '"' lv_field_l '"' lv_ws ':' lv_ws '*"([^"]*)"' + INTO DATA(lv_pattern). + FIND REGEX lv_pattern IN to_lower( iv_json ) SUBMATCHES DATA(lv_val). + IF sy-subrc = 0 AND lv_val IS NOT INITIAL. + rv_value = lv_val. + RETURN. + ENDIF. + + " Pattern: "field": value (unquoted: true, false, number) + CONCATENATE '"' lv_field_l '"' lv_ws ':' lv_ws '*([a-z0-9]+)' + INTO lv_pattern. + FIND REGEX lv_pattern IN to_lower( iv_json ) SUBMATCHES lv_val. + IF sy-subrc = 0. + rv_value = lv_val. + ENDIF. + ENDMETHOD. + + METHOD build_result_json. + " ----------------------------------------------------------------- + " Build a simple result JSON: {"action": "...", "type": "...", "name": "..."} + " ----------------------------------------------------------------- + rv_json = '{"action": "' && iv_action && '", "type": "' && iv_type + && '", "name": "' && iv_name && '"}'. + ENDMETHOD. + +ENDCLASS. diff --git a/tests/e2e_project/functions/zgroup/z_my_func.abap b/tests/e2e_project/functions/zgroup/z_my_func.abap new file mode 100644 index 0000000..3bf191b --- /dev/null +++ b/tests/e2e_project/functions/zgroup/z_my_func.abap @@ -0,0 +1,5 @@ +FUNCTION z_my_func + EXPORTING + VALUE(ev_result) TYPE string. + ev_result = 'hello'. +ENDFUNCTION. diff --git a/tests/e2e_project/manifest.json b/tests/e2e_project/manifest.json new file mode 100644 index 0000000..a47d819 --- /dev/null +++ b/tests/e2e_project/manifest.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "last_init": "2026-05-25T01:52:21+00:00", + "last_refresh": null, + "objects": { + "ZHELLO": { + "type": "report", + "file": "reports/zhello.abap", + "system_status": "active", + "corr_nr": null, + "depends_on": [], + "last_sync": "2026-05-25T02:09:40+00:00", + "last_sync_result": "success" + }, + "ZSAPILOT_OBJ": { + "type": "report", + "file": "reports/zsapilot_obj.abap", + "system_status": "not_exists", + "corr_nr": null, + "depends_on": [], + "last_sync": "2026-05-25T02:09:39+00:00", + "last_sync_result": "failed" + }, + "ZCL_SAPILOT_CONSTANTS": { + "type": "class", + "file": "classes/zcl_sapilot_constants.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T02:09:38+00:00", + "last_sync_result": "success" + }, + "ZCL_SAPILOT_HTTP_HANDLER": { + "type": "class", + "file": "classes/zcl_sapilot_http_handler.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T02:09:37+00:00", + "last_sync_result": "success" + }, + "ZCL_SAPILOT_OP_ADMIN": { + "type": "class", + "file": "classes/zcl_sapilot_op_admin.abap", + "system_status": "active", + "corr_nr": null, + "depends_on": [], + "last_sync": "2026-05-25T02:09:38+00:00", + "last_sync_result": "failed" + }, + "ZGROUP/Z_MY_FUNC": { + "type": "function", + "file": "functions/zgroup/z_my_func.abap", + "system_status": "not_exists", + "corr_nr": null, + "depends_on": [], + "last_sync": null, + "last_sync_result": "failed" + }, + "Z_STATUS": { + "type": "domain", + "file": "domains/z_status.abap", + "system_status": "not_exists", + "corr_nr": null, + "depends_on": [], + "last_sync": "2026-05-25T02:09:37+00:00", + "last_sync_result": "failed" + } + } +} \ No newline at end of file diff --git a/tests/test_e2e.md b/tests/test_e2e.md new file mode 100644 index 0000000..d1cf8fc --- /dev/null +++ b/tests/test_e2e.md @@ -0,0 +1,332 @@ +""" +sap-cli 端到端测试 — 使用真实项目目录 + mock SAP + +验证完整批量同步流程: + init → dry-run → sync --all → refresh + +运行方式: + python test/script/test_e2e.py +""" +from __future__ import annotations + +import io +import json +import os +import shutil +import sys +import tempfile +import unittest +from unittest.mock import MagicMock + +# Windows UTF-8 +if sys.platform == "win32": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +E2E_PROJECT = os.path.join(PROJECT_ROOT, "test", "e2e_project") + + +class TestE2EInit(unittest.TestCase): + """端到端: init 命令。""" + + def setUp(self): + # 复制测试项目到临时目录,避免污染原始文件 + self.tmpdir = tempfile.mkdtemp(prefix="sapcli_e2e_") + shutil.copytree(E2E_PROJECT, self.tmpdir, dirs_exist_ok=True) + self.client = self._mock_client() + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _mock_client(self): + client = MagicMock() + client.get_object_status.return_value = { + "exists": True, "status": "active", "corr_nr": "DEVK901362" + } + return client + + def test_init_generates_manifest(self): + """init 正确生成清单,包含所有扫描到的对象。""" + import argparse + from sapcli.commands import cmd_init + from sapcli.manifest import Manifest + + args = argparse.Namespace(path=self.tmpdir) + cmd_init(args, self.client) + + manifest = Manifest.load(self.tmpdir) + + # 验证扫描到的对象 + self.assertEqual(len(manifest.objects), 6) + self.assertIn("ZHELLO", manifest.objects) + self.assertIn("ZSAPILOT_OBJ", manifest.objects) + self.assertIn("ZCL_SAPILOT_CONSTANTS", manifest.objects) + self.assertIn("ZCL_SAPILOT_HTTP_HANDLER", manifest.objects) + self.assertIn("ZCL_SAPILOT_OP_ADMIN", manifest.objects) + self.assertIn("ZGROUP/Z_MY_FUNC", manifest.objects) + + # 验证类型映射 + self.assertEqual(manifest.get("ZHELLO").type, "report") + self.assertEqual(manifest.get("ZCL_SAPILOT_CONSTANTS").type, "class") + self.assertEqual(manifest.get("ZGROUP/Z_MY_FUNC").type, "function") + + # 验证相对路径 + self.assertEqual(manifest.get("ZHELLO").file, "reports/zhello.abap") + self.assertEqual(manifest.get("ZGROUP/Z_MY_FUNC").file, "functions/zgroup/z_my_func.abap") + + def test_init_manifest_json_structure(self): + """init 生成的 manifest.json 结构正确。""" + import argparse + from sapcli.commands import cmd_init + + args = argparse.Namespace(path=self.tmpdir) + cmd_init(args, self.client) + + with open(os.path.join(self.tmpdir, "manifest.json"), "r", encoding="utf-8") as f: + data = json.load(f) + + self.assertEqual(data["version"], 1) + self.assertIsNotNone(data["last_init"]) + self.assertIsNone(data["last_refresh"]) + self.assertEqual(len(data["objects"]), 6) + + # 验证单个对象结构 + zhello = data["objects"]["ZHELLO"] + self.assertEqual(zhello["type"], "report") + self.assertEqual(zhello["file"], "reports/zhello.abap") + self.assertEqual(zhello["system_status"], "active") + self.assertEqual(zhello["corr_nr"], "DEVK901362") + self.assertEqual(zhello["depends_on"], []) + self.assertIsNone(zhello["last_sync"]) + self.assertEqual(zhello["last_sync_result"], "pending") + + def test_init_mixed_statuses(self): + """init 正确处理混合 SAP 状态(存在/不存在/未激活)。""" + import argparse + from sapcli.commands import cmd_init + from sapcli.manifest import Manifest + + def mock_status(uri): + if "zhello" in uri: + return {"exists": True, "status": "active", "corr_nr": "DEVK901362"} + elif "zsapilot_obj" in uri: + return {"exists": True, "status": "inactive", "corr_nr": None} + else: + return {"exists": False, "status": "not_exists", "corr_nr": None} + + self.client.get_object_status.side_effect = mock_status + + args = argparse.Namespace(path=self.tmpdir) + cmd_init(args, self.client) + + manifest = Manifest.load(self.tmpdir) + + self.assertEqual(manifest.get("ZHELLO").system_status, "active") + self.assertEqual(manifest.get("ZHELLO").corr_nr, "DEVK901362") + self.assertEqual(manifest.get("ZSAPILOT_OBJ").system_status, "inactive") + self.assertIsNone(manifest.get("ZSAPILOT_OBJ").corr_nr) + self.assertEqual(manifest.get("ZCL_SAPILOT_CONSTANTS").system_status, "not_exists") + + +class TestE2ESyncAll(unittest.TestCase): + """端到端: sync --all 命令。""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="sapcli_e2e_") + shutil.copytree(E2E_PROJECT, self.tmpdir, dirs_exist_ok=True) + self.client = self._mock_client() + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _mock_client(self): + client = MagicMock() + client.object_exists.return_value = True + client.lock.return_value = ("handle", "DEVK901362") + client.unlock.return_value = True + client.set_source.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + client.get_object_status.return_value = { + "exists": True, "status": "active", "corr_nr": "DEVK901362" + } + return client + + def _create_manifest(self): + """先执行 init 创建清单。""" + import argparse + from sapcli.commands import cmd_init + args = argparse.Namespace(path=self.tmpdir) + cmd_init(args, self.client) + + def test_dry_run_no_side_effects(self): + """dry-run 不修改清单状态。""" + import argparse + from sapcli.commands import cmd_sync_all + from sapcli.manifest import Manifest + + self._create_manifest() + + args = argparse.Namespace(path=self.tmpdir, dry_run=True, fail_fast=False) + cmd_sync_all(args, self.client) + + manifest = Manifest.load(self.tmpdir) + # 所有对象应保持 pending 状态 + for entry in manifest.objects.values(): + self.assertEqual(entry.last_sync_result, "pending") + + # activate 不应被调用 + self.client.activate.assert_not_called() + + def test_sync_all_updates_manifest(self): + """sync --all 成功后所有对象标记为 success。""" + import argparse + from sapcli.commands import cmd_sync_all + from sapcli.manifest import Manifest + + self._create_manifest() + + args = argparse.Namespace(path=self.tmpdir, dry_run=False, fail_fast=False) + cmd_sync_all(args, self.client) + + manifest = Manifest.load(self.tmpdir) + for name, entry in manifest.objects.items(): + self.assertEqual(entry.system_status, "active", f"{name} 应为 active") + self.assertEqual(entry.last_sync_result, "success", f"{name} 应为 success") + self.assertIsNotNone(entry.last_sync, f"{name} 应有 last_sync") + + def test_sync_all_respects_execution_order(self): + """sync --all 按类型优先级执行。""" + import argparse + from sapcli.commands import cmd_sync_all + + self._create_manifest() + + args = argparse.Namespace(path=self.tmpdir, dry_run=False, fail_fast=False) + cmd_sync_all(args, self.client) + + # 验证 activate 的调用顺序 + calls = self.client.activate.call_args_list + names = [call[0][0] for call in calls] # 第一个位置参数是 name + + # interface 应在 class 前,class 应在 report 前 + class_idx = next((i for i, n in enumerate(names) if "ZCL_SAPILOT" in n), None) + report_idx = next((i for i, n in enumerate(names) if "ZHELLO" in n or "ZSAPILOT" in n), None) + + if class_idx is not None and report_idx is not None: + self.assertLess(class_idx, report_idx, "class 应在 report 之前执行") + + def test_sync_all_with_dependency_failure(self): + """sync --all 某对象失败时依赖它的对象被跳过。""" + import argparse + from sapcli.commands import cmd_sync_all + from sapcli.manifest import Manifest, ManifestEntry, init_manifest + + # 手动创建带依赖的清单 + m = init_manifest(self.tmpdir) + m.upsert(ManifestEntry( + name="ZCL_SAPILOT_CONSTANTS", type="class", + file="classes/zcl_sapilot_constants.abap", + system_status="inactive", last_sync_result="pending", + )) + m.upsert(ManifestEntry( + name="ZSAPILOT_OBJ", type="report", + file="reports/zsapilot_obj.abap", + system_status="inactive", last_sync_result="pending", + depends_on=["ZCL_SAPILOT_CONSTANTS"], + )) + m.save() + + # 让 class 的 activate 失败 + def mock_activate(name, uri, corr_nr=None): + if "zcl_sapilot_constants" in name.lower(): + return (False, [{"type": "E", "line": "1", "text": "test error", "href": ""}]) + return (True, []) + self.client.activate.side_effect = mock_activate + + args = argparse.Namespace(path=self.tmpdir, dry_run=False, fail_fast=False) + cmd_sync_all(args, self.client) + + manifest = Manifest.load(self.tmpdir) + self.assertEqual(manifest.get("ZCL_SAPILOT_CONSTANTS").last_sync_result, "failed") + self.assertEqual(manifest.get("ZSAPILOT_OBJ").last_sync_result, "skipped") + + +class TestE2ERefresh(unittest.TestCase): + """端到端: refresh 命令。""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="sapcli_e2e_") + shutil.copytree(E2E_PROJECT, self.tmpdir, dirs_exist_ok=True) + self.client = MagicMock() + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_refresh_preserves_sync_history(self): + """refresh 只更新 system_status 和 corr_nr,不动 last_sync。""" + import argparse + from sapcli.commands import cmd_refresh + from sapcli.manifest import Manifest, ManifestEntry, init_manifest + + # 创建带同步历史的清单 + m = init_manifest(self.tmpdir) + m.upsert(ManifestEntry( + name="ZCL_SAPILOT_CONSTANTS", type="class", + file="classes/zcl_sapilot_constants.abap", + system_status="active", corr_nr="DEVK901362", + last_sync="2026-01-01T00:00:00", last_sync_result="success", + )) + m.save() + + # SAP 端状态变更 + self.client.get_object_status.return_value = { + "exists": True, "status": "inactive", "corr_nr": "DEVK901363" + } + + args = argparse.Namespace(path=self.tmpdir) + cmd_refresh(args, self.client) + + manifest = Manifest.load(self.tmpdir) + entry = manifest.get("ZCL_SAPILOT_CONSTANTS") + + # 状态已更新 + self.assertEqual(entry.system_status, "inactive") + self.assertEqual(entry.corr_nr, "DEVK901363") + + # 同步历史不变 + self.assertEqual(entry.last_sync, "2026-01-01T00:00:00") + self.assertEqual(entry.last_sync_result, "success") + + +# ════════════════════════════════════════════════════════════════ +# 运行 +# ════════════════════════════════════════════════════════════════ + +if __name__ == "__main__": + print("=" * 60) + print(" sap-cli 端到端测试 — 真实项目目录 + mock SAP") + print("=" * 60) + print(f" 测试项目: {E2E_PROJECT}") + + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + for cls in [TestE2EInit, TestE2ESyncAll, TestE2ERefresh]: + suite.addTests(loader.loadTestsFromTestCase(cls)) + + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + print("\n" + "=" * 60) + if result.wasSuccessful(): + print(f" ✓ 全部通过! ({result.testsRun} 个测试)") + else: + print(f" ✗ 失败: {len(result.failures)} 错误: {len(result.errors)}") + print("=" * 60) + + sys.exit(0 if result.wasSuccessful() else 1) diff --git a/tests/test_sapcli.py b/tests/test_sapcli.py new file mode 100644 index 0000000..ef45779 --- /dev/null +++ b/tests/test_sapcli.py @@ -0,0 +1,708 @@ +""" +sap-cli 单元测试(v3 — 匹配重构后 API) + +运行: python tests/test_sapcli.py +""" + +import io +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +# 将项目根目录加入 sys.path,支持从任意位置运行测试 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets")) + + +# ═══════════════════════════════════════════ +# TestTypes +# ═══════════════════════════════════════════ +class TestTypes(unittest.TestCase): + """对象类型注册与名称解析。""" + + def test_type_registry(self): + from sapcli.types import all_type_keys, get_type_config + keys = all_type_keys() + self.assertGreaterEqual(len(keys), 10) + for k in keys: + cfg = get_type_config(k) + self.assertIsNotNone(cfg) + self.assertTrue(cfg.obj_uri_template) + + def test_parse_report_name(self): + from sapcli.types import parse_object_name + parsed = parse_object_name("ZMY_REPORT", "report") + self.assertEqual(parsed.display_name, "ZMY_REPORT") + + def test_parse_class_name(self): + from sapcli.types import parse_object_name + parsed = parse_object_name("ZCL_MY_CLASS", "class") + self.assertEqual(parsed.display_name, "ZCL_MY_CLASS") + + def test_parse_function_name(self): + from sapcli.types import parse_object_name + parsed = parse_object_name("ZGROUP/Z_MY_FUNC", "function") + self.assertIn("z_my_func", parsed.obj_uri) + + def test_parse_function_name_no_slash(self): + from sapcli.types import parse_object_name + from sapcli.exceptions import InvalidNameError + with self.assertRaises(InvalidNameError): + parse_object_name("Z_MY_FUNC", "function") + + def test_new_types_registered(self): + from sapcli.types import all_type_keys, get_type_config + keys = all_type_keys() + # Check that all 10 core types are registered + expected = ["report", "class", "function", "functiongroup", "interface", + "domain", "dataelement", "table", "structure", "tabletype"] + for t in expected: + self.assertIn(t, keys) + cfg = get_type_config(t) + self.assertIsNotNone(cfg) + + def test_r3tr_object_code(self): + from sapcli.types import get_r3tr_object_code + # 有独立 R3TR 代码 + self.assertEqual(get_r3tr_object_code("class"), "CLAS") + self.assertEqual(get_r3tr_object_code("report"), "PROG") + self.assertEqual(get_r3tr_object_code("table"), "TABL") + self.assertEqual(get_r3tr_object_code("domain"), "DOMA") + self.assertEqual(get_r3tr_object_code("cdsview"), "DDLS") + # 非独立 R3TR 对象(属父对象) + self.assertIsNone(get_r3tr_object_code("function")) + self.assertIsNone(get_r3tr_object_code("include")) + # 未知类型 + self.assertIsNone(get_r3tr_object_code("not_a_type")) + + +# ═══════════════════════════════════════════ +# TestExceptions +# ═══════════════════════════════════════════ +class TestExceptions(unittest.TestCase): + + def test_exceptions(self): + from sapcli.exceptions import ( + SapCliError, ConfigError, LoginError, LockError, + ActivationError, CreateError, DeleteError, + SyntaxCheckError, InvalidNameError, + ObjectNotFoundError, CyclicDependencyError, + ) + for ExcCls in [SapCliError, ConfigError, LoginError, LockError, + ActivationError, CreateError, DeleteError, + SyntaxCheckError, InvalidNameError]: + e = ExcCls("test error") + self.assertIsInstance(e, SapCliError) + + def test_cyclic_dependency(self): + from sapcli.exceptions import CyclicDependencyError + e = CyclicDependencyError(["A", "B", "C"]) + self.assertIn("A", str(e)) + self.assertEqual(e.cycle_members, ["A", "B", "C"]) + + +# ═══════════════════════════════════════════ +# TestConfig +# ═══════════════════════════════════════════ +class TestConfig(unittest.TestCase): + + def setUp(self): + self._saved_env = {k: os.environ.pop(k, None) for k in + ["SAP_HOST", "SAP_CLIENT", "SAP_USER", "SAP_PASSWORD", "SAPCLI_PROFILE"]} + + def tearDown(self): + for k, v in self._saved_env.items(): + if v is not None: + os.environ[k] = v + + def test_config_from_env(self): + from sapcli.config import load_config + with patch.dict(os.environ, { + "SAP_HOST": "http://test:8000", + "SAP_CLIENT": "200", + "SAP_USER": "testuser", + "SAP_PASSWORD": "testpass", + }): + cfg, _ = load_config("/nonexistent/config.ini") + self.assertEqual(cfg.host, "http://test:8000") + self.assertEqual(cfg.client, "200") + + def test_config_no_file_returns_empty(self): + """无配置文件且无环境变量时,load_config 抛出 ConfigError。""" + from sapcli.config import load_config + from sapcli.exceptions import ConfigError + # clear env vars and prevent reading any config file + with patch.dict(os.environ, {}, clear=True), \ + patch("sapcli.config.os.path.isfile", return_value=False): + with self.assertRaises(ConfigError): + load_config("/nonexistent/config.ini") + + def test_multi_profile(self): + from sapcli.config import load_config + with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f: + f.write("[SAP]\nhost=http://dev:8000\nclient=100\nuser=devuser\npassword=devpass\n") + f.write("[QAS]\nhost=http://qas:8000\nclient=200\nuser=qasuser\npassword=qaspass\n") + f.write("[PRD]\nhost=http://prd:8000\nclient=300\nuser=prduser\npassword=prdpass\n") + tmppath = f.name + try: + # 默认 profile=SAP + cfg, _ = load_config(tmppath) + self.assertEqual(cfg.host, "http://dev:8000") + # 指定 QAS + cfg, _ = load_config(tmppath, profile="QAS") + self.assertEqual(cfg.host, "http://qas:8000") + self.assertEqual(cfg.client, "200") + # 指定 PRD + cfg, _ = load_config(tmppath, profile="PRD") + self.assertEqual(cfg.host, "http://prd:8000") + finally: + os.unlink(tmppath) + + def test_profile_fallback(self): + from sapcli.config import load_config + from sapcli.exceptions import ConfigError + with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f: + f.write("[SAP]\nhost=http://dev:8000\nclient=100\nuser=devuser\npassword=devpass\n") + tmppath = f.name + try: + # 不存在的 profile → section 为空 → ConfigError + with self.assertRaises(ConfigError): + load_config(tmppath, profile="NONEXISTENT") + finally: + os.unlink(tmppath) + + +# ═══════════════════════════════════════════ +# TestManifest +# ═══════════════════════════════════════════ +class TestManifest(unittest.TestCase): + + def _make_manifest(self, td): + from sapcli.manifest import Manifest + m = Manifest(_project_path=td) + return m + + def _make_entry(self, name, obj_type, file, **kw): + from sapcli.manifest import ManifestEntry + return ManifestEntry(name=name, type=obj_type, file=file, **kw) + + def test_manifest_add_and_get(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + m.upsert(self._make_entry("Z_TEST", "report", "reports/z_test.abap")) + entry = m.get("Z_TEST") + self.assertIsNotNone(entry) + self.assertEqual(entry.type, "report") + + def test_manifest_remove(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + m.upsert(self._make_entry("Z_A", "domain", "domains/z_a.abap")) + m.remove("Z_A") + self.assertIsNone(m.get("Z_A")) + + def test_manifest_pending(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + m.upsert(self._make_entry("Z_A", "domain", "domains/z_a.abap", system_status="active", last_sync_result="success")) + m.upsert(self._make_entry("Z_B", "report", "reports/z_b.abap", system_status="inactive")) + m.upsert(self._make_entry("Z_C", "class", "classes/z_c.abap", system_status="not_exists")) + pending = m.pending_objects() + names = [p.name for p in pending] + self.assertIn("Z_B", names) + self.assertIn("Z_C", names) + self.assertNotIn("Z_A", names) + + def test_manifest_persistence(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + m.upsert(self._make_entry("Z_TEST", "report", "reports/z_test.abap")) + m.save() + self.assertTrue(os.path.isfile(os.path.join(td, "manifest.json"))) + + def test_manifest_load_empty(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + self.assertEqual(len(m.objects), 0) + + +# ═══════════════════════════════════════════ +# TestScanner +# ═══════════════════════════════════════════ +class TestScanner(unittest.TestCase): + + def test_scanner_basic(self): + from sapcli.scanner import scan_project + with tempfile.TemporaryDirectory() as td: + os.makedirs(os.path.join(td, "reports")) + with open(os.path.join(td, "reports", "zmy_report.abap"), "w") as f: + f.write("WRITE: / 'Hello'.") + result = scan_project(td) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "ZMY_REPORT") + self.assertEqual(result[0].type, "report") + + def test_scanner_empty_dir(self): + from sapcli.scanner import scan_project + with tempfile.TemporaryDirectory() as td: + result = scan_project(td) + self.assertEqual(len(result), 0) + + +# ═══════════════════════════════════════════ +# TestSorter +# ═══════════════════════════════════════════ +class TestSorter(unittest.TestCase): + + def _make_entry(self, name, obj_type, deps=None): + from sapcli.manifest import ManifestEntry + return ManifestEntry(name=name, type=obj_type, file=f"{name}.abap", + depends_on=deps or []) + + def test_sorter_basic(self): + from sapcli.sorter import topological_sort + objects = [ + self._make_entry("Z_B", "class", ["Z_A"]), + self._make_entry("Z_A", "domain", []), + ] + result = topological_sort(objects) + names = [o.name for o in result] + self.assertLess(names.index("Z_A"), names.index("Z_B")) + + def test_sorter_no_deps(self): + from sapcli.sorter import topological_sort + objects = [ + self._make_entry("Z_A", "domain"), + self._make_entry("Z_B", "report"), + ] + result = topological_sort(objects) + self.assertEqual(len(result), 2) + + def test_sorter_cycle_detection(self): + from sapcli.sorter import topological_sort + from sapcli.exceptions import CyclicDependencyError + objects = [ + self._make_entry("Z_A", "class", ["Z_B"]), + self._make_entry("Z_B", "class", ["Z_A"]), + ] + with self.assertRaises(CyclicDependencyError): + topological_sort(objects) + + def test_sorter_external_dep_ignored(self): + from sapcli.sorter import topological_sort + objects = [self._make_entry("Z_A", "domain", ["Z_EXTERNAL"])] + result = topological_sort(objects) + self.assertEqual(len(result), 1) + + def test_sorter_empty(self): + from sapcli.sorter import topological_sort + self.assertEqual(topological_sort([]), []) + + +# ═══════════════════════════════════════════ +# TestDdic +# ═══════════════════════════════════════════ +class TestDdic(unittest.TestCase): + + def test_ddic_domain_xml(self): + from sapcli.ddic import DomainDefinition + d = DomainDefinition(datatype="CHAR", length=10) + xml = d.to_xml("Z_TEST", "Test domain") + self.assertIn("Z_TEST", xml) + self.assertIn("Test domain", xml) + self.assertIn("CHAR", xml) + + def test_ddic_domain_fix_values(self): + from sapcli.ddic import DomainDefinition + d = DomainDefinition( + datatype="CHAR", length=1, + fix_values=[{"low": "A", "text": "Active"}, {"low": "I", "text": "Inactive"}], + ) + xml = d.to_xml("Z_STATUS", "Status") + self.assertIn("Active", xml) + + def test_ddic_dataelement_with_domain(self): + from sapcli.ddic import DataElementDefinition + de = DataElementDefinition(domain_name="Z_STATUS") + xml = de.to_xml("Z_STATUS_DE", "Status element", "$TMP") + self.assertIn("Z_STATUS", xml) + + def test_ddic_dataelement_builtin(self): + from sapcli.ddic import DataElementDefinition + de = DataElementDefinition(datatype="CHAR", length=20) + xml = de.to_xml("Z_NAME_DE", "Name element", "$TMP") + self.assertIn("CHAR", xml) + + def test_ddic_table_ddl(self): + from sapcli.ddic import TableDefinition, TableField + t = TableDefinition( + fields=[ + TableField(name="MANDT", type_name="MANDT"), + TableField(name="ID", type_name="Z_ID"), + ], + ) + ddl = t.to_ddl("ZMY_TABLE", "My table") + self.assertIn("zmy_table", ddl) + self.assertIn("define table", ddl) + self.assertIn("MANDT", ddl) + + def test_ddic_structure_ddl(self): + from sapcli.ddic import StructureDefinition, TableField + s = StructureDefinition( + fields=[ + TableField(name="ID", type_name="Z_ID"), + TableField(name="NAME", type_name="Z_NAME"), + ], + ) + ddl = s.to_ddl("ZMY_STRUCT", "My struct") + self.assertIn("define structure", ddl) + + def test_ddic_tabletype_xml(self): + from sapcli.ddic import TableTypeDefinition + tt = TableTypeDefinition(line_type="ZMY_STRUCT") + xml = tt.to_xml("ZTY_TABLE", "Table type", "$TMP") + self.assertIn("ZMY_STRUCT", xml) + self.assertIn("TTYP", xml) + + def test_ddic_json_builder(self): + from sapcli.ddic import build_definition + result = build_definition({"name": "Z_TEST", "datatype": "NUMC", "length": 6}, "domain") + self.assertIn("NUMC", result) + + +# ═══════════════════════════════════════════ +# TestXmlUtils +# ═══════════════════════════════════════════ +class TestXmlUtils(unittest.TestCase): + + def test_xml_escape_basic(self): + from sapcli.utils.xml_utils import xml_escape + self.assertEqual(xml_escape("hello"), "hello") + + def test_xml_escape_special_chars(self): + from sapcli.utils.xml_utils import xml_escape + result = xml_escape('') + self.assertIn("<script>", result) + self.assertIn(""xss"", result) + + def test_xml_escape_quotes(self): + from sapcli.utils.xml_utils import xml_escape + result = xml_escape('say "hello" & \'bye\'') + self.assertIn(""", result) + self.assertIn("&", result) + + def test_ddl_escape(self): + from sapcli.utils.xml_utils import ddl_escape + self.assertEqual(ddl_escape("it's O'Brien's"), "it''s O''Brien''s") + + +# ═══════════════════════════════════════════ +# TestAuth +# ═══════════════════════════════════════════ +class TestAuth(unittest.TestCase): + + def test_resolve_password_env_priority(self): + from sapcli.auth import resolve_password + with patch("sapcli.password._KEYRING_AVAILABLE", True): + result = resolve_password("h", "c", "u", config_password="cfg", env_password="env") + self.assertEqual(result, "env") + + def test_resolve_password_keyring(self): + from sapcli.auth import resolve_password + with patch("sapcli.password._KEYRING_AVAILABLE", True), \ + patch("sapcli.password.get_password", return_value="kr_pass"): + result = resolve_password("h", "c", "u", config_password="cfg") + self.assertEqual(result, "kr_pass") + + def test_resolve_password_config_fallback(self): + from sapcli.auth import resolve_password + with patch("sapcli.password._KEYRING_AVAILABLE", False): + result = resolve_password("h", "c", "u", config_password="cfg_pass") + self.assertEqual(result, "cfg_pass") + + def test_keyring_unavailable(self): + from sapcli.auth import get_password, set_password + with patch("sapcli.password._KEYRING_AVAILABLE", False): + self.assertIsNone(get_password("h", "c", "u")) + self.assertFalse(set_password("h", "c", "u", "p")) + + +# ═══════════════════════════════════════════ +# TestXmlInjection +# ═══════════════════════════════════════════ +class TestXmlInjection(unittest.TestCase): + + def test_xml_injection_in_ddic(self): + from sapcli.ddic import DomainDefinition + evil = 'ZTEST<>&"' + d = DomainDefinition(datatype="CHAR", length=10) + xml = d.to_xml(evil, evil) + self.assertNotIn('name="ZTEST<', xml) + self.assertIn("<", xml) + self.assertIn(">", xml) + + def test_xml_injection_in_ddl(self): + from sapcli.ddic import TableDefinition, TableField + evil_desc = "O'Brien's \"test\"" + t = TableDefinition(fields=[TableField(name="ID", type_name="Z_ID")]) + ddl = t.to_ddl("ZMY_TABLE", evil_desc) + self.assertIn("O''Brien", ddl) + + def test_empty_name_handling(self): + from sapcli.ddic import DomainDefinition + d = DomainDefinition(datatype="CHAR", length=10) + xml = d.to_xml("", "") + self.assertIsInstance(xml, str) + + +# ═══════════════════════════════════════════ +# TestClientMethods +# ═══════════════════════════════════════════ +class TestClientMethods(unittest.TestCase): + + def _make_client(self): + from sapcli.client import ADTClient + client = ADTClient.__new__(ADTClient) + client.host = "http://test:8000" + client.csrf_token = "test-token" + client.sap_client = "100" + client.session = MagicMock() + client._stateful = False + return client + + def test_list_objects(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.content = ( + b'' + b'' + b'' + b'' + b'' + b'' + ) + client.session.get.return_value = mock_resp + result = client.list_objects(obj_type="report") + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["name"], "Z_TEST") + + def test_transport_info(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.content = ( + b'' + b'' + ) + client.session.get.return_value = mock_resp + result = client.transport_info("DEVK901362") + self.assertIsInstance(result, dict) + + def test_pretty_print(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.text = 'FORMATTED CODE' + client.session.post.return_value = mock_resp + result = client.pretty_print("raw code") + self.assertIsInstance(result, str) + + +# ═══════════════════════════════════════════ +# TestAnalyze +# ═══════════════════════════════════════════ +class TestAnalyze(unittest.TestCase): + + def test_analyze_type_ref(self): + from sapcli.commands.analyze import _analyze_dependencies + code = 'DATA: lo_obj TYPE REF TO zcl_my_class.\nDATA: lo_if TYPE REF TO zif_my_interface.\n' + deps = _analyze_dependencies(code) + self.assertIn("ZCL_MY_CLASS", deps) + + def test_analyze_call_function(self): + from sapcli.commands.analyze import _analyze_dependencies + code = "CALL FUNCTION 'Z_MY_FUNC'\n EXPORTING iv_param = lv_val.\n" + deps = _analyze_dependencies(code) + self.assertIn("Z_MY_FUNC", deps) + + def test_analyze_select_table(self): + from sapcli.commands.analyze import _analyze_dependencies + code = "SELECT * FROM zmy_table INTO TABLE @lt_data.\n" + deps = _analyze_dependencies(code) + self.assertIn("ZMY_TABLE", deps) + + def test_analyze_builtin_filtered(self): + from sapcli.commands.analyze import _analyze_dependencies + code = "DATA: lv_str TYPE string.\nDATA: lv_int TYPE i.\n" + deps = _analyze_dependencies(code) + self.assertNotIn("STRING", deps) + self.assertNotIn("I", deps) + + +# ═══════════════════════════════════════════ +# TestScaffold +# ═══════════════════════════════════════════ +class TestScaffold(unittest.TestCase): + + def test_scaffold_alv_report(self): + from sapcli.commands.scaffold import _alv_report_template + result = _alv_report_template("ZMY_REPORT", "$TMP") + self.assertIn("ZMY_REPORT", result) + self.assertIn("cl_salv_table", result) + + def test_scaffold_interface_class(self): + from sapcli.commands.scaffold import _interface_class_template + result = _interface_class_template("ZCL_MY_CLASS", "$TMP") + self.assertIn("ZCL_MY_CLASS", result) + self.assertIn("INTERFACE", result) + + def test_scaffold_data_model(self): + from sapcli.commands.scaffold import _data_model_template + result = _data_model_template("ZMY_MODEL", "$TMP") + self.assertIn("ZMY_MODEL", result) + self.assertIn("define table", result) + + def test_scaffold_bapi_wrapper(self): + from sapcli.commands.scaffold import _bapi_wrapper_template + result = _bapi_wrapper_template("Z_MY_BAPI", "$TMP") + self.assertIn("Z_MY_BAPI", result) + self.assertIn("CLASS", result) + + +# ═══════════════════════════════════════════ +# TestOutput +# ═══════════════════════════════════════════ +class TestOutput(unittest.TestCase): + + def test_print_header(self): + from sapcli.cli.output import print_header + captured = io.StringIO() + with patch("sys.stdout", captured): + print_header("测试标题") + self.assertIn("测试标题", captured.getvalue()) + + def test_print_success(self): + from sapcli.cli.output import print_success + captured = io.StringIO() + with patch("sys.stdout", captured): + print_success("操作成功") + self.assertIn("✓", captured.getvalue()) + + def test_print_error(self): + from sapcli.cli.output import print_error + captured = io.StringIO() + with patch("sys.stdout", captured): + print_error("操作失败") + self.assertIn("✗", captured.getvalue()) + + +# ═══════════════════════════════════════════ +# TestDiff +# ═══════════════════════════════════════════ +class TestDiff(unittest.TestCase): + + def test_unified_diff_logic(self): + """验证 difflib.unified_diff 逻辑(与 diff_cmd.py 一致)。""" + import difflib + old = "LINE 1\nLINE 2\nLINE 3\n".splitlines(keepends=True) + new = "LINE 1\nLINE 2 MODIFIED\nLINE 3\n".splitlines(keepends=True) + diff = list(difflib.unified_diff(old, new, fromfile="SAP", tofile="LOCAL", lineterm="")) + self.assertTrue(any("LINE 2" in l for l in diff)) + self.assertTrue(any(l.startswith("-") and "LINE 2" in l and not l.startswith("---") for l in diff)) + self.assertTrue(any(l.startswith("+") and "MODIFIED" in l and not l.startswith("+++") for l in diff)) + + +# ═══════════════════════════════════════════ +# TestOpenSpec +# ═══════════════════════════════════════════ +class TestOpenSpec(unittest.TestCase): + + def test_openspec_structure_exists(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + self.assertTrue(os.path.isdir(os.path.join(project_root, "openspec"))) + + def test_openspec_config_exists(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + self.assertTrue(os.path.isfile(os.path.join(project_root, "openspec", "config.yaml"))) + + def test_openspec_specs_exist(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + specs_dir = os.path.join(project_root, "openspec", "specs") + self.assertTrue(os.path.isdir(specs_dir)) + # Source of Truth — 7 domains describing current system behavior + for domain in ["connection", "object-lifecycle", "batch-ops", + "search-browse", "transport", "quality", "config"]: + self.assertTrue(os.path.isfile(os.path.join(specs_dir, domain, "spec.md"))) + + +# ═══════════════════════════════════════════ +# TestCLAUDEmd +# ═══════════════════════════════════════════ +class TestCLAUDEmd(unittest.TestCase): + + def test_claude_md_exists(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + self.assertTrue(os.path.isfile(os.path.join(project_root, "docs", "dev", "CLAUDE.md"))) + + def test_agents_md_exists(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + self.assertTrue(os.path.isfile(os.path.join(project_root, "docs", "dev", "AGENTS.md"))) + + +# ═══════════════════════════════════════════ +# TestModuleStructure +# ═══════════════════════════════════════════ +class TestModuleStructure(unittest.TestCase): + + def test_cli_package(self): + import sapcli.cli + import sapcli.cli.parser + import sapcli.cli.output + + def test_commands_package(self): + import sapcli.commands + import sapcli.commands.crud + import sapcli.commands.batch + import sapcli.commands.config_cmd + import sapcli.commands.search + import sapcli.commands.transport + import sapcli.commands.quality + import sapcli.commands.diff_cmd + import sapcli.commands.package_cmd + import sapcli.commands.cds + import sapcli.commands.analyze + import sapcli.commands.scaffold + + def test_utils_package(self): + import sapcli.utils + import sapcli.utils.xml_utils + + def test_auth_module(self): + import sapcli.auth + + def test_all_commands_importable(self): + from sapcli.commands import ( + cmd_create, cmd_delete, cmd_download, cmd_info, cmd_sync, + cmd_init, cmd_refresh, cmd_sync_all, cmd_config, + cmd_list, cmd_whereused, cmd_search, + cmd_transport, cmd_check, cmd_format, + cmd_diff, cmd_package, cmd_cds, + cmd_analyze, cmd_scaffold, + ) + + +# ═══════════════════════════════════════════ +if __name__ == "__main__": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + unittest.main(verbosity=2) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_activate.py b/tests/unit/test_activate.py new file mode 100644 index 0000000..f51d9dd --- /dev/null +++ b/tests/unit/test_activate.py @@ -0,0 +1,169 @@ +"""Tests for sapcli.commands.activate — activate command.""" +import argparse +import unittest +from unittest.mock import MagicMock + +from sapcli.commands.activate import cmd_activate + + +def _make_client(): + """创建完整 mock 的 ADTClient。""" + client = MagicMock() + client.host = "https://sap.example.com" + client.sap_client = "100" + client.csrf_token = "test-csrf-token" + client.user = "TESTUSER" + client.session = MagicMock() + client._headers.return_value = { + "content-type": "application/xml", + "x-csrf-token": "test-csrf-token", + } + return client + + +class TestCmdActivateSingle(unittest.TestCase): + """单对象激活测试。""" + + def test_activate_success(self): + """激活成功,无消息。""" + client = _make_client() + client.activate.return_value = (True, []) + args = argparse.Namespace( + name="ZTEST", type="report", + names=None, types=None, corr_nr=None, config=None, + ) + cmd_activate(args, client) + client.activate.assert_called_once_with( + "ZTEST", "/sap/bc/adt/programs/programs/ztest", None, + ) + + def test_activate_success_with_corr_nr(self): + """带传输请求号激活。""" + client = _make_client() + client.activate.return_value = (True, []) + args = argparse.Namespace( + name="ZTEST", type="class", + names=None, types=None, corr_nr="DEVK901362", config=None, + ) + cmd_activate(args, client) + client.activate.assert_called_once_with( + "ZTEST", "/sap/bc/adt/oo/classes/ztest", "DEVK901362", + ) + + def test_activate_with_errors(self): + """激活失败,返回错误消息。""" + client = _make_client() + client.activate.return_value = ( + False, + [{"type": "E", "line": "10", "text": "Syntax error", "href": ""}], + ) + args = argparse.Namespace( + name="ZMY_CLASS", type="class", + names=None, types=None, corr_nr=None, config=None, + ) + cmd_activate(args, client) + client.activate.assert_called_once() + + def test_activate_with_warnings(self): + """激活成功但有警告。""" + client = _make_client() + client.activate.return_value = ( + True, + [{"type": "W", "line": "5", "text": "Unused variable", "href": ""}], + ) + args = argparse.Namespace( + name="ZTEST", type="report", + names=None, types=None, corr_nr=None, config=None, + ) + cmd_activate(args, client) # 不抛异常 + + def test_activate_exception(self): + """activate 抛出异常时优雅处理。""" + client = _make_client() + client.activate.side_effect = Exception("HTTP 500") + args = argparse.Namespace( + name="ZTEST", type="report", + names=None, types=None, corr_nr=None, config=None, + ) + cmd_activate(args, client) # 不抛异常,打印错误 + + +class TestCmdActivateBatch(unittest.TestCase): + """批量激活测试。""" + + def test_batch_activate_all_success(self): + """批量激活全部成功。""" + client = _make_client() + client.activate.return_value = (True, []) + args = argparse.Namespace( + name=None, type=None, + names="ZCLS1,ZCLS2,ZCLS3", + types="class,class,class", + corr_nr=None, config=None, + ) + cmd_activate(args, client) + self.assertEqual(client.activate.call_count, 3) + + def test_batch_activate_with_corr_nr(self): + """批量激活带传输请求号。""" + client = _make_client() + client.activate.return_value = (True, []) + args = argparse.Namespace( + name=None, type=None, + names="ZCLS1,ZREP1", + types="class,report", + corr_nr="DEVK901368", config=None, + ) + cmd_activate(args, client) + calls = client.activate.call_args_list + self.assertEqual(len(calls), 2) + # 验证都传了 corr_nr + for call in calls: + self.assertEqual(call[0][2], "DEVK901368") + + def test_batch_activate_mismatch_count(self): + """names 和 types 数量不匹配时报错。""" + client = _make_client() + args = argparse.Namespace( + name=None, type=None, + names="ZCLS1,ZCLS2", + types="class", + corr_nr=None, config=None, + ) + cmd_activate(args, client) + client.activate.assert_not_called() + + def test_batch_activate_partial_failure(self): + """批量激活部分失败。""" + client = _make_client() + client.activate.side_effect = [ + (True, []), + (False, [{"type": "E", "line": "1", "text": "Error", "href": ""}]), + ] + args = argparse.Namespace( + name=None, type=None, + names="ZCLS1,ZCLS2", + types="class,class", + corr_nr=None, config=None, + ) + cmd_activate(args, client) + self.assertEqual(client.activate.call_count, 2) + + +class TestCmdActivateNoneMode(unittest.TestCase): + """既没有 --name 也没有 --names 的情况。""" + + def test_no_name_no_names(self): + """没有提供 name 或 names 时,访问 args.name 应为 None,触发 AttributeError。""" + client = _make_client() + args = argparse.Namespace( + name=None, type=None, + names=None, types=None, corr_nr=None, config=None, + ) + # name=None 传给 parse_object_name 会失败 + with self.assertRaises((TypeError, AttributeError, ValueError)): + cmd_activate(args, client) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_app_config.py b/tests/unit/test_app_config.py new file mode 100644 index 0000000..3445c86 --- /dev/null +++ b/tests/unit/test_app_config.py @@ -0,0 +1,327 @@ +"""app.py main() 和 config_cmd.py 单元测试。 + +运行: python tests/unit/test_app_config.py +""" +from __future__ import annotations + +import io +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch, call + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "assets")) + +from sapcli.cli.app import main +from sapcli.commands.config_cmd import cmd_config +from sapcli.exceptions import ConfigError, SapCliError + + +# ────────────────────────────────────────────────────────────────────── +# 辅助:构造一个假的 SAPConfig +# ────────────────────────────────────────────────────────────────────── +def _fake_config(): + """返回一个 (SAPConfig, loaded_from) 元组。""" + from sapcli.config import SAPConfig + cfg = SAPConfig(host="sap.example.com", client="100", user="TESTUSER", password="secret123") + return cfg, "/tmp/fake_config.ini" + + +# ────────────────────────────────────────────────────────────────────── +# app.py main() 测试 +# ────────────────────────────────────────────────────────────────────── +class TestMainNoCommand(unittest.TestCase): + """无子命令时 print_help + exit(1)。""" + + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_no_command(self, mock_enc, mock_log, mock_ssl): + with patch("sys.argv", ["sap-cli"]): + with self.assertRaises(SystemExit) as ctx: + main() + self.assertEqual(ctx.exception.code, 1) + + +class TestMainConfigShow(unittest.TestCase): + """config 命令 → cmd_config 被调用。""" + + @patch("sapcli.cli.app.cmd_config") + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_config_show(self, mock_enc, mock_log, mock_ssl, mock_cmd_config): + with patch("sys.argv", ["sap-cli", "config", "show"]): + main() + mock_cmd_config.assert_called_once() + # 第一个位置参数是 args (Namespace) + args_passed = mock_cmd_config.call_args[0][0] + self.assertEqual(args_passed.command, "config") + + +class TestMainAuthLogin(unittest.TestCase): + """auth login → cmd_auth_login 被调用。""" + + @patch("sapcli.cli.app.cmd_auth_login") + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_auth_login(self, mock_enc, mock_log, mock_ssl, mock_login): + with patch("sys.argv", ["sap-cli", "auth", "login"]): + main() + mock_login.assert_called_once() + + +class TestMainAuthLogout(unittest.TestCase): + """auth logout → cmd_auth_logout 被调用。""" + + @patch("sapcli.cli.app.cmd_auth_logout") + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_auth_logout(self, mock_enc, mock_log, mock_ssl, mock_logout): + with patch("sys.argv", ["sap-cli", "auth", "logout"]): + main() + mock_logout.assert_called_once() + + +class TestMainAuthStatus(unittest.TestCase): + """auth status (无子命令) → cmd_auth_status 被调用。""" + + @patch("sapcli.cli.app.cmd_auth_status") + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_auth_status(self, mock_enc, mock_log, mock_ssl, mock_status): + with patch("sys.argv", ["sap-cli", "auth", "status"]): + main() + mock_status.assert_called_once() + + +class TestMainConfigError(unittest.TestCase): + """load_config 抛 ConfigError → exit(1)。""" + + @patch("sapcli.cli.app.load_config", side_effect=ConfigError("配置缺失")) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_config_error(self, mock_enc, mock_log, mock_ssl, mock_load): + with patch("sys.argv", ["sap-cli", "download", "--name", "ZT", "--type", "report", "--path", "./out"]): + with self.assertRaises(SystemExit) as ctx: + main() + self.assertEqual(ctx.exception.code, 1) + + +class TestMainDownloadSuccess(unittest.TestCase): + """download 命令全链路 mock:load_config → ADTClient → login → cmd_download。""" + + @patch("sapcli.cli.app.cmd_download") + @patch("sapcli.cli.app.ADTClient") + @patch("sapcli.cli.app.load_config", return_value=_fake_config()) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_download_success(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download): + mock_client = MagicMock() + mock_adt_cls.return_value = mock_client + + with patch("sys.argv", ["sap-cli", "download", "--name", "ZTEST", "--type", "report", "--path", "./out"]): + main() + + # 验证 ADTClient 被正确构造 + mock_adt_cls.assert_called_once_with( + "sap.example.com", "100", "TESTUSER", "secret123", verify_ssl=False + ) + # 验证 login 被调用 + mock_client.login.assert_called_once() + # 验证 cmd_download 被调用 + mock_download.assert_called_once() + # cmd_download 的第二个参数应该是 mock_client + self.assertIs(mock_download.call_args[0][1], mock_client) + + +class TestMainSapCliError(unittest.TestCase): + """handler 抛 SapCliError → exit(1)。""" + + @patch("sapcli.cli.app.cmd_download", side_effect=SapCliError("下载失败")) + @patch("sapcli.cli.app.ADTClient") + @patch("sapcli.cli.app.load_config", return_value=_fake_config()) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_sap_cli_error(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download): + mock_adt_cls.return_value = MagicMock() + + with patch("sys.argv", ["sap-cli", "download", "--name", "ZTEST", "--type", "report", "--path", "./out"]): + with self.assertRaises(SystemExit) as ctx: + main() + self.assertEqual(ctx.exception.code, 1) + + +class TestMainVerifySslFlag(unittest.TestCase): + """--verify-ssl 参数传递给 ADTClient。""" + + @patch("sapcli.cli.app.cmd_download") + @patch("sapcli.cli.app.ADTClient") + @patch("sapcli.cli.app.load_config", return_value=_fake_config()) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_verify_ssl_flag(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download): + mock_adt_cls.return_value = MagicMock() + + with patch("sys.argv", [ + "sap-cli", "--verify-ssl", + "download", "--name", "ZTEST", "--type", "report", "--path", "./out", + ]): + main() + + # verify_ssl 应为 True + mock_adt_cls.assert_called_once_with( + "sap.example.com", "100", "TESTUSER", "secret123", verify_ssl=True + ) + + +class TestMainProfileFlag(unittest.TestCase): + """--profile 参数传递给 load_config。""" + + @patch("sapcli.cli.app.cmd_download") + @patch("sapcli.cli.app.ADTClient") + @patch("sapcli.cli.app.load_config", return_value=_fake_config()) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_profile_flag(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download): + mock_adt_cls.return_value = MagicMock() + + with patch("sys.argv", [ + "sap-cli", "--profile", "DEV", + "download", "--name", "ZTEST", "--type", "report", "--path", "./out", + ]): + main() + + # load_config 应收到 profile="DEV" + mock_load.assert_called_once() + # load_config(config_path, profile=...) + # parser 中 --config 的 dest 是 "config",--profile 的 dest 是 "profile" + # load_config 调用签名: load_config(args.config, profile=getattr(args, "profile", None)) + call_kwargs = mock_load.call_args + self.assertEqual(call_kwargs[0][0], None) # args.config = None + # 第二个参数是 profile + # load_config 被调用为 load_config(args.config, profile=args.profile) + # 所以要看 kwargs 或位置参数 + if call_kwargs[1]: + self.assertEqual(call_kwargs[1].get("profile"), "DEV") + else: + # 可能是位置参数方式传递 + self.assertEqual(call_kwargs[0][1], "DEV") + + +# ────────────────────────────────────────────────────────────────────── +# config_cmd.py 测试 +# ────────────────────────────────────────────────────────────────────── +class TestConfigShowDefault(unittest.TestCase): + """config show → 显示默认配置。""" + + # _config_show 内部用 from sapcli.config import load_config,所以 patch 源模块 + @patch("sapcli.config.load_config", return_value=_fake_config()) + def test_config_show_default(self, mock_load): + import argparse + args = argparse.Namespace(config=None, config_action="show") + # 捕获 stdout + with patch("sys.stdout", new_callable=io.StringIO) as mock_out: + cmd_config(args) + + output = mock_out.getvalue() + self.assertIn("sap-cli 当前配置", output) + self.assertIn("sap.example.com", output) + self.assertIn("100", output) + self.assertIn("TESTUSER", output) + self.assertIn("***", output) + + +class TestConfigListProfiles(unittest.TestCase): + """config list-profiles → 显示多 profile。""" + + def test_config_list_profiles(self): + import argparse + import configparser + + # 创建一个临时配置文件包含多个 section + with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f: + parser = configparser.ConfigParser() + parser.add_section("SAP") + parser.set("SAP", "host", "sap.example.com") + parser.add_section("DEV") + parser.set("DEV", "host", "dev.sap.example.com") + parser.write(f) + tmp_path = f.name + + try: + args = argparse.Namespace(config=tmp_path, config_action="list-profiles") + with patch("sys.stdout", new_callable=io.StringIO) as mock_out: + cmd_config(args) + + output = mock_out.getvalue() + self.assertIn("sap-cli 可用 profile", output) + self.assertIn("SAP", output) + self.assertIn("DEV", output) + self.assertIn("(默认)", output) # SAP section 是默认的 + finally: + os.unlink(tmp_path) + + +class TestConfigSetWritesFile(unittest.TestCase): + """config set → 写入配置文件。""" + + def test_config_set_writes_file(self): + import argparse + import configparser + + with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f: + f.write("") + tmp_path = f.name + + try: + args = argparse.Namespace( + config=tmp_path, + config_action="set", + key="host", + value="newhost.example.com", + profile=None, + ) + with patch("sys.stdout", new_callable=io.StringIO): + cmd_config(args) + + # 验证文件已写入 + parser = configparser.ConfigParser() + parser.read(tmp_path, encoding="utf-8") + self.assertTrue(parser.has_section("SAP")) + self.assertEqual(parser.get("SAP", "host"), "newhost.example.com") + finally: + os.unlink(tmp_path) + + +class TestConfigSetInvalidKey(unittest.TestCase): + """config set 无效 key → 报错。""" + + def test_config_set_invalid_key(self): + import argparse + + args = argparse.Namespace( + config=None, + config_action="set", + key="invalid_key", + value="some_value", + profile=None, + ) + with patch("sys.stdout", new_callable=io.StringIO) as mock_out: + cmd_config(args) + + output = mock_out.getvalue() + self.assertIn("不支持的配置项", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_batch_analyze.py b/tests/unit/test_batch_analyze.py new file mode 100644 index 0000000..8748730 --- /dev/null +++ b/tests/unit/test_batch_analyze.py @@ -0,0 +1,733 @@ +"""batch.py / analyze.py 深入单元测试 — 失败/边界场景。 + +运行: python tests/unit/test_batch_analyze.py +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch, call + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + +from sapcli.manifest import Manifest, ManifestEntry + + +# ─── Helpers ─── + +def _make_client(): + """创建完整 mock 的 ADTClient。""" + client = MagicMock() + client.host = "https://sap.example.com" + client.sap_client = "100" + client.csrf_token = "test-csrf-token" + client.user = "TESTUSER" + client.session = MagicMock() + return client + + +def _args(**kwargs): + defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +def _write_manifest(td, entries, version=1): + """在 td 下写 manifest.json。entries: list[ManifestEntry]。""" + data = { + "version": version, + "objects": {e.name: e.to_dict() for e in entries}, + } + path = os.path.join(td, "manifest.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + return path + + +def _make_entry(name="ZTEST", type_="report", file_="reports/ztest.abap", + status="not_exists", corr_nr=None, depends_on=None, + last_sync=None, last_sync_result="pending"): + return ManifestEntry( + name=name, + type=type_, + file=file_, + system_status=status, + corr_nr=corr_nr, + depends_on=depends_on or [], + last_sync=last_sync, + last_sync_result=last_sync_result, + ) + + +# ═══════════════════════════════════════════ +# batch.py — cmd_init +# ═══════════════════════════════════════════ + +class TestCmdInitEmptyDir(unittest.TestCase): + """cmd_init 无 .abap 文件的空目录。""" + + def test_no_abap_files_returns_early(self): + from sapcli.commands.batch import cmd_init + client = _make_client() + with tempfile.TemporaryDirectory() as td: + # 空目录,没有任何子目录或 .abap 文件 + cmd_init(_args(path=td), client) + # 不应创建 manifest.json + self.assertFalse(os.path.isfile(os.path.join(td, "manifest.json"))) + # client 不应被调用 + client.get_object_status.assert_not_called() + + def test_no_abap_files_with_subdirs(self): + """有标准子目录但无 .abap 文件。""" + from sapcli.commands.batch import cmd_init + client = _make_client() + with tempfile.TemporaryDirectory() as td: + for d in ("reports", "classes", "interfaces"): + os.makedirs(os.path.join(td, d)) + cmd_init(_args(path=td), client) + self.assertFalse(os.path.isfile(os.path.join(td, "manifest.json"))) + client.get_object_status.assert_not_called() + + +class TestCmdInitDirNotExists(unittest.TestCase): + """cmd_init 目录不存在。""" + + def test_nonexistent_dir_raises_config_error(self): + from sapcli.commands.batch import cmd_init + from sapcli.exceptions import ConfigError + client = _make_client() + with self.assertRaises(ConfigError) as ctx: + cmd_init(_args(path="/nonexistent/path/xyz"), client) + self.assertIn("不存在", str(ctx.exception)) + + +# ═══════════════════════════════════════════ +# batch.py — cmd_sync_all +# ═══════════════════════════════════════════ + +class TestCmdSyncAllNoManifest(unittest.TestCase): + """cmd_sync_all 无清单文件报错。""" + + def test_missing_manifest_raises_file_not_found(self): + from sapcli.commands.batch import cmd_sync_all + client = _make_client() + with tempfile.TemporaryDirectory() as td: + # 不创建 manifest.json + with self.assertRaises(FileNotFoundError): + cmd_sync_all(_args(path=td), client) + + +class TestCmdSyncAllDryRun(unittest.TestCase): + """cmd_sync_all dry-run 模式:只打印计划,不实际同步。""" + + def test_dry_run_does_not_sync(self): + from sapcli.commands.batch import cmd_sync_all + client = _make_client() + with tempfile.TemporaryDirectory() as td: + # 创建源文件 + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + src_file = os.path.join(report_dir, "ztest.abap") + with open(src_file, "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + + entry = _make_entry(status="inactive", last_sync_result="failed") + _write_manifest(td, [entry]) + + with patch("builtins.print") as mock_print: + cmd_sync_all(_args(path=td, dry_run=True), client) + + # _sync_single 不应被调用 + # 检查打印了 dry-run 标题 + printed = "\n".join(str(c) for c in mock_print.call_args_list) + self.assertIn("dry-run", printed) + + def test_dry_run_shows_pending_objects(self): + from sapcli.commands.batch import cmd_sync_all + client = _make_client() + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + with open(os.path.join(report_dir, "zother.abap"), "w", encoding="utf-8") as f: + f.write("REPORT zother.") + + e1 = _make_entry(name="ZTEST", file_="reports/ztest.abap", status="not_exists") + e2 = _make_entry(name="ZOTHER", file_="reports/zother.abap", status="inactive") + _write_manifest(td, [e1, e2]) + + with patch("builtins.print") as mock_print: + cmd_sync_all(_args(path=td, dry_run=True), client) + + printed = "\n".join(str(c) for c in mock_print.call_args_list) + self.assertIn("ZTEST", printed) + self.assertIn("ZOTHER", printed) + + +class TestCmdSyncAllFailFast(unittest.TestCase): + """cmd_sync_all 有对象同步失败(fail-fast)。""" + + @patch("sapcli.commands.batch._sync_single") + def test_fail_fast_stops_on_first_error(self, mock_sync): + from sapcli.commands.batch import cmd_sync_all + mock_sync.return_value = (False, "同步失败: 语法错误", None) + client = _make_client() + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + + e1 = _make_entry(name="ZFIRST", file_="reports/zfirst.abap", status="inactive") + e2 = _make_entry(name="ZSECOND", file_="reports/zsecond.abap", status="inactive") + _write_manifest(td, [e1, e2]) + + for fname in ("zfirst.abap", "zsecond.abap"): + with open(os.path.join(report_dir, fname), "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + + with patch("builtins.print"): + cmd_sync_all(_args(path=td, fail_fast=True), client) + + # fail_fast → 第一个失败后 break,_sync_single 最多调用 1 次 + self.assertLessEqual(mock_sync.call_count, 1) + + @patch("sapcli.commands.batch._sync_single") + def test_no_fail_fast_continues_on_error(self, mock_sync): + from sapcli.commands.batch import cmd_sync_all + # 第一次失败,第二次成功 + mock_sync.side_effect = [ + (False, "同步失败", None), + (True, None, "DEVK001"), + ] + client = _make_client() + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + + e1 = _make_entry(name="ZFIRST", file_="reports/zfirst.abap", status="inactive") + e2 = _make_entry(name="ZSECOND", file_="reports/zsecond.abap", status="inactive") + _write_manifest(td, [e1, e2]) + + for fname in ("zfirst.abap", "zsecond.abap"): + with open(os.path.join(report_dir, fname), "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + + with patch("builtins.print"): + cmd_sync_all(_args(path=td, fail_fast=False), client) + + # 没有 fail_fast → 继续同步第二个 + self.assertEqual(mock_sync.call_count, 2) + + @patch("sapcli.commands.batch._sync_single") + def test_cascading_skip_on_dep_failure(self, mock_sync): + """依赖失败时,后续依赖它的对象被级联跳过。""" + from sapcli.commands.batch import cmd_sync_all + mock_sync.return_value = (False, "同步失败", None) + client = _make_client() + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + + e1 = _make_entry(name="ZBASE", file_="reports/zbase.abap", status="inactive") + e2 = _make_entry(name="ZCHILD", file_="reports/zchild.abap", status="inactive", + depends_on=["ZBASE"]) + _write_manifest(td, [e1, e2]) + + for fname in ("zbase.abap", "zchild.abap"): + with open(os.path.join(report_dir, fname), "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + + with patch("builtins.print") as mock_print: + cmd_sync_all(_args(path=td, fail_fast=False), client) + + printed = "\n".join(str(c) for c in mock_print.call_args_list) + # ZCHILD 应被跳过(依赖失败) + self.assertIn("ZCHILD", printed) + self.assertIn("跳过", printed) + + +class TestCmdSyncAllMissingFile(unittest.TestCase): + """cmd_sync_all 清单中对象对应的本地文件缺失。""" + + @patch("sapcli.commands.batch._sync_single") + def test_missing_file_is_skipped(self, mock_sync): + from sapcli.commands.batch import cmd_sync_all + mock_sync.return_value = (True, None, "DEVK001") + client = _make_client() + + with tempfile.TemporaryDirectory() as td: + # 不创建源文件 → 清单中 file 指向不存在的文件 + e1 = _make_entry(name="ZMISSING", file_="reports/zmissing.abap", status="inactive") + _write_manifest(td, [e1]) + + with patch("builtins.print"): + cmd_sync_all(_args(path=td), client) + + # _sync_single 不应被调用(文件缺失,对象被跳过) + mock_sync.assert_not_called() + + +class TestCmdSyncAllAlreadySynced(unittest.TestCase): + """cmd_sync_all 跳过已同步且激活的对象。""" + + @patch("sapcli.commands.batch._sync_single") + def test_active_success_is_skipped(self, mock_sync): + from sapcli.commands.batch import cmd_sync_all + client = _make_client() + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "zactive.abap"), "w", encoding="utf-8") as f: + f.write("REPORT zactive.") + + e1 = _make_entry( + name="ZACTIVE", file_="reports/zactive.abap", + status="active", last_sync_result="success", + last_sync="2026-01-01T00:00:00+00:00", + ) + _write_manifest(td, [e1]) + + with patch("builtins.print"): + cmd_sync_all(_args(path=td), client) + + mock_sync.assert_not_called() + + +class TestCmdSyncAllCyclicDependency(unittest.TestCase): + """cmd_sync_all 拓扑排序检测到循环依赖时抛异常。""" + + def test_cyclic_raises(self): + from sapcli.commands.batch import cmd_sync_all + from sapcli.exceptions import CyclicDependencyError + client = _make_client() + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + for fname in ("za.abap", "zb.abap"): + with open(os.path.join(report_dir, fname), "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + + e1 = _make_entry(name="ZA", file_="reports/za.abap", status="inactive", + depends_on=["ZB"]) + e2 = _make_entry(name="ZB", file_="reports/zb.abap", status="inactive", + depends_on=["ZA"]) + _write_manifest(td, [e1, e2]) + + with self.assertRaises(CyclicDependencyError): + cmd_sync_all(_args(path=td), client) + + +class TestCmdSyncAllNotExistsAutoCreate(unittest.TestCase): + """cmd_sync_all 对象不存在时自动创建,创建失败则 fail-fast 或 continue。""" + + def test_auto_create_failure_fail_fast(self): + from sapcli.commands.batch import cmd_sync_all + client = _make_client() + client.create_object.side_effect = Exception("SAP 连接超时") + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "znew.abap"), "w", encoding="utf-8") as f: + f.write("REPORT znew.") + + e1 = _make_entry(name="ZNEW", file_="reports/znew.abap", status="not_exists") + _write_manifest(td, [e1]) + + with patch("builtins.print"): + cmd_sync_all(_args(path=td, fail_fast=True), client) + + client.create_object.assert_called_once() + + def test_auto_create_failure_continue(self): + from sapcli.commands.batch import cmd_sync_all + client = _make_client() + client.create_object.side_effect = Exception("SAP 连接超时") + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "znew.abap"), "w", encoding="utf-8") as f: + f.write("REPORT znew.") + + e1 = _make_entry(name="ZNEW", file_="reports/znew.abap", status="not_exists") + _write_manifest(td, [e1]) + + with patch("builtins.print"): + # 不应抛出异常,只是标记为 failed + cmd_sync_all(_args(path=td, fail_fast=False), client) + + # 验证 manifest 被更新了(即使失败也保存) + self.assertTrue(os.path.isfile(os.path.join(td, "manifest.json"))) + + +class TestCmdSyncAllFunctionAutoGroup(unittest.TestCase): + """cmd_sync_all function 类型自动创建函数组。""" + + def test_auto_creates_function_group(self): + from sapcli.commands.batch import cmd_sync_all + client = _make_client() + client.function_group_exists.return_value = False + client.create_function_group.return_value = True + # create_object for the function module itself → succeed + client.create_object.return_value = ("/uri/zgrp/zfunc", "/uri/zgrp/zfunc/source/main") + + with tempfile.TemporaryDirectory() as td: + func_dir = os.path.join(td, "functions", "zgrp") + os.makedirs(func_dir) + with open(os.path.join(func_dir, "zfunc.abap"), "w", encoding="utf-8") as f: + f.write("FUNCTION zfunc.") + + e1 = _make_entry( + name="ZGRP/ZFUNC", type_="function", + file_="functions/zgrp/zfunc.abap", status="not_exists", + ) + _write_manifest(td, [e1]) + + with patch("sapcli.commands.batch._sync_single") as mock_sync, \ + patch("builtins.print"): + # _sync_single succeeds + mock_sync.return_value = (True, None, "DEVK001") + cmd_sync_all(_args(path=td), client) + + client.create_function_group.assert_called_once_with("ZGRP") + + +# ═══════════════════════════════════════════ +# batch.py — cmd_init SAP 查询失败 +# ═══════════════════════════════════════════ + +class TestCmdInitSAPQueryFailure(unittest.TestCase): + """cmd_init 查询 SAP 状态失败时使用 fallback 状态。""" + + def test_query_failure_defaults_to_not_exists(self): + from sapcli.commands.batch import cmd_init + client = _make_client() + client.get_object_status.side_effect = Exception("网络超时") + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + + with patch("builtins.print"): + cmd_init(_args(path=td), client) + + # 应仍然创建 manifest.json + manifest_path = os.path.join(td, "manifest.json") + self.assertTrue(os.path.isfile(manifest_path)) + + with open(manifest_path, "r", encoding="utf-8") as f: + data = json.load(f) + # 失败时 fallback 到 not_exists + self.assertEqual(data["objects"]["ZTEST"]["system_status"], "not_exists") + + +# ═══════════════════════════════════════════ +# batch.py — cmd_refresh 查询失败 +# ═══════════════════════════════════════════ + +class TestCmdRefreshQueryFailure(unittest.TestCase): + """cmd_refresh 单个对象查询失败时跳过。""" + + def test_query_failure_skips_entry(self): + from sapcli.commands.batch import cmd_refresh + client = _make_client() + client.get_object_status.side_effect = Exception("SAP 不可用") + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + entry = _make_entry(status="active") + _write_manifest(td, [entry]) + + with patch("builtins.print"): + cmd_refresh(_args(path=td), client) + + # 不抛异常,只跳过失败的对象 + client.get_object_status.assert_called() + + +# ═══════════════════════════════════════════ +# analyze.py — cmd_analyze +# ═══════════════════════════════════════════ + +class TestCmdAnalyzeDirNotExists(unittest.TestCase): + """cmd_analyze 目录不存在。""" + + def test_nonexistent_dir_prints_error(self): + from sapcli.commands.analyze import cmd_analyze + client = _make_client() + with patch("builtins.print") as mock_print: + cmd_analyze(_args(path="/nonexistent/path/xyz"), client) + printed = "\n".join(str(c) for c in mock_print.call_args_list) + self.assertIn("不存在", printed) + + +class TestCmdAnalyzeNoAbapFiles(unittest.TestCase): + """cmd_analyze 项目无 .abap 文件(空项目)。""" + + def test_empty_project_no_crash(self): + from sapcli.commands.analyze import cmd_analyze + client = _make_client() + with tempfile.TemporaryDirectory() as td: + with patch("builtins.print") as mock_print: + cmd_analyze(_args(path=td), client) + printed = "\n".join(str(c) for c in mock_print.call_args_list) + self.assertIn("未找到", printed) + + +class TestCmdAnalyzeNoDependencies(unittest.TestCase): + """cmd_analyze 项目中 .abap 文件无外部依赖。""" + + def test_no_z_deps_shows_no_dependencies(self): + from sapcli.commands.analyze import cmd_analyze + client = _make_client() + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "zsimple.abap"), "w", encoding="utf-8") as f: + f.write("REPORT zsimple.\nWRITE: / 'hello'.\n") + + with patch("builtins.print") as mock_print: + cmd_analyze(_args(path=td), client) + + printed = "\n".join(str(c) for c in mock_print.call_args_list) + self.assertIn("无外部依赖", printed) + + +class TestCmdAnalyzeWithDependencies(unittest.TestCase): + """cmd_analyze 正确检测 Z/Y 开头的外部依赖。""" + + def test_detects_type_ref_to(self): + from sapcli.commands.analyze import cmd_analyze, _analyze_dependencies + source = "DATA: lo_obj TYPE REF TO ZCL_MY_CLASS." + deps = _analyze_dependencies(source) + self.assertIn("ZCL_MY_CLASS", deps) + + def test_detects_call_function(self): + from sapcli.commands.analyze import _analyze_dependencies + source = "CALL FUNCTION 'Z_MY_FUNC'." + deps = _analyze_dependencies(source) + self.assertIn("Z_MY_FUNC", deps) + + def test_detects_create_object(self): + from sapcli.commands.analyze import _analyze_dependencies + source = "CREATE OBJECT lo_obj TYPE ZCL_FACTORY." + deps = _analyze_dependencies(source) + self.assertIn("ZCL_FACTORY", deps) + + def test_detects_type_reference(self): + from sapcli.commands.analyze import _analyze_dependencies + source = "DATA: lv_val TYPE ZCUSTOM_TYPE." + deps = _analyze_dependencies(source) + self.assertIn("ZCUSTOM_TYPE", deps) + + def test_ignores_builtin_types(self): + from sapcli.commands.analyze import _analyze_dependencies + source = "DATA: lv_str TYPE STRING.\nDATA: lv_int TYPE INT4." + deps = _analyze_dependencies(source) + self.assertEqual(deps, []) + + def test_ignores_short_names(self): + from sapcli.commands.analyze import _analyze_dependencies + source = "DATA: lv_val TYPE AB." + deps = _analyze_dependencies(source) + self.assertEqual(deps, []) + + def test_detects_y_prefix(self): + from sapcli.commands.analyze import _analyze_dependencies + source = "DATA: lo TYPE REF TO YCL_SERVICE." + deps = _analyze_dependencies(source) + self.assertIn("YCL_SERVICE", deps) + + def test_multiple_deps_deduplicated(self): + from sapcli.commands.analyze import _analyze_dependencies + source = ( + "DATA: lo1 TYPE REF TO ZCL_A.\n" + "DATA: lo2 TYPE REF TO ZCL_A.\n" + "CALL FUNCTION 'ZCL_A'.\n" + ) + deps = _analyze_dependencies(source) + # ZCL_A 只出现一次 + self.assertEqual(deps.count("ZCL_A"), 1) + + +class TestCmdAnalyzeFileIntegration(unittest.TestCase): + """cmd_analyze 完整文件分析集成测试。""" + + def test_analyze_with_deps(self): + from sapcli.commands.analyze import cmd_analyze + client = _make_client() + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "zrpt.abap"), "w", encoding="utf-8") as f: + f.write("REPORT zrpt.\nDATA: lo TYPE REF TO ZCL_HELPER.\n") + + with patch("builtins.print") as mock_print: + cmd_analyze(_args(path=td), client) + + printed = "\n".join(str(c) for c in mock_print.call_args_list) + self.assertIn("ZCL_HELPER", printed) + + def test_analyze_multiple_files(self): + from sapcli.commands.analyze import cmd_analyze + client = _make_client() + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + # 文件 1: 有依赖 + with open(os.path.join(report_dir, "za.abap"), "w", encoding="utf-8") as f: + f.write("REPORT za.\nDATA lo TYPE REF TO ZCL_DEP.\n") + # 文件 2: 无依赖 + with open(os.path.join(report_dir, "zb.abap"), "w", encoding="utf-8") as f: + f.write("REPORT zb.\nWRITE: / 'hello'.\n") + + with patch("builtins.print") as mock_print: + cmd_analyze(_args(path=td), client) + + printed = "\n".join(str(c) for c in mock_print.call_args_list) + self.assertIn("2", printed) # 2 个文件 + self.assertIn("ZCL_DEP", printed) + + +class TestCmdAnalyzeMissingFiles(unittest.TestCase): + """cmd_analyze 处理缺少文件的对象。""" + + def test_analyze_only_reads_existing_files(self): + """cmd_analyze 通过 os.walk 扫描,只读存在的 .abap 文件,不会因 manifest 中 + 引用的缺失文件而崩溃。""" + from sapcli.commands.analyze import cmd_analyze + client = _make_client() + with tempfile.TemporaryDirectory() as td: + # 创建一个有文件的对象和一个空目录 + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "zexists.abap"), "w", encoding="utf-8") as f: + f.write("REPORT zexists.\n") + + # 只分析到实际存在的文件 + with patch("builtins.print") as mock_print: + cmd_analyze(_args(path=td), client) + + printed = "\n".join(str(c) for c in mock_print.call_args_list) + self.assertIn("1", printed) # 1 个文件 + + +class TestCmdAnalyzeEmptySource(unittest.TestCase): + """_analyze_dependencies 输入空源码。""" + + def test_empty_source_no_deps(self): + from sapcli.commands.analyze import _analyze_dependencies + deps = _analyze_dependencies("") + self.assertEqual(deps, []) + + def test_whitespace_only_no_deps(self): + from sapcli.commands.analyze import _analyze_dependencies + deps = _analyze_dependencies(" \n \n ") + self.assertEqual(deps, []) + + +# ═══════════════════════════════════════════ +# sorter.py — 环形依赖检测(补充) +# ═══════════════════════════════════════════ + +class TestTopologicalSortCyclic(unittest.TestCase): + """topological_sort 检测循环依赖。""" + + def test_simple_two_node_cycle(self): + from sapcli.sorter import topological_sort + from sapcli.exceptions import CyclicDependencyError + + e1 = _make_entry(name="ZA", depends_on=["ZB"]) + e2 = _make_entry(name="ZB", depends_on=["ZA"]) + with self.assertRaises(CyclicDependencyError) as ctx: + topological_sort([e1, e2]) + self.assertTrue(len(ctx.exception.cycle_members) > 0) + + def test_three_node_cycle(self): + from sapcli.sorter import topological_sort + from sapcli.exceptions import CyclicDependencyError + + e1 = _make_entry(name="ZA", depends_on=["ZC"]) + e2 = _make_entry(name="ZB", depends_on=["ZA"]) + e3 = _make_entry(name="ZC", depends_on=["ZB"]) + with self.assertRaises(CyclicDependencyError): + topological_sort([e1, e2, e3]) + + def test_self_dependency(self): + from sapcli.sorter import topological_sort + from sapcli.exceptions import CyclicDependencyError + + e1 = _make_entry(name="ZA", depends_on=["ZA"]) + with self.assertRaises(CyclicDependencyError): + topological_sort([e1]) + + def test_no_deps_returns_all(self): + from sapcli.sorter import topological_sort + + e1 = _make_entry(name="ZA") + e2 = _make_entry(name="ZB") + result = topological_sort([e1, e2]) + names = [e.name for e in result] + self.assertEqual(set(names), {"ZA", "ZB"}) + + def test_external_dep_ignored(self): + """depends_on 引用不在列表中的外部对象,不报错。""" + from sapcli.sorter import topological_sort + + e1 = _make_entry(name="ZA", depends_on=["Z_EXTERNAL"]) + result = topological_sort([e1]) + self.assertEqual(len(result), 1) + + def test_empty_list_returns_empty(self): + from sapcli.sorter import topological_sort + self.assertEqual(topological_sort([]), []) + + +# ═══════════════════════════════════════════ +# batch.py — cmd_sync_all 成功路径 +# ═══════════════════════════════════════════ + +class TestCmdSyncAllSuccess(unittest.TestCase): + """cmd_sync_all 正常同步成功。""" + + @patch("sapcli.commands.batch._sync_single") + def test_sync_updates_manifest(self, mock_sync): + from sapcli.commands.batch import cmd_sync_all + mock_sync.return_value = (True, None, "DEVK999") + client = _make_client() + + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + + e1 = _make_entry(status="inactive", last_sync_result="pending") + _write_manifest(td, [e1]) + + with patch("builtins.print"): + cmd_sync_all(_args(path=td), client) + + # 验证 manifest 更新 + with open(os.path.join(td, "manifest.json"), "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["objects"]["ZTEST"]["system_status"], "active") + self.assertEqual(data["objects"]["ZTEST"]["last_sync_result"], "success") + self.assertEqual(data["objects"]["ZTEST"]["corr_nr"], "DEVK999") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..92ab61e --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,384 @@ +"""CLI 层(parser.py + app.py)单元测试。 + +运行: python tests/unit/test_cli.py +""" +from __future__ import annotations + +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "assets")) + +from sapcli.cli.parser import build_parser +from sapcli.types import all_type_keys + + +class TestParserBuild(unittest.TestCase): + """test_parser_build: build_parser() 返回 ArgumentParser""" + + def test_returns_argument_parser(self): + import argparse + parser = build_parser() + self.assertIsInstance(parser, argparse.ArgumentParser) + + def test_parser_has_description(self): + parser = build_parser() + self.assertIn("sap-cli", parser.description) + + +class TestParserDownload(unittest.TestCase): + """test_parser_download: 解析 download 命令""" + + def test_download_full_args(self): + parser = build_parser() + args = parser.parse_args([ + "download", "--name", "ZTEST", "--type", "report", "--path", "./out", + ]) + self.assertEqual(args.command, "download") + self.assertEqual(args.name, "ZTEST") + self.assertEqual(args.type, "report") + self.assertEqual(args.path, "./out") + + def test_download_class_type(self): + parser = build_parser() + args = parser.parse_args([ + "download", "--name", "ZCL_TEST", "--type", "class", "--path", "./out", + ]) + self.assertEqual(args.command, "download") + self.assertEqual(args.type, "class") + + +class TestParserSyncSingle(unittest.TestCase): + """test_parser_sync_single: 解析 sync 单对象模式""" + + def test_sync_single(self): + parser = build_parser() + args = parser.parse_args([ + "sync", "--name", "ZTEST", "--type", "report", "--path", "./file.abap", + ]) + self.assertEqual(args.command, "sync") + self.assertEqual(args.name, "ZTEST") + self.assertEqual(args.type, "report") + self.assertEqual(args.path, "./file.abap") + self.assertFalse(getattr(args, "all", False)) + + +class TestParserSyncAll(unittest.TestCase): + """test_parser_sync_all: 解析 sync --all 批量模式""" + + def test_sync_all(self): + parser = build_parser() + args = parser.parse_args([ + "sync", "--all", "--path", "./project", + ]) + self.assertEqual(args.command, "sync") + self.assertTrue(args.all) + self.assertEqual(args.path, "./project") + self.assertIsNone(args.name) + self.assertIsNone(args.type) + + def test_sync_all_dry_run(self): + parser = build_parser() + args = parser.parse_args([ + "sync", "--all", "--path", "./project", "--dry-run", + ]) + self.assertTrue(args.all) + self.assertTrue(args.dry_run) + + +class TestParserCreate(unittest.TestCase): + """test_parser_create: 解析 create 命令""" + + def test_create_full_args(self): + parser = build_parser() + args = parser.parse_args([ + "create", "--name", "ZTEST", "--type", "report", + "--description", "test", "--package", "$TMP", + ]) + self.assertEqual(args.command, "create") + self.assertEqual(args.name, "ZTEST") + self.assertEqual(args.type, "report") + self.assertEqual(args.description, "test") + self.assertEqual(args.package, "$TMP") + + def test_create_default_package(self): + parser = build_parser() + args = parser.parse_args([ + "create", "--name", "ZTEST", "--type", "report", + ]) + self.assertEqual(args.package, "$TMP") + self.assertIsNone(args.description) + + +class TestParserNoCommand(unittest.TestCase): + """test_parser_no_command: 无命令时 args.command 为 None""" + + def test_no_command(self): + parser = build_parser() + args = parser.parse_args([]) + self.assertIsNone(args.command) + + +class TestParserConfig(unittest.TestCase): + """test_parser_config: 解析 config 子命令""" + + def test_config_show(self): + parser = build_parser() + args = parser.parse_args(["config", "show"]) + self.assertEqual(args.command, "config") + self.assertEqual(args.config_action, "show") + + def test_config_set(self): + parser = build_parser() + args = parser.parse_args(["config", "set", "host", "http://sap:8000"]) + self.assertEqual(args.command, "config") + self.assertEqual(args.config_action, "set") + self.assertEqual(args.key, "host") + self.assertEqual(args.value, "http://sap:8000") + + +class TestParserAuth(unittest.TestCase): + """test_parser_auth: 解析 auth 子命令""" + + def test_auth_login(self): + parser = build_parser() + args = parser.parse_args(["auth", "login"]) + self.assertEqual(args.command, "auth") + self.assertEqual(args.auth_action, "login") + + def test_auth_logout(self): + parser = build_parser() + args = parser.parse_args(["auth", "logout"]) + self.assertEqual(args.command, "auth") + self.assertEqual(args.auth_action, "logout") + + def test_auth_status(self): + parser = build_parser() + args = parser.parse_args(["auth", "status"]) + self.assertEqual(args.command, "auth") + self.assertEqual(args.auth_action, "status") + + +class TestParserTransport(unittest.TestCase): + """test_parser_transport: 解析 transport 子命令""" + + def test_transport_info(self): + parser = build_parser() + args = parser.parse_args(["transport", "info", "--corr_nr", "DEVK001"]) + self.assertEqual(args.command, "transport") + self.assertEqual(args.transport_action, "info") + self.assertEqual(args.corr_nr, "DEVK001") + + def test_transport_list(self): + parser = build_parser() + args = parser.parse_args(["transport", "list"]) + self.assertEqual(args.command, "transport") + self.assertEqual(args.transport_action, "list") + + +class TestParserInvalidType(unittest.TestCase): + """test_parser_invalid_type: --type invalid 应报错""" + + def test_invalid_type_download(self): + parser = build_parser() + with self.assertRaises(SystemExit): + parser.parse_args([ + "download", "--name", "ZTEST", "--type", "invalid", "--path", "./out", + ]) + + def test_invalid_type_create(self): + parser = build_parser() + with self.assertRaises(SystemExit): + parser.parse_args([ + "create", "--name", "ZTEST", "--type", "invalid", + ]) + + +class TestParserScaffold(unittest.TestCase): + """test_parser_scaffold: 解析 scaffold 命令""" + + def test_scaffold_with_template(self): + parser = build_parser() + args = parser.parse_args([ + "scaffold", "--name", "ZTEST", "--template", "alv-report", + ]) + self.assertEqual(args.command, "scaffold") + self.assertEqual(args.name, "ZTEST") + self.assertEqual(args.template, "alv-report") + + def test_scaffold_invalid_template(self): + parser = build_parser() + with self.assertRaises(SystemExit): + parser.parse_args([ + "scaffold", "--name", "ZTEST", "--template", "nonexistent", + ]) + + +class TestMainNoCommandExits(unittest.TestCase): + """test_main_no_command_exits: main() 无参数时 exit code 1""" + + @patch("sapcli.cli.app.sys.argv", ["sap-cli"]) + @patch("sapcli.cli.app._setup_encoding") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._suppress_ssl_warnings") + def test_main_no_command_exits(self, mock_ssl, mock_log, mock_enc): + from sapcli.cli.app import main + with self.assertRaises(SystemExit) as ctx: + main() + self.assertEqual(ctx.exception.code, 1) + + +class TestParserEdgeCases(unittest.TestCase): + """补充边界情况测试。""" + + def test_all_type_keys_accepted(self): + """确保 all_type_keys() 返回的每个类型都能被 parser 接受。""" + parser = build_parser() + for t in all_type_keys(): + args = parser.parse_args([ + "download", "--name", "ZTEST", "--type", t, "--path", "./out", + ]) + self.assertEqual(args.type, t, f"type={t} 应被 parser 接受") + + def test_sync_fail_fast(self): + parser = build_parser() + args = parser.parse_args([ + "sync", "--name", "ZTEST", "--type", "report", + "--path", "./file.abap", "--fail-fast", + ]) + self.assertTrue(args.fail_fast) + + def test_delete_with_path(self): + parser = build_parser() + args = parser.parse_args([ + "delete", "--name", "ZTEST", "--type", "report", "--path", "./project", + ]) + self.assertEqual(args.command, "delete") + self.assertEqual(args.path, "./project") + + def test_info_command(self): + parser = build_parser() + args = parser.parse_args([ + "info", "--name", "ZCL_TEST", "--type", "class", + ]) + self.assertEqual(args.command, "info") + self.assertEqual(args.name, "ZCL_TEST") + + def test_init_command(self): + parser = build_parser() + args = parser.parse_args(["init", "--path", "./project"]) + self.assertEqual(args.command, "init") + self.assertEqual(args.path, "./project") + + def test_refresh_command(self): + parser = build_parser() + args = parser.parse_args(["refresh", "--path", "./project"]) + self.assertEqual(args.command, "refresh") + + def test_list_command(self): + parser = build_parser() + args = parser.parse_args([ + "list", "--type", "report", "--prefix", "Z*", + ]) + self.assertEqual(args.command, "list") + self.assertEqual(args.prefix, "Z*") + + def test_whereused_command(self): + parser = build_parser() + args = parser.parse_args([ + "whereused", "--name", "ZTEST", "--type", "report", + ]) + self.assertEqual(args.command, "whereused") + + def test_search_command(self): + parser = build_parser() + args = parser.parse_args(["search", "--query", "CALL FUNCTION"]) + self.assertEqual(args.command, "search") + self.assertEqual(args.query, "CALL FUNCTION") + + def test_check_command(self): + parser = build_parser() + args = parser.parse_args([ + "check", "--name", "ZTEST", "--type", "report", + ]) + self.assertEqual(args.command, "check") + + def test_format_command(self): + parser = build_parser() + args = parser.parse_args([ + "format", "--name", "ZTEST", "--type", "report", + ]) + self.assertEqual(args.command, "format") + + def test_diff_command(self): + parser = build_parser() + args = parser.parse_args([ + "diff", "--name", "ZTEST", "--type", "report", "--path", "./ztest.abap", + ]) + self.assertEqual(args.command, "diff") + + def test_analyze_command(self): + parser = build_parser() + args = parser.parse_args(["analyze", "--path", "./project"]) + self.assertEqual(args.command, "analyze") + + def test_package_create(self): + parser = build_parser() + args = parser.parse_args([ + "package", "create", "--name", "ZMY_PKG", "--description", "test pkg", + ]) + self.assertEqual(args.command, "package") + self.assertEqual(args.package_action, "create") + self.assertEqual(args.name, "ZMY_PKG") + + def test_cds_download(self): + parser = build_parser() + args = parser.parse_args([ + "cds", "download", "--name", "ZMY_CDS", "--path", "./out", + ]) + self.assertEqual(args.command, "cds") + self.assertEqual(args.cds_action, "download") + + def test_transport_release(self): + parser = build_parser() + args = parser.parse_args([ + "transport", "release", "--corr_nr", "DEVK001", + ]) + self.assertEqual(args.transport_action, "release") + + def test_transport_objects(self): + parser = build_parser() + args = parser.parse_args([ + "transport", "objects", "--corr_nr", "DEVK001", + ]) + self.assertEqual(args.transport_action, "objects") + + def test_transport_create(self): + parser = build_parser() + args = parser.parse_args([ + "transport", "create", "--description", "CEM BIP 传输请求", + ]) + self.assertEqual(args.transport_action, "create") + self.assertEqual(args.description, "CEM BIP 传输请求") + + def test_global_config_option(self): + parser = build_parser() + args = parser.parse_args([ + "--config", "./my_config.ini", + "download", "--name", "ZTEST", "--type", "report", "--path", "./out", + ]) + self.assertEqual(args.config, "./my_config.ini") + + def test_global_profile_option(self): + parser = build_parser() + args = parser.parse_args([ + "--profile", "DEV", + "download", "--name", "ZTEST", "--type", "report", "--path", "./out", + ]) + self.assertEqual(args.profile, "DEV") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..5a8e2cd --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,1176 @@ +"""client.py 单元测试 — 30 个 ADT API 方法 + +运行: python tests/unit/test_client.py +""" +from __future__ import annotations + +import os +import sys +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + +from sapcli.client import ADTClient +from sapcli.exceptions import ( + LoginError, SapCliError, CreateError, DeleteError, + ActivationError, SyntaxCheckError, LockError, +) + +# ADT XML 命名空间 +TM_NS = "http://www.sap.com/cts/adt/tm" +ADTCORE_NS = "http://www.sap.com/adt/core" +CHK_NS = "http://www.sap.com/adt/checklist" +LOCK_NS = "http://www.sap.com/adt/lock" + + +def _make_client() -> ADTClient: + """创建 mock 好的 ADTClient。""" + client = ADTClient.__new__(ADTClient) + client.host = "https://sap.example.com" + client.sap_client = "100" + client.user = "TESTUSER" + client.password = "TESTPASS" + client.csrf_token = "test-csrf-token" + client.session = MagicMock() + client._stateful = False + return client + + +def _mock_resp(status_code=200, text="", content=b"", headers=None): + """快速创建 mock response。""" + resp = MagicMock() + resp.status_code = status_code + resp.text = text + resp.content = content + resp.headers = headers or {} + resp.raise_for_status = MagicMock() + return resp + + +# ═══════════════════════════════════════════ +# 基础方法 +# ═══════════════════════════════════════════ + +class TestHeaders(unittest.TestCase): + + def test_default(self): + h = _make_client()._headers() + self.assertEqual(h["content-type"], "application/xml") + self.assertEqual(h["x-csrf-token"], "test-csrf-token") + + def test_custom(self): + h = _make_client()._headers("text/plain") + self.assertEqual(h["content-type"], "text/plain") + + +class TestContextManager(unittest.TestCase): + + def test_enter_calls_login(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, headers={"x-csrf-token": "TOK"}) + result = client.__enter__() + self.assertIs(result, client) + + def test_exit_closes_session(self): + client = _make_client() + client.__exit__(None, None, None) + client.session.close.assert_called_once() + + +# ═══════════════════════════════════════════ +# 认证 +# ═══════════════════════════════════════════ + +class TestLogin(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, headers={"x-csrf-token": "NEW_TOK"}) + self.assertTrue(client.login()) + self.assertEqual(client.csrf_token, "NEW_TOK") + + def test_failure(self): + client = _make_client() + client.session.get.return_value = _mock_resp(401) + with self.assertRaises(LoginError): + client.login() + + +# ═══════════════════════════════════════════ +# 对象存在检查 +# ═══════════════════════════════════════════ + +class TestObjectExists(unittest.TestCase): + + def test_exists(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200) + self.assertTrue(client.object_exists("/uri")) + + def test_not_exists(self): + client = _make_client() + client.session.get.return_value = _mock_resp(404) + self.assertFalse(client.object_exists("/uri")) + + +# ═══════════════════════════════════════════ +# 源码读取 +# ═══════════════════════════════════════════ + +class TestGetSource(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, text="REPORT ztest.") + self.assertEqual(client.get_source("/src"), "REPORT ztest.") + + def test_not_found(self): + client = _make_client() + mock = _mock_resp(404) + mock.raise_for_status.side_effect = Exception("404") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.get_source("/nonexistent") + + +# ═══════════════════════════════════════════ +# 锁管理 +# ═══════════════════════════════════════════ + +class TestLock(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'lh_123' + f'DEVK001' + ).encode()) + handle, corr = client.lock("/uri") + self.assertEqual(handle, "lh_123") + self.assertEqual(corr, "DEVK001") + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(403) + with self.assertRaises(LockError): + client.lock("/uri") + + def test_no_handle(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + ).encode()) + with self.assertRaises(LockError): + client.lock("/uri") + + +class TestUnlock(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200) + self.assertTrue(client.unlock("/uri", "lh")) + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500) + self.assertFalse(client.unlock("/uri", "lh")) + + +# ═══════════════════════════════════════════ +# 源码写入 +# ═══════════════════════════════════════════ + +class TestSetSource(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.put.return_value = _mock_resp(200) + self.assertTrue(client.set_source("/src", "code", "lh")) + + def test_with_corr_nr(self): + client = _make_client() + client.session.put.return_value = _mock_resp(200) + self.assertTrue(client.set_source("/src", "code", "lh", corr_nr="DEVK001")) + + +# ═══════════════════════════════════════════ +# 传输请求 +# ═══════════════════════════════════════════ + +_TRANSPORT_XML = ( + f'' + f'' + f'' + f'' +).encode() + +_TRANSPORT_EMPTY = ( + f'' + f'' +).encode() + +# create_transport_request 返回的 XML(带 tm:number 属性) +_CREATE_TRANSPORT_RESP = ( + f'' + f'' + f'' + f'' +).encode() + + +class TestGetTransportRequest(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_XML) + self.assertEqual(client.get_transport_request(), "DEVK001") + + def test_none(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_EMPTY) + self.assertIsNone(client.get_transport_request()) + + +class TestListTransportRequests(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_XML) + self.assertIsInstance(client.list_transport_requests(), list) + + def test_empty(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_EMPTY) + self.assertEqual(client.list_transport_requests(), []) + + +class TestCreateTransportRequest(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201, content=_CREATE_TRANSPORT_RESP) + result = client.create_transport_request("My request") + self.assertEqual(result, "DEVK002") + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_transport_request("Fail") + + +# ═══════════════════════════════════════════ +# 激活 — 解析 type="E" 属性 +# ═══════════════════════════════════════════ + +class TestActivate(unittest.TestCase): + + def test_success_empty(self): + """空响应视为成功。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="", content=b"") + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + + def test_failure_with_error_type(self): + """包含 type="E" 属性的元素视为失败。""" + client = _make_client() + xml_body = ( + f'' + f'' + f'' + f'Activation error' + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml_body, content=xml_body.encode()) + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + + def test_http_error(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500) + with self.assertRaises(ActivationError): + client.activate("ztest", "/uri") + + # ── NW 7.40 double-activate workaround(空响应验证路径)────────── + + def _verify_xml(self, version): + """构造 verify GET 响应的 XML(bytes),adtcore:version 指定。""" + return ( + f'' + f'' + f'' + ).encode() + + def test_empty_response_already_active(self): + """空响应 + verify 显示 active → 成功,无需重试(post 仅 1 次)。""" + client = _make_client() + # 激活 POST → 200 空响应 + client.session.post.return_value = _mock_resp(200, text="", content=b"") + # verify GET → 200, version="active" + client.session.get.return_value = _mock_resp(200, content=self._verify_xml("active")) + + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + self.assertEqual(msgs, []) + # 区分真/假实现:已 active 时不应重试激活 + self.assertEqual(client.session.post.call_count, 1) + + def test_double_activate_success(self): + """空响应 + inactive → 重试一次 → active(double-activate 成功路径)。""" + client = _make_client() + # 激活 POST + 重试 POST → 均 200 空 + client.session.post.side_effect = [ + _mock_resp(200, text="", content=b""), + _mock_resp(200, text="", content=b""), + ] + # verify GET → inactive;再 verify GET → active + client.session.get.side_effect = [ + _mock_resp(200, content=self._verify_xml("inactive")), + _mock_resp(200, content=self._verify_xml("active")), + ] + + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + self.assertEqual(msgs, []) + # 区分真/假实现:确实重试了,post 被调用 2 次 + self.assertEqual(client.session.post.call_count, 2) + # 两次 verify GET + self.assertEqual(client.session.get.call_count, 2) + + def test_double_activate_still_inactive(self): + """重试后仍 inactive → 失败并返回 SE09 提示(post 调用 2 次)。""" + client = _make_client() + client.session.post.side_effect = [ + _mock_resp(200, text="", content=b""), + _mock_resp(200, text="", content=b""), + ] + # 两次 verify GET 都返回 inactive + client.session.get.side_effect = [ + _mock_resp(200, content=self._verify_xml("inactive")), + _mock_resp(200, content=self._verify_xml("inactive")), + ] + + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + # 重试过一次,共 2 次 POST + self.assertEqual(client.session.post.call_count, 2) + # 返回的消息含 SE09 或 inactive 提示 + msg_text = " ".join(m.get("text", "") for m in msgs) + self.assertTrue("SE09" in msg_text or "inactive" in msg_text) + + +# ═══════════════════════════════════════════ +# 语法检查 — 同样解析 type="E" +# ═══════════════════════════════════════════ + +class TestSyntaxCheck(unittest.TestCase): + + def test_clean(self): + """空响应视为通过。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="", content=b"") + ok, msgs = client.syntax_check("ztest", "/uri") + self.assertTrue(ok) + + def test_with_errors(self): + client = _make_client() + xml_body = ( + f'' + f'' + f'' + f'Syntax error at line 1' + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml_body, content=xml_body.encode()) + ok, msgs = client.syntax_check("ztest", "/uri") + self.assertFalse(ok) + + def test_http_error(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500) + with self.assertRaises(SyntaxCheckError): + client.syntax_check("ztest", "/uri") + + +# ═══════════════════════════════════════════ +# 删除对象(内部调 lock) +# ═══════════════════════════════════════════ + +class TestDeleteObject(unittest.TestCase): + + def _mock_lock_ok(self, client): + client.session.post.return_value = _mock_resp(200, content=( + f'lh_del' + ).encode()) + + def test_success_200(self): + client = _make_client() + self._mock_lock_ok(client) + client.session.delete.return_value = _mock_resp(200) + ok, msg = client.delete_object("/uri") + self.assertTrue(ok) + + def test_success_204(self): + client = _make_client() + self._mock_lock_ok(client) + client.session.delete.return_value = _mock_resp(204) + ok, msg = client.delete_object("/uri") + self.assertTrue(ok) + + def test_failure(self): + client = _make_client() + self._mock_lock_ok(client) + client.session.delete.return_value = _mock_resp(500, text="Cannot delete") + with self.assertRaises(DeleteError): + client.delete_object("/uri") + + def test_ddic_lock_uses_special_accept_header(self): + """delete 调用 lock 时传 DDIC lock 专用 Accept 头(缺陷4)。 + + DDIC 对象(domain/dataelement/table/structure)的 lock 端点对默认 + Accept: */* 返回 HTTP 406。参考 abap-adt-api objectcontents.ts 的 lock + 实现,需用 application/*,application/vnd.sap.as+xml;charset=UTF-8; + dataname=com.sap.adt.lock.result。 + """ + client = _make_client() + self._mock_lock_ok(client) + client.session.delete.return_value = _mock_resp(204) + client.delete_object("/uri") + # lock 的 POST 请求必须携带 DDIC lock 专用 Accept 头。 + # 注意:delete 成功后会再发一次 unlock 的 POST,故不能用 call_args + # (那是最后一次调用 = unlock),须从调用列表中挑出 _action=LOCK 的请求。 + lock_calls = [ + kw for _, kw in client.session.post.call_args_list + if kw.get("params", {}).get("_action") == "LOCK" + ] + self.assertEqual(len(lock_calls), 1, "delete 应恰好发起一次 LOCK 请求") + accept = lock_calls[0]["headers"]["Accept"] + self.assertIn("com.sap.adt.lock.result", accept) + self.assertNotEqual(accept, "*/*") + + +# ═══════════════════════════════════════════ +# 创建对象 +# ═══════════════════════════════════════════ + +class TestCreateObject(unittest.TestCase): + + def test_report_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201, headers={ + "Location": "/sap/bc/adt/programs/programs/ztest" + }) + obj_uri, src_uri = client.create_object("report", "ZTEST", "Test report") + self.assertIn("ztest", obj_uri) + + def test_class_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201, headers={ + "Location": "/sap/bc/adt/oo/classes/zcl_test" + }) + obj_uri, src_uri = client.create_object("class", "ZCL_TEST", "Test class") + self.assertIn("zcl_test", obj_uri) + + def test_with_source(self): + """有 source → lock → set_source → unlock → activate。""" + client = _make_client() + lock_resp = _mock_resp(200, content=( + f'lh' + ).encode()) + create_resp = _mock_resp(201, headers={"Location": "/sap/bc/adt/programs/programs/ztest"}) + unlock_resp = _mock_resp(200) + activate_resp = _mock_resp(200, text="", content=b"") + client.session.post.side_effect = [create_resp, lock_resp, unlock_resp, activate_resp] + client.session.put.return_value = _mock_resp(200) + obj_uri, src_uri = client.create_object("report", "ZTEST", "Test", source="REPORT ztest.") + self.assertIn("ztest", obj_uri) + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(409, text="Already exists") + with self.assertRaises(CreateError): + client.create_object("report", "ZTEST", "Test") + + +class TestBuildCreateBody(unittest.TestCase): + + def test_report(self): + body = _make_client()._build_create_body("report", "ZTEST", "Desc") + self.assertIn("ZTEST", body) + + def test_class(self): + body = _make_client()._build_create_body("class", "ZCL_TEST", "Desc") + self.assertIn("ZCL_TEST", body) + + def test_unsupported(self): + with self.assertRaises(ValueError): + _make_client()._build_create_body("unknown", "ZTEST", "Desc") + + +# ═══════════════════════════════════════════ +# DDIC 对象 — definition_body 是 XML 字符串 +# ═══════════════════════════════════════════ + +_DDIC_DOMAIN_XML = ( + f'' + f'' + f'' + f'' +) + +class TestCreateDDICObject(unittest.TestCase): + + def test_domain_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201) + # mock activate(create_ddic_object 内部调用) + client.session.post.side_effect = [ + _mock_resp(201), # create POST + _mock_resp(200, text="", content=b""), # activate POST + ] + obj_uri, src_uri = client.create_ddic_object("domain", "ZTEST_DOM", _DDIC_DOMAIN_XML) + self.assertIn("ztest_dom", obj_uri) + + def test_table_success(self): + """table 类型先 POST 创建实体,再 PUT 写入 DDL(缺陷3 修复后)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + # mock _put_ddl_source + with patch.object(client, "_put_ddl_source") as mock_ddl, \ + patch.object(client, "activate", return_value=(True, [])): + obj_uri, src_uri = client.create_ddic_object( + "table", "ZTEST_TAB", "" + ) + # 先 POST 创建实体,再 PUT DDL + self.assertTrue(client.session.post.called) + mock_ddl.assert_called_once() + self.assertIn("ztest_tab", obj_uri) + + def test_create_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_ddic_object("domain", "ZTEST", _DDIC_DOMAIN_XML) + + +# ═══════════════════════════════════════════ +# 对象状态 +# ═══════════════════════════════════════════ + +class TestGetObjectStatus(unittest.TestCase): + + def test_active(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + self.assertTrue(client.get_object_status("/uri")["exists"]) + + def test_not_exists(self): + client = _make_client() + client.session.get.return_value = _mock_resp(404) + self.assertFalse(client.get_object_status("/uri")["exists"]) + + +# ═══════════════════════════════════════════ +# 函数组 +# ═══════════════════════════════════════════ + +class TestFunctionGroup(unittest.TestCase): + + def test_exists_true(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200) + self.assertTrue(client.function_group_exists("ZGROUP")) + + def test_exists_false(self): + client = _make_client() + client.session.get.return_value = _mock_resp(404) + self.assertFalse(client.function_group_exists("ZGROUP")) + + def test_create_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_function_group("ZGROUP")) + + def test_create_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_function_group("ZGROUP") + + +# ═══════════════════════════════════════════ +# 搜索浏览 +# ═══════════════════════════════════════════ + +_LIST_TWO = ( + f'' + f'' + f'' + f'' + f'' + f'' + f'' +).encode() + +_LIST_EMPTY = ( + f'' + f'' +).encode() + + +class TestListObjects(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_LIST_TWO) + result = client.list_objects(obj_type="report") + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["name"], "Z_TEST1") + + def test_empty(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_LIST_EMPTY) + self.assertEqual(client.list_objects(), []) + + +class TestWhereUsed(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + result = client.where_used("Z_TEST", "/uri", "PROG/P") + self.assertEqual(len(result), 1) + + def test_empty(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_LIST_EMPTY) + self.assertEqual(client.where_used("Z", "/uri"), []) + + +class TestSearchCode(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + self.assertEqual(len(client.search_code("SELECT", obj_type="report")), 1) + + def test_empty(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_LIST_EMPTY) + self.assertEqual(client.search_code("NONEXISTENT"), []) + + +class TestReadSourceForDiff(unittest.TestCase): + + def test_success(self): + client = _make_client() + with patch.object(client, "get_source", return_value="REPORT ztest."): + self.assertEqual(client.read_source_for_diff("ZTEST", "report"), "REPORT ztest.") + + def test_no_src_uri_raises(self): + client = _make_client() + with self.assertRaises(SapCliError): + client.read_source_for_diff("ZTEST", "functiongroup") + + +# ═══════════════════════════════════════════ +# CDS +# ═══════════════════════════════════════════ + +class TestGetCdsSource(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, text="define view Z_TEST as select from mara { mara.matnr };") + self.assertIn("define view", client.get_cds_source("Z_TEST")) + + def test_not_found(self): + client = _make_client() + mock = _mock_resp(404) + mock.raise_for_status.side_effect = Exception("404") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.get_cds_source("NONEXISTENT") + + +class TestCreateCDS(unittest.TestCase): + + def test_success(self): + client = _make_client() + lock_resp = _mock_resp(200, content=( + f'lh_cds' + ).encode()) + create_resp = _mock_resp(201, headers={"Location": "/sap/bc/adt/dds/ddl/sources/z_test"}) + unlock_resp = _mock_resp(200) + client.session.post.side_effect = [create_resp, lock_resp, unlock_resp] + client.session.put.return_value = _mock_resp(200) + obj_uri, src_uri = client.create_cds("Z_TEST", "Test", "define view Z_TEST as select from mara { mara.matnr };") + self.assertIn("z_test", obj_uri) + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_cds("Z_TEST", "Test", "ddl source") + + +# ═══════════════════════════════════════════ +# 包管理 +# ═══════════════════════════════════════════ + +class TestPackage(unittest.TestCase): + + def test_create_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_package("Z_TEST_PKG")) + + def test_create_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_package("Z_TEST_PKG") + + def test_get_info_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'Test' + ).encode()) + self.assertIsInstance(client.get_package_info("Z_TEST"), dict) + + def test_get_info_not_found(self): + client = _make_client() + mock = _mock_resp(404) + mock.raise_for_status.side_effect = Exception("404") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.get_package_info("NONEXISTENT") + + +# ═══════════════════════════════════════════ +# 传输管理 +# ═══════════════════════════════════════════ + +class TestTransportInfo(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + result = client.transport_info("DEVK001") + self.assertEqual(result["number"], "DEVK001") + + def test_not_found(self): + client = _make_client() + mock = _mock_resp(404) + mock.raise_for_status.side_effect = Exception("404") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.transport_info("NONEXISTENT") + + +class TestTransportRelease(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200) + self.assertTrue(client.transport_release("DEVK001")) + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(SapCliError): + client.transport_release("DEVK001") + + +class TestTransportObjects(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + self.assertIsInstance(client.transport_objects("DEVK001"), list) + + def test_empty(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + self.assertEqual(client.transport_objects("DEVK001"), []) + + +# ═══════════════════════════════════════════ +# 代码质量 +# ═══════════════════════════════════════════ + +class TestATCCheck(unittest.TestCase): + + def test_clean(self): + client = _make_client() + xml_body = ( + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml_body, content=xml_body.encode()) + ok, findings = client.atc_check("ZTEST", "/uri") + self.assertTrue(ok) + + def test_with_findings(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'Unused variable' + f'' + f'' + ).encode()) + ok, findings = client.atc_check("ZTEST", "/uri") + self.assertFalse(ok) + + +class TestPrettyPrint(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="FORMATTED CODE") + self.assertEqual(client.pretty_print("raw code"), "FORMATTED CODE") + + def test_error(self): + """pretty_print 用 raise_for_status() 抛 HTTPError。""" + client = _make_client() + mock = _mock_resp(500, text="Error") + mock.raise_for_status.side_effect = Exception("500 Server Error") + client.session.post.return_value = mock + with self.assertRaises(Exception): + client.pretty_print("raw code") + + +# ═══════════════════════════════════════════ +# _build_create_body 各类型 +# ═══════════════════════════════════════════ + +class TestBuildCreateBodyTypes(unittest.TestCase): + + def _check(self, obj_type, name): + body = _make_client()._build_create_body(obj_type, name, f"Test {obj_type}") + self.assertIn(name, body) + + def test_report(self): self._check("report", "ZTEST") + def test_class(self): self._check("class", "ZCL_TEST") + def test_interface(self): self._check("interface", "ZIF_TEST") + def test_domain(self): self._check("domain", "ZTEST_DOM") + def test_dataelement(self): self._check("dataelement", "ZTEST_DE") + def test_table(self): self._check("table", "ZTEST_TAB") + def test_structure(self): self._check("structure", "ZTEST_STR") + def test_tabletype(self): self._check("tabletype", "ZTEST_TT") + + def test_unsupported(self): + with self.assertRaises(ValueError): + _make_client()._build_create_body("bogus_type", "ZTEST", "Test") + + +# ═══════════════════════════════════════════ +# set_source 错误重试路径(行 59-69) +# ═══════════════════════════════════════════ + +class TestSetSourceRetry(unittest.TestCase): + + def _error_body(self, corrnr): + return f'{corrnr}' + + def test_retry_with_extracted_corrnr_succeeds(self): + """首次 400 + 错误体含不同 corrNr → 用该 corrNr 重试成功。""" + client = _make_client() + client.session.put.side_effect = [ + _mock_resp(400, text=self._error_body("DEVK999")), + _mock_resp(200), + ] + self.assertTrue(client.set_source("/src", "code", "lh", corr_nr="DEVK001")) + self.assertEqual(client.session.put.call_count, 2) + + def test_retry_fails_raises(self): + """重试后仍 400 → raise_for_status。""" + client = _make_client() + first = _mock_resp(400, text=self._error_body("DEVK999")) + retry = _mock_resp(400) + retry.raise_for_status.side_effect = Exception("still 400") + client.session.put.side_effect = [first, retry] + with self.assertRaises(Exception): + client.set_source("/src", "code", "lh", corr_nr="DEVK001") + + def test_no_corrnr_in_error_raises(self): + """错误体无 corrNr → 不重试,直接 raise。""" + client = _make_client() + resp = _mock_resp(400, text="boom") + resp.raise_for_status.side_effect = Exception("400") + client.session.put.return_value = resp + with self.assertRaises(Exception): + client.set_source("/src", "code", "lh") + self.assertEqual(client.session.put.call_count, 1) + + +# ═══════════════════════════════════════════ +# lock 高级路径(行 94, 101-107) +# ═══════════════════════════════════════════ + +class TestLockAdvanced(unittest.TestCase): + + def test_lock_with_corr_nr(self): + """传 corr_nr → 写入 params 并原样返回(不提取)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'lh_123' + ).encode()) + handle, corr = client.lock("/uri", corr_nr="DEVK001") + self.assertEqual(handle, "lh_123") + self.assertEqual(corr, "DEVK001") + + def test_lock_500_retry_with_existing_corrnr(self): + """首次 500 + 错误体含 corrNr → 用该 corrNr 重试成功。""" + client = _make_client() + error_body = 'DEVK999' + client.session.post.side_effect = [ + _mock_resp(500, text=error_body), + _mock_resp(200, content=( + f'lh' + ).encode()), + ] + handle, corr = client.lock("/uri", corr_nr="DEVK001") + self.assertEqual(handle, "lh") + self.assertEqual(corr, "DEVK999") + self.assertEqual(client.session.post.call_count, 2) + + +# ═══════════════════════════════════════════ +# _extract_locked_corrnr(行 135-149) +# ═══════════════════════════════════════════ + +class TestExtractLockedCorrnr(unittest.TestCase): + + def test_key_corrnr(self): + client = _make_client() + body = 'DEVK001' + self.assertEqual(client._extract_locked_corrnr(body), "DEVK001") + + def test_star_skipped_no_message(self): + client = _make_client() + body = '*' + self.assertIsNone(client._extract_locked_corrnr(body)) + + def test_message_branch(self): + client = _make_client() + body = 'Object is locked in request DEVK888' + self.assertEqual(client._extract_locked_corrnr(body), "DEVK888") + + def test_invalid_xml(self): + client = _make_client() + self.assertIsNone(client._extract_locked_corrnr("not valid xml <<")) + + +# ═══════════════════════════════════════════ +# _extract_corrnr_from_lock_response(行 166, 172-175, 183-185) +# ═══════════════════════════════════════════ + +class TestExtractCorrnrFromLockResponse(unittest.TestCase): + + def _extract(self, xml): + return _make_client()._extract_corrnr_from_lock_response(ET.fromstring(xml)) + + def test_strategy1_cornn_tag(self): + self.assertEqual( + self._extract('DEVK001'), + "DEVK001", + ) + + def test_strategy1_star_skipped(self): + self.assertIsNone( + self._extract('*'), + ) + + def test_strategy2_attr(self): + self.assertEqual( + self._extract(''), + "DEVK003", + ) + + def test_strategy2_tagname(self): + self.assertEqual( + self._extract('DEVK004'), + "DEVK004", + ) + + def test_strategy3_property_key(self): + self.assertEqual( + self._extract('DEVK005'), + "DEVK005", + ) + + def test_no_match_returns_none(self): + self.assertIsNone(self._extract('val')) + + +# ═══════════════════════════════════════════ +# activate — inactivectsobjects 路径(行 235-265) +# ═══════════════════════════════════════════ + +class TestActivateInactiveCtsObjects(unittest.TestCase): + + IOC_NS = "http://www.sap.com/abapxml/inactiveCtsObjects" + + def _activate_resp(self, xml): + return _mock_resp( + 200, text=xml, content=xml.encode(), + headers={"content-type": "application/vnd.sap.adt.inactivectsobjects+xml"}, + ) + + def test_with_objects_via_fallback_ref(self): + """ref 不在 adtcore ns → 走 endswith}ref 回退路径。""" + xml = ( + f'' + f'' + f'' + f'' + f'' + f'' + ) + client = _make_client() + client.session.post.return_value = self._activate_resp(xml) + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + self.assertTrue(any("Z_INACTIVE" in m["text"] for m in msgs)) + + def test_with_objects_via_adtcore_ref(self): + """ref 在 adtcore ns → obj_elem.find 直接命中。""" + xml = ( + f'' + f'' + f'' + f'' + f'' + f'' + ) + client = _make_client() + client.session.post.return_value = self._activate_resp(xml) + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + self.assertTrue(any("Z_ADTC" in m["text"] for m in msgs)) + + def test_empty_no_objects(self): + """inactivectsobjects 响应但无 entry → 视为成功。""" + xml = ( + f'' + f'' + f'' + ) + client = _make_client() + client.session.post.return_value = self._activate_resp(xml) + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + self.assertEqual(msgs, []) + + +# ═══════════════════════════════════════════ +# activate — corr_nr / msg 回退 / verify 异常(行 219, 316-317, 334-337) +# ═══════════════════════════════════════════ + +class TestActivateMisc(unittest.TestCase): + + def test_activate_with_corr_nr(self): + """传 corr_nr → 写入 activation params。""" + client = _make_client() + xml = f'' + client.session.post.return_value = _mock_resp(200, text=xml, content=xml.encode()) + ok, msgs = client.activate("ztest", "/uri", corr_nr="DEVK001") + self.assertTrue(ok) + self.assertEqual(msgs, []) + + def test_msg_without_txt_uses_fallback_text(self): + """msg 无 }txt 子元素 → 用第一个长文本后代(行 334-337)。""" + client = _make_client() + xml = ( + f'' + f'' + f'' + f'Some long activation failure text' + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml, content=xml.encode()) + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + self.assertTrue(msgs) + self.assertIn("Some long", msgs[0]["text"]) + + def test_empty_response_verify_raises(self): + """空响应 + verify GET 抛异常 → 捕获后视为成功(行 316-317)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="", content=b"") + client.session.get.side_effect = Exception("verify boom") + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + self.assertEqual(msgs, []) + + +# ═══════════════════════════════════════════ +# syntax_check — msg 回退文本(行 390-393) +# ═══════════════════════════════════════════ + +class TestSyntaxCheckMsgFallback(unittest.TestCase): + + def test_msg_without_txt_uses_fallback_text(self): + client = _make_client() + xml = ( + f'' + f'' + f'' + f'Another long syntax failure note' + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml, content=xml.encode()) + ok, msgs = client.syntax_check("ztest", "/uri") + self.assertFalse(ok) + self.assertTrue(msgs) + self.assertIn("Another long", msgs[0]["text"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_clone.py b/tests/unit/test_clone.py new file mode 100644 index 0000000..90a8a22 --- /dev/null +++ b/tests/unit/test_clone.py @@ -0,0 +1,322 @@ +"""commands/clone.py 单元测试 — cmd_clone + _make_profile_client。 + +clone 命令从一个系统下载对象,上传到另一个系统。client 参数被忽略; +源/目标客户端分别按 --from / --to profile 构建。 + +运行: python tests/unit/test_clone.py +""" +from __future__ import annotations + +import argparse +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + + +def _args(**kwargs): + defaults = { + "name": "ZTEST", + "type": "report", + "from_profile": "DEV", + "to_profile": "QA", + "corr_nr": None, + "config": None, + "verify_ssl": False, + } + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +# ═══════════════════════════════════════════ +# _make_profile_client +# ═══════════════════════════════════════════ + +class TestMakeProfileClient(unittest.TestCase): + """_make_profile_client 按 profile 加载配置并建立已登录的 ADT 客户端。""" + + @patch("sapcli.commands.clone.ADTClient") + @patch("sapcli.commands.clone.load_config") + def test_builds_and_logs_in(self, mock_load, mock_client_cls): + from sapcli.commands.clone import _make_profile_client + + sap_cfg = MagicMock() + sap_cfg.host = "h" + sap_cfg.client = "100" + sap_cfg.user = "u" + sap_cfg.password = "p" + mock_load.return_value = (sap_cfg, "DEV") + + inner = MagicMock() + mock_client_cls.return_value = inner + + result = _make_profile_client("/path.ini", "DEV", verify_ssl=True) + + mock_load.assert_called_once_with("/path.ini", profile="DEV") + mock_client_cls.assert_called_once_with( + "h", "100", "u", "p", verify_ssl=True, + ) + inner.login.assert_called_once() + self.assertIs(result, inner) + + @patch("sapcli.commands.clone.ADTClient") + @patch("sapcli.commands.clone.load_config") + def test_default_verify_ssl_false(self, mock_load, mock_client_cls): + from sapcli.commands.clone import _make_profile_client + + mock_load.return_value = (MagicMock(), "QA") + mock_client_cls.return_value = MagicMock() + + _make_profile_client(None, "QA") + + # 未传 verify_ssl 时默认 False + self.assertEqual(mock_client_cls.call_args[1].get("verify_ssl"), False) + + +# ═══════════════════════════════════════════ +# cmd_clone +# ═══════════════════════════════════════════ + +class TestCmdClone(unittest.TestCase): + """cmd_clone 跨系统克隆对象。""" + + def test_no_source_type_raises_invalid_name(self): + """functiongroup 无源码 → InvalidNameError。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import InvalidNameError + + with self.assertRaises(InvalidNameError): + cmd_clone(_args(type="functiongroup", name="ZFG"), MagicMock()) + + @patch("sapcli.commands.clone._make_profile_client") + def test_source_connection_failure_raises_config_error(self, mock_make): + """源系统连接失败 → ConfigError。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import ConfigError + + mock_make.side_effect = ConnectionError("host unreachable") + + with patch("builtins.print"): + with self.assertRaises(ConfigError) as ctx: + cmd_clone(_args(), None) + self.assertIn("源系统", str(ctx.exception)) + + @patch("sapcli.commands.clone._make_profile_client") + def test_source_object_not_found_raises(self, mock_make): + """源对象不存在 → ObjectNotFoundError,且关闭源 session。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import ObjectNotFoundError + + src_client = MagicMock() + src_client.object_exists.return_value = False + mock_make.return_value = src_client + + with patch("builtins.print"): + with self.assertRaises(ObjectNotFoundError): + cmd_clone(_args(), None) + # 即使失败也关闭源 session(finally 块) + src_client.session.close.assert_called_once() + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_success_target_exists_normalizes_source(self, mock_make, mock_sync): + """目标对象已存在 → 同步成功;源码 \r\n 归一化为 \n 并写入临时文件。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest.\r\nWRITE 'hi'.\r\n" + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + + captured = {} + + def fake_sync(name, obj_type, path, client, corr_nr=None): + with open(path, "r", encoding="utf-8") as f: + captured["content"] = f.read() + captured["client"] = client + captured["corr_nr"] = corr_nr + return (True, None, corr_nr or "DEVK001") + + mock_sync.side_effect = fake_sync + + with patch("builtins.print"): + cmd_clone(_args(corr_nr="DEVK901362"), None) + + # 源码换行归一化 + self.assertEqual(captured["content"], "REPORT ztest.\nWRITE 'hi'.\n") + # _sync_single 收到的是目标客户端 + self.assertIs(captured["client"], tgt_client) + self.assertEqual(captured["corr_nr"], "DEVK901362") + # 两个 session 都关闭 + src_client.session.close.assert_called_once() + tgt_client.session.close.assert_called_once() + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_success_target_not_exists(self, mock_make, mock_sync): + """目标对象不存在时打印「将创建并同步」并继续。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = False + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (True, None, None) + + with patch("builtins.print") as mock_print: + cmd_clone(_args(), None) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("将创建并同步", printed) + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_sync_failure_raises_sapcli_error(self, mock_make, mock_sync): + """_sync_single 返回失败 → SapCliError。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import SapCliError + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (False, "激活失败: 行3", None) + + with patch("builtins.print"): + with self.assertRaises(SapCliError) as ctx: + cmd_clone(_args(), None) + self.assertIn("激活失败", str(ctx.exception)) + tgt_client.session.close.assert_called_once() + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_sync_failure_with_empty_error_uses_default(self, mock_make, mock_sync): + """_sync_single 返回失败但 error_msg 为空 → 使用默认消息。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import SapCliError + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (False, "", None) + + with patch("builtins.print"): + with self.assertRaises(SapCliError) as ctx: + cmd_clone(_args(name="ZMYCLS", type="class"), None) + self.assertIn("ZMYCLS", str(ctx.exception)) + + @patch("sapcli.commands.clone._make_profile_client") + def test_target_connection_failure_raises_config_error(self, mock_make): + """目标系统连接失败 → ConfigError(源已成功连接并下载)。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import ConfigError + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + mock_make.side_effect = [src_client, ConnectionError("target down")] + + with patch("builtins.print"): + with self.assertRaises(ConfigError) as ctx: + cmd_clone(_args(), None) + self.assertIn("目标系统", str(ctx.exception)) + # 源 session 已关闭 + src_client.session.close.assert_called_once() + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_no_corr_nr_omits_transport_line(self, mock_make, mock_sync): + """未指定 corr_nr 时不打印「目标传输请求」行。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (True, None, None) + + with patch("builtins.print") as mock_print: + cmd_clone(_args(corr_nr=None), None) + + # 同步成功;actual_corr 为 None 时不打印目标传输请求 + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("克隆完成", printed) + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_temp_file_cleaned_up(self, mock_make, mock_sync): + """同步成功后临时文件被删除。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + + captured = {} + + def fake_sync(name, obj_type, path, client, corr_nr=None): + captured["path"] = path + return (True, None, "DEVK001") + + mock_sync.side_effect = fake_sync + + with patch("builtins.print"): + cmd_clone(_args(corr_nr="DEVK001"), None) + + # 同步返回后,临时文件应已删除 + self.assertFalse(os.path.isfile(captured["path"])) + + @patch("sapcli.commands.clone._make_profile_client") + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone.os.unlink", side_effect=OSError("busy")) + def test_temp_file_unlink_error_swallowed(self, mock_unlink, mock_sync, mock_make): + """os.unlink 抛 OSError 时被吞掉,不影响主流程。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (True, None, "DEVK001") + + # 不应抛异常(OSError 在 finally 中被吞) + with patch("builtins.print"): + cmd_clone(_args(corr_nr="DEVK001"), None) + mock_unlink.assert_called_once() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py new file mode 100644 index 0000000..c8cfd48 --- /dev/null +++ b/tests/unit/test_commands.py @@ -0,0 +1,600 @@ +"""commands/ 模块单元测试 + +运行: python tests/unit/test_commands.py +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch, PropertyMock + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + +from sapcli.client import ADTClient + + +def _mock_resp(status_code=200, text="", content=b"", headers=None): + r = MagicMock() + r.status_code = status_code + r.text = text + r.content = content + r.headers = headers or {} + r.raise_for_status = MagicMock() + return r + + +def _make_client(): + """创建完整 mock 的 ADTClient。""" + client = MagicMock() + client.host = "https://sap.example.com" + client.sap_client = "100" + client.csrf_token = "test-csrf-token" + client.user = "TESTUSER" + # client.session 用于直接 HTTP 调用 + client.session = MagicMock() + client._headers.return_value = { + "content-type": "application/xml", + "x-csrf-token": "test-csrf-token", + } + return client + + +def _args(**kwargs): + defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +# ═══════════════════════════════════════════ +# crud.py — download +# ═══════════════════════════════════════════ + +class TestCmdDownload(unittest.TestCase): + + def test_download_report_success(self): + from sapcli.commands.crud import cmd_download + client = _make_client() + client.object_exists.return_value = True + client.get_source.return_value = "REPORT ztest.\nWRITE: / 'hello'." + with tempfile.TemporaryDirectory() as td: + cmd_download(_args(path=td), client) + self.assertTrue(os.path.isfile(os.path.join(td, "ztest.abap"))) + + def test_download_object_not_found(self): + from sapcli.commands.crud import cmd_download + from sapcli.exceptions import ObjectNotFoundError + client = _make_client() + client.object_exists.return_value = False + with tempfile.TemporaryDirectory() as td: + with self.assertRaises(ObjectNotFoundError): + cmd_download(_args(path=td), client) + + def test_download_creates_dir(self): + from sapcli.commands.crud import cmd_download + client = _make_client() + client.object_exists.return_value = True + client.get_source.return_value = "REPORT ztest." + with tempfile.TemporaryDirectory() as td: + subdir = os.path.join(td, "sub", "dir") + cmd_download(_args(path=subdir), client) + self.assertTrue(os.path.isfile(os.path.join(subdir, "ztest.abap"))) + + def test_download_class(self): + from sapcli.commands.crud import cmd_download + client = _make_client() + client.object_exists.return_value = True + client.get_source.return_value = "CLASS zcl_test DEFINITION." + with tempfile.TemporaryDirectory() as td: + cmd_download(_args(type="class", name="ZCL_TEST", path=td), client) + self.assertTrue(os.path.isfile(os.path.join(td, "zcl_test.abap"))) + + +# ═══════════════════════════════════════════ +# crud.py — sync +# ═══════════════════════════════════════════ + +class TestCmdSync(unittest.TestCase): + + def test_sync_report_success(self): + from sapcli.commands.crud import cmd_sync + client = _make_client() + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.activate.return_value = (True, []) + with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f: + f.write("REPORT ztest.\nWRITE: / 'hello'.") + tmpfile = f.name + try: + cmd_sync(_args(path=tmpfile, project_path=None), client) + client.lock.assert_called_once() + client.set_source.assert_called_once() + finally: + os.unlink(tmpfile) + + def test_sync_file_not_found(self): + from sapcli.commands.crud import cmd_sync + from sapcli.exceptions import ConfigError + client = _make_client() + with self.assertRaises(ConfigError): + cmd_sync(_args(path="/nonexistent/file.abap"), client) + + def test_sync_with_check(self): + from sapcli.commands.crud import cmd_sync + client = _make_client() + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.activate.return_value = (True, []) + client.syntax_check.return_value = (True, []) + with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f: + f.write("REPORT ztest.") + tmpfile = f.name + try: + cmd_sync(_args(path=tmpfile, check=True, project_path=None), client) + client.syntax_check.assert_called_once() + finally: + os.unlink(tmpfile) + + +# ═══════════════════════════════════════════ +# crud.py — info(直接用 client.session.get) +# ═══════════════════════════════════════════ + +_INFO_XML = ( + b'' + b'' +) + +class TestCmdInfo(unittest.TestCase): + + def test_info_success(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_INFO_XML) + cmd_info(_args(), client) + client.session.get.assert_called() + + def test_info_not_exists(self): + from sapcli.commands.crud import cmd_info + from sapcli.exceptions import ObjectNotFoundError + client = _make_client() + client.session.get.return_value = _mock_resp(404) + with self.assertRaises(ObjectNotFoundError): + cmd_info(_args(), client) + + +# ═══════════════════════════════════════════ +# crud.py — delete +# ═══════════════════════════════════════════ + +class TestCmdDelete(unittest.TestCase): + + @patch("builtins.input", return_value="yes") + @patch("sapcli.commands.crud.Manifest") + def test_delete_success(self, MockManifest, mock_input): + from sapcli.commands.crud import cmd_delete + client = _make_client() + client.object_exists.return_value = True + client.delete_object.return_value = (True, "") + cmd_delete(_args(), client) + client.delete_object.assert_called_once() + + def test_delete_failure(self): + from sapcli.commands.crud import cmd_delete + from sapcli.exceptions import DeleteError + client = _make_client() + client.object_exists.return_value = True + client.delete_object.side_effect = DeleteError("删除失败") + with self.assertRaises(DeleteError), patch("builtins.input", return_value="yes"): + cmd_delete(_args(), client) + + +# ═══════════════════════════════════════════ +# crud.py — create +# ═══════════════════════════════════════════ + +class TestCmdCreate(unittest.TestCase): + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_report_no_source(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_object.return_value = ("/uri/ztest", "/uri/ztest/source/main") + cmd_create(_args(source=None, description="Test", package="$TMP", corr_nr=None), client) + client.create_object.assert_called_once() + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_with_source_file(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_object.return_value = ("/uri/ztest", "/uri/ztest/source/main") + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.activate.return_value = (True, []) + with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f: + f.write("REPORT ztest.") + tmpfile = f.name + try: + cmd_create(_args(source=tmpfile, description="Test", package="$TMP", corr_nr=None), client) + finally: + os.unlink(tmpfile) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_ddic_type(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_ddic_object.return_value = ("/uri/ztest_dom", "/uri/ztest_dom/source/main") + cmd_create(_args(type="domain", name="ZTEST_DOM", source=None, description="Domain", package="$TMP", corr_nr=None), client) + client.create_ddic_object.assert_called_once() + + +# ═══════════════════════════════════════════ +# crud.py — print_source_preview +# ═══════════════════════════════════════════ + +class TestPrintSourcePreview(unittest.TestCase): + + def test_preview_short(self): + from sapcli.commands.crud import print_source_preview + with patch("builtins.print"): + print_source_preview("REPORT ztest.\nWRITE: / 'hello'.") + + def test_preview_long(self): + from sapcli.commands.crud import print_source_preview + long_src = "\n".join(f"LINE {i}" for i in range(100)) + with patch("builtins.print"): + print_source_preview(long_src, max_lines=5) + + +# ═══════════════════════════════════════════ +# batch.py — init +# ═══════════════════════════════════════════ + +class TestCmdInit(unittest.TestCase): + + def test_init_empty_dir_no_crash(self): + """空目录不崩溃(不扫描到 .abap 文件则提前返回)。""" + from sapcli.commands.batch import cmd_init + client = _make_client() + with tempfile.TemporaryDirectory() as td: + cmd_init(argparse.Namespace(name="ZTEST", type="report", path=td, config=None), client) + # 空目录无 .abap 文件 → 不创建 manifest → 不崩溃即可 + + def test_init_with_abap_file(self): + """有 .abap 文件时,初始化应创建 manifest。""" + from sapcli.commands.batch import cmd_init + client = _make_client() + client.get_object_status.return_value = {"exists": True, "status": "active", "corr_nr": None} + with tempfile.TemporaryDirectory() as td: + report_dir = os.path.join(td, "reports") + os.makedirs(report_dir) + with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + cmd_init(argparse.Namespace(name="ZTEST", type="report", path=td, config=None), client) + self.assertTrue(os.path.isfile(os.path.join(td, "manifest.json"))) + + +class TestCmdRefresh(unittest.TestCase): + + def test_refresh_success(self): + from sapcli.commands.batch import cmd_refresh + from sapcli.manifest import Manifest, ManifestEntry + client = _make_client() + client.get_object_status.return_value = { + "exists": True, "status": "active", "corr_nr": None + } + with tempfile.TemporaryDirectory() as td: + # 直接写 manifest.json 文件,供 Manifest.load() 读取 + entry = ManifestEntry( + name="ZTEST", type="report", + file="reports/ztest.abap", + system_status="not_exists", + ) + import json + manifest_data = { + "version": 2, + "objects": {"ZTEST": entry.to_dict()}, + } + manifest_path = os.path.join(td, "manifest.json") + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest_data, f) + os.makedirs(os.path.join(td, "reports")) + cmd_refresh(argparse.Namespace(path=td, config=None), client) + client.get_object_status.assert_called() + + +# ═══════════════════════════════════════════ +# cds.py +# ═══════════════════════════════════════════ + +class TestCmdCds(unittest.TestCase): + + def test_cds_download(self): + from sapcli.commands.cds import cmd_cds + client = _make_client() + client.get_cds_source.return_value = "define view Z_TEST as select from mara { mara.matnr };" + with tempfile.TemporaryDirectory() as td: + cmd_cds(argparse.Namespace(cds_action="download", name="Z_TEST", path=td, config=None), client) + client.get_cds_source.assert_called_once() + + def test_cds_create(self): + from sapcli.commands.cds import cmd_cds + client = _make_client() + client.create_cds.return_value = ("/uri/z_test", "/uri/z_test/source/main") + with tempfile.TemporaryDirectory() as td: + cmd_cds(argparse.Namespace( + cds_action="create", name="Z_TEST", description="Test CDS", + source=None, path=td, config=None + ), client) + client.create_cds.assert_called_once() + + +# ═══════════════════════════════════════════ +# config_cmd.py +# ═══════════════════════════════════════════ + +class TestCmdConfig(unittest.TestCase): + + def test_config_show(self): + from sapcli.commands.config_cmd import cmd_config + with patch("sapcli.commands.config_cmd._config_show"): + cmd_config(argparse.Namespace(config_action="show", config=None)) + + def test_config_list_profiles(self): + from sapcli.commands.config_cmd import cmd_config + with patch("sapcli.commands.config_cmd._config_list_profiles"): + cmd_config(argparse.Namespace(config_action="list-profiles", config=None)) + + def test_config_set(self): + from sapcli.commands.config_cmd import cmd_config + with patch("sapcli.commands.config_cmd._config_set"): + cmd_config(argparse.Namespace(config_action="set", key="host", value="http://new:8000", config=None)) + + +# ═══════════════════════════════════════════ +# diff_cmd.py +# ═══════════════════════════════════════════ + +class TestCmdDiff(unittest.TestCase): + + def test_diff_success(self): + from sapcli.commands.diff_cmd import cmd_diff + client = _make_client() + client.read_source_for_diff.return_value = "REPORT zremote." + with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f: + f.write("REPORT zlocal.") + tmpfile = f.name + try: + cmd_diff(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client) + client.read_source_for_diff.assert_called_once() + finally: + os.unlink(tmpfile) + + +# ═══════════════════════════════════════════ +# package_cmd.py +# ═══════════════════════════════════════════ + +class TestCmdPackage(unittest.TestCase): + + def test_package_info(self): + from sapcli.commands.package_cmd import cmd_package + client = _make_client() + client.get_package_info.return_value = {"name": "Z_TEST", "description": "Test"} + cmd_package(argparse.Namespace(package_action="info", name="Z_TEST", config=None), client) + client.get_package_info.assert_called_once() + + def test_package_create(self): + from sapcli.commands.package_cmd import cmd_package + client = _make_client() + client.create_package.return_value = True + cmd_package(argparse.Namespace(package_action="create", name="Z_TEST_PKG", description="Test", superpackage=None, config=None), client) + client.create_package.assert_called_once() + + +# ═══════════════════════════════════════════ +# quality.py +# ═══════════════════════════════════════════ + +class TestCmdCheck(unittest.TestCase): + + def test_check_clean(self): + from sapcli.commands.quality import cmd_check + client = _make_client() + client.atc_check.return_value = (True, []) + cmd_check(_args(), client) + client.atc_check.assert_called_once() + + def test_check_with_errors(self): + from sapcli.commands.quality import cmd_check + client = _make_client() + client.atc_check.return_value = (False, [{"type": "E", "line": "1", "text": "Error"}]) + cmd_check(_args(), client) # 打印错误但不抛异常 + + +class TestCmdFormat(unittest.TestCase): + + def test_format_success(self): + from sapcli.commands.quality import cmd_format + client = _make_client() + client.pretty_print.return_value = "FORMATTED CODE" + with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f: + f.write("REPORT ztest.") + tmpfile = f.name + try: + cmd_format(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client) + client.pretty_print.assert_called_once() + finally: + os.unlink(tmpfile) + + +# ═══════════════════════════════════════════ +# search.py +# ═══════════════════════════════════════════ + +class TestCmdList(unittest.TestCase): + + def test_list_found(self): + from sapcli.commands.search import cmd_list + client = _make_client() + client.list_objects.return_value = [{"name": "Z_TEST", "type": "PROG/P", "package": "$TMP"}] + cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client) + client.list_objects.assert_called_once() + + def test_list_empty(self): + from sapcli.commands.search import cmd_list + client = _make_client() + client.list_objects.return_value = [] + cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client) + + +class TestCmdWhereUsed(unittest.TestCase): + + def test_whereused_found(self): + from sapcli.commands.search import cmd_whereused + client = _make_client() + client.where_used.return_value = [{"name": "Z_USER", "type": "PROG/P"}] + cmd_whereused(_args(), client) + client.where_used.assert_called_once() + + +class TestCmdSearch(unittest.TestCase): + + def test_search_found(self): + from sapcli.commands.search import cmd_search + client = _make_client() + client.search_code.return_value = [{"name": "Z_RESULT", "type": "PROG/P"}] + cmd_search(argparse.Namespace(query="SELECT", type=None, config=None), client) + client.search_code.assert_called_once() + + +# ═══════════════════════════════════════════ +# transport.py +# ═══════════════════════════════════════════ + +class TestCmdTransport(unittest.TestCase): + + def test_transport_list(self): + from sapcli.commands.transport import cmd_transport + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "Test", "owner": "TESTUSER", "status": "D"} + ] + cmd_transport(argparse.Namespace(transport_action="list", config=None), client) + client.list_transport_requests.assert_called_once() + + def test_transport_info(self): + from sapcli.commands.transport import cmd_transport + client = _make_client() + client.transport_info.return_value = {"number": "DEVK001", "description": "Test", "status": "D"} + cmd_transport(argparse.Namespace(transport_action="info", number="DEVK001", corr_nr="DEVK001", config=None), client) + client.transport_info.assert_called_once() + + def test_transport_release(self): + from sapcli.commands.transport import cmd_transport + client = _make_client() + client.transport_release.return_value = True + with patch("builtins.input", return_value="yes"): + cmd_transport(argparse.Namespace(transport_action="release", number="DEVK001", corr_nr="DEVK001", config=None), client) + client.transport_release.assert_called_once() + + +# ═══════════════════════════════════════════ +# analyze.py +# ═══════════════════════════════════════════ + +class TestCmdAnalyze(unittest.TestCase): + + def test_analyze_success(self): + from sapcli.commands.analyze import cmd_analyze + client = _make_client() + client.get_source.return_value = "REPORT ztest.\nCALL FUNCTION 'BAPI_TEST'." + with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f: + f.write("REPORT ztest.") + tmpfile = f.name + try: + cmd_analyze(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client) + finally: + os.unlink(tmpfile) + + +# ═══════════════════════════════════════════ +# scaffold.py +# ═══════════════════════════════════════════ + +class TestCmdScaffold(unittest.TestCase): + + def test_scaffold_no_template_lists(self): + from sapcli.commands.scaffold import cmd_scaffold + with patch("builtins.print") as mock_print: + cmd_scaffold(argparse.Namespace(template=None, name="ZTEST", path=".", config=None, package="$TMP"), None) + self.assertTrue(mock_print.called) + + def test_scaffold_alv_report(self): + from sapcli.commands.scaffold import cmd_scaffold + with tempfile.TemporaryDirectory() as td: + cmd_scaffold(argparse.Namespace(template="alv-report", name="ZALV_TEST", path=td, config=None, package="$TMP"), None) + self.assertTrue(len(os.listdir(td)) > 0) + + def test_scaffold_bapi_wrapper(self): + from sapcli.commands.scaffold import cmd_scaffold + with tempfile.TemporaryDirectory() as td: + cmd_scaffold(argparse.Namespace(template="bapi-wrapper", name="ZBAPI_TEST", path=td, config=None, package="$TMP"), None) + self.assertTrue(len(os.listdir(td)) > 0) + + def test_scaffold_interface_class(self): + from sapcli.commands.scaffold import cmd_scaffold + with tempfile.TemporaryDirectory() as td: + cmd_scaffold(argparse.Namespace(template="interface-class", name="ZIF_TEST", path=td, config=None, package="$TMP"), None) + self.assertTrue(len(os.listdir(td)) > 0) + + def test_scaffold_data_model(self): + from sapcli.commands.scaffold import cmd_scaffold + with tempfile.TemporaryDirectory() as td: + cmd_scaffold(argparse.Namespace(template="data-model", name="ZMODEL", path=td, config=None, package="$TMP"), None) + self.assertTrue(len(os.listdir(td)) > 0) + + +# ═══════════════════════════════════════════ +# auth.py — 需 mock getpass + load_config +# ═══════════════════════════════════════════ + +class TestAuth(unittest.TestCase): + + @patch("sapcli.auth.getpass") + @patch("sapcli.auth.set_password", return_value=True) + @patch("sapcli.config.load_config") + def test_cmd_auth_login(self, mock_load, mock_set, mock_getpass): + from sapcli.auth import cmd_auth_login + mock_cfg = MagicMock() + mock_cfg.host = "http://sap:8000" + mock_cfg.client = "100" + mock_cfg.user = "TESTUSER" + mock_load.return_value = (mock_cfg, None) + mock_getpass.getpass.return_value = "SECRET" + cmd_auth_login(argparse.Namespace(config=None)) + mock_set.assert_called_once() + + def test_cmd_auth_status_no_keyring(self): + from sapcli.auth import cmd_auth_status + with patch("sapcli.auth._KEYRING_AVAILABLE", False): + cmd_auth_status(argparse.Namespace()) # 不抛异常 + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_commands_extra.py b/tests/unit/test_commands_extra.py new file mode 100644 index 0000000..08b2b7f --- /dev/null +++ b/tests/unit/test_commands_extra.py @@ -0,0 +1,1901 @@ +"""commands/ 模块深入单元测试 — 覆盖失败/边界场景。 + +运行: python tests/unit/test_commands_extra.py +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch, mock_open + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + +from sapcli.client import ADTClient + + +# ── helpers ────────────────────────────────────────────────────── + +def _mock_resp(status_code=200, text="", content=b"", headers=None): + r = MagicMock() + r.status_code = status_code + r.text = text + r.content = content + r.headers = headers or {} + r.raise_for_status = MagicMock() + return r + + +def _make_client(): + """创建完全 mock 的 ADTClient。""" + client = MagicMock(spec=ADTClient) + client.host = "https://sap.example.com" + client.sap_client = "100" + client.csrf_token = "test-csrf-token" + client.user = "TESTUSER" + client.session = MagicMock() + client._headers.return_value = { + "content-type": "application/xml", + "x-csrf-token": "test-csrf-token", + } + return client + + +def _args(**kwargs): + defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +# 用于 cmd_info 的 XML 响应 +_INFO_XML = ( + b'' + b'') + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_download +# ═══════════════════════════════════════════════════════════════ + +class TestCmdDownloadObjectNotFound(unittest.TestCase): + """cmd_download 对象不存在时报错。""" + + def test_object_not_found_raises(self): + from sapcli.commands.crud import cmd_download + from sapcli.exceptions import ObjectNotFoundError + + client = _make_client() + client.object_exists.return_value = False + + with tempfile.TemporaryDirectory() as td: + with self.assertRaises(ObjectNotFoundError) as ctx: + cmd_download(_args(path=td), client) + self.assertIn("ZTEST", str(ctx.exception)) + + def test_object_not_found_prints_hint(self): + from sapcli.commands.crud import cmd_download + from sapcli.exceptions import ObjectNotFoundError + + client = _make_client() + client.object_exists.return_value = False + + with tempfile.TemporaryDirectory() as td: + with patch("builtins.print") as mock_print: + with self.assertRaises(ObjectNotFoundError): + cmd_download(_args(path=td), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("不存在", printed) + + +class TestCmdDownloadFileWriteFailure(unittest.TestCase): + """cmd_download 文件写入失败(权限/路径不可写)。""" + + def test_write_permission_error(self): + from sapcli.commands.crud import cmd_download + + client = _make_client() + client.object_exists.return_value = True + client.get_source.return_value = "REPORT ztest." + + # 令 open() 抛出 PermissionError + with patch("builtins.open", side_effect=PermissionError("拒绝访问")): + with self.assertRaises(PermissionError): + cmd_download(_args(path="/tmp/some_dir"), client) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_create +# ═══════════════════════════════════════════════════════════════ + +class TestCmdCreateWithSourceFile(unittest.TestCase): + """cmd_create 有 --source 文件时。""" + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_reads_source_file(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + + client = _make_client() + client.object_exists.return_value = False + client.create_object.return_value = ("/uri/ztest", "/uri/ztest/source/main") + + source_code = "REPORT ztest.\nWRITE: / 'hello'." + with tempfile.NamedTemporaryFile( + mode="w", suffix=".abap", delete=False, encoding="utf-8" + ) as f: + f.write(source_code) + tmpfile = f.name + try: + cmd_create( + _args(source=tmpfile, description="Test", package="$TMP", corr_nr=None), + client, + ) + # create_object 的 source 参数应该来自文件内容 + call_args = client.create_object.call_args + self.assertEqual(call_args[0][4], source_code) + finally: + os.unlink(tmpfile) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_with_class_source(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + + client = _make_client() + client.object_exists.return_value = False + client.create_object.return_value = ("/uri/zcl_test", "/uri/zcl_test/source/main") + + source_code = "CLASS zcl_test DEFINITION PUBLIC. ENDCLASS." + with tempfile.NamedTemporaryFile( + mode="w", suffix=".abap", delete=False, encoding="utf-8" + ) as f: + f.write(source_code) + tmpfile = f.name + try: + cmd_create( + _args( + type="class", + name="ZCL_TEST", + source=tmpfile, + description="Test Class", + package="$TMP", + corr_nr=None, + ), + client, + ) + call_args = client.create_object.call_args + self.assertEqual(call_args[0][4], source_code) + finally: + os.unlink(tmpfile) + + +class TestCmdCreateWithDefinitionFile(unittest.TestCase): + """cmd_create 有 --definition 文件时(DDIC 类型)。""" + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_domain_with_definition(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + + client = _make_client() + client.object_exists.return_value = False + client.create_ddic_object.return_value = ("/uri/ztest_dom", "/uri/ztest_dom/source/main") + + # 创建一个临时 JSON 定义文件 + definition = {"datatype": "CHAR", "length": 10, "decimals": 0} + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as f: + json.dump(definition, f) + def_file = f.name + try: + cmd_create( + _args( + type="domain", + name="ZTEST_DOM", + source=None, + definition=def_file, + description="Test Domain", + package="$TMP", + corr_nr=None, + ), + client, + ) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(def_file) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_dataelement_with_definition(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + + client = _make_client() + client.object_exists.return_value = False + client.create_ddic_object.return_value = ("/uri/ztest_de", "/uri/ztest_de/source/main") + + definition = {"datatype": "CHAR", "length": 20, "domain_name": "ZTEST_DOM"} + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as f: + json.dump(definition, f) + def_file = f.name + try: + cmd_create( + _args( + type="dataelement", + name="ZTEST_DE", + source=None, + definition=def_file, + description="Test DE", + package="$TMP", + corr_nr=None, + ), + client, + ) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(def_file) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_table_with_definition(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + + client = _make_client() + client.object_exists.return_value = False + client.create_ddic_object.return_value = ("/uri/ztest_tab", "/uri/ztest_tab/source/main") + + definition = { + "fields": [ + {"name": "KEY_FIELD", "type": "char10", "key": True, "not_null": True}, + {"name": "VALUE", "type": "char20"}, + ], + "table_category": "#TRANSPARENT", + "delivery_class": "#A", + } + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as f: + json.dump(definition, f) + def_file = f.name + try: + cmd_create( + _args( + type="table", + name="ZTEST_TAB", + source=None, + definition=def_file, + description="Test Table", + package="$TMP", + corr_nr=None, + ), + client, + ) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(def_file) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_create_tabletype_requires_definition(self, mock_transport): + """tabletype 没有 definition 时应报错。""" + from sapcli.commands.crud import cmd_create + from sapcli.exceptions import CreateError + + client = _make_client() + client.object_exists.return_value = False + + with self.assertRaises(CreateError): + cmd_create( + _args( + type="tabletype", + name="ZTEST_TT", + source=None, + definition=None, + description="Test TT", + package="$TMP", + corr_nr=None, + ), + client, + ) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_functiongroup_success(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + + client = _make_client() + client.function_group_exists.return_value = False + client.create_function_group.return_value = "/uri/ztest_fg" + + cmd_create( + _args( + type="functiongroup", + name="ZTEST_FG", + source=None, + description="Test FG", + package="$TMP", + corr_nr=None, + ), + client, + ) + client.create_function_group.assert_called_once() + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_create_functiongroup_already_exists(self, mock_transport): + from sapcli.commands.crud import cmd_create + from sapcli.exceptions import ObjectAlreadyExistsError + + client = _make_client() + client.function_group_exists.return_value = True + + with self.assertRaises(ObjectAlreadyExistsError): + cmd_create( + _args( + type="functiongroup", + name="ZTEST_FG", + source=None, + description="Test FG", + package="$TMP", + corr_nr=None, + ), + client, + ) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_info +# ═══════════════════════════════════════════════════════════════ + +class TestCmdInfoObjectNotFound(unittest.TestCase): + """cmd_info 对象不存在(HTTP 404)。""" + + def test_info_404_raises(self): + from sapcli.commands.crud import cmd_info + from sapcli.exceptions import ObjectNotFoundError + + client = _make_client() + client.session.get.return_value = _mock_resp(404) + + with self.assertRaises(ObjectNotFoundError) as ctx: + cmd_info(_args(), client) + self.assertIn("ZTEST", str(ctx.exception)) + + def test_info_500_raises(self): + from sapcli.commands.crud import cmd_info + from sapcli.exceptions import SapCliError + + client = _make_client() + client.session.get.return_value = _mock_resp(500, text="Internal Server Error") + + with self.assertRaises(SapCliError) as ctx: + cmd_info(_args(), client) + self.assertIn("500", str(ctx.exception)) + + def test_info_name_mismatch(self): + """返回的名称与请求名称不匹配。""" + from sapcli.commands.crud import cmd_info + from sapcli.exceptions import SapCliError + + mismatched_xml = ( + b'' + b'' + ) + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=mismatched_xml) + + with self.assertRaises(SapCliError) as ctx: + cmd_info(_args(), client) + self.assertIn("不匹配", str(ctx.exception)) + + def test_info_http_406_fallback(self): + """HTTP 406 时回退到 application/xml。""" + from sapcli.commands.crud import cmd_info + + client = _make_client() + # 第一次 406,第二次 200 + client.session.get.side_effect = [ + _mock_resp(406), + _mock_resp(200, content=_INFO_XML), + ] + # 应该不抛异常 + cmd_info(_args(), client) + self.assertEqual(client.session.get.call_count, 2) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_info — 传输请求号字段(纯只读查 E071) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdInfoTransport(unittest.TestCase): + """cmd_info 传输请求号字段。""" + + @staticmethod + def _xml(name="ZTEST", type_code="PROG/P"): + return ( + b'' + b'' + ) + + def test_request_found(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=self._xml()) + client.query_table_data.return_value = { + "rows": [["DEVK901362"], ["DEVK901400"]] + } + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("DEVK901362", printed) + self.assertIn("DEVK901400", printed) + + def test_no_request_shows_none(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=self._xml()) + client.query_table_data.return_value = {"rows": []} + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("传输请求: 无", printed) + + def test_query_failure_graceful(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=self._xml()) + client.query_table_data.side_effect = Exception("boom") + # 不应抛异常 + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("(无法获取)", printed) + + def test_non_r3tr_type_skips_query(self): + """include 非独立 R3TR 对象,不查 E071。""" + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp( + 200, content=self._xml(type_code="PROG/I") + ) + with patch("builtins.print") as mock_print: + cmd_info(_args(type="include", name="ZTEST"), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("非独立传输对象", printed) + client.query_table_data.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_list(来自 search.py) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdListNoResults(unittest.TestCase): + """cmd_list 无结果。""" + + def test_list_empty_results(self): + from sapcli.commands.search import cmd_list + + client = _make_client() + client.list_objects.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到", printed) + + def test_list_api_error(self): + from sapcli.commands.search import cmd_list + + client = _make_client() + client.list_objects.side_effect = Exception("Network error") + + # 应不抛异常,只打印错误 + with patch("builtins.print") as mock_print: + cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_list_with_results(self): + from sapcli.commands.search import cmd_list + + client = _make_client() + client.list_objects.return_value = [ + {"name": "Z_PROG1", "type": "PROG/P", "package": "$TMP", "description": "Test 1"}, + {"name": "Z_PROG2", "type": "PROG/P", "package": "$TMP", "description": "Test 2"}, + ] + + with patch("builtins.print") as mock_print: + cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("2", printed) + + def test_list_with_package_filter(self): + from sapcli.commands.search import cmd_list + + client = _make_client() + client.list_objects.return_value = [] + + cmd_list( + argparse.Namespace(type="report", package="Z_MY_PKG", prefix=None, config=None), + client, + ) + # 检查 list_objects 被调用时包含 package 参数 + call_kwargs = client.list_objects.call_args[1] + self.assertEqual(call_kwargs.get("package"), "Z_MY_PKG") + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_whereused(来自 search.py) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdWhereUsedNoReferences(unittest.TestCase): + """cmd_whereused 无引用。""" + + def test_whereused_empty(self): + from sapcli.commands.search import cmd_whereused + + client = _make_client() + client.where_used.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_whereused(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到引用", printed) + + def test_whereused_api_error(self): + from sapcli.commands.search import cmd_whereused + + client = _make_client() + client.where_used.side_effect = Exception("Connection refused") + + with patch("builtins.print") as mock_print: + cmd_whereused(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_whereused_with_results(self): + from sapcli.commands.search import cmd_whereused + + client = _make_client() + client.where_used.return_value = [ + {"name": "Z_USER1", "type": "PROG/P", "package": "$TMP", "uri": "/prog/z_user1"}, + ] + + with patch("builtins.print") as mock_print: + cmd_whereused(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("1", printed) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_search(来自 search.py) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdSearchNoResults(unittest.TestCase): + """cmd_search 无结果。""" + + def test_search_empty(self): + from sapcli.commands.search import cmd_search + + client = _make_client() + client.search_code.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_search(argparse.Namespace(query="SELECT", type=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到", printed) + + def test_search_api_error(self): + from sapcli.commands.search import cmd_search + + client = _make_client() + client.search_code.side_effect = Exception("Timeout") + + with patch("builtins.print") as mock_print: + cmd_search(argparse.Namespace(query="SELECT", type=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_search_with_type_filter(self): + from sapcli.commands.search import cmd_search + + client = _make_client() + client.search_code.return_value = [ + {"name": "Z_RESULT", "type": "PROG/P", "description": "found"}, + ] + + cmd_search( + argparse.Namespace(query="WRITE", type="report", config=None), + client, + ) + client.search_code.assert_called_once() + # search_code 被调用时传入了 obj_type 参数 + call_args = client.search_code.call_args + # 它是 (query, obj_type=adt_type) 形式 + obj_type_arg = call_args[1].get("obj_type") + if obj_type_arg is None and len(call_args[0]) > 1: + obj_type_arg = call_args[0][1] + self.assertIsNotNone(obj_type_arg) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_format(来自 quality.py) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdFormatSuccess(unittest.TestCase): + """cmd_format 格式化成功(完整流程)。""" + + def test_format_full_success(self): + from sapcli.commands.quality import cmd_format + + client = _make_client() + client.get_source.return_value = "REPORT ztest.\nWRITE: / 'hello'." + client.pretty_print.return_value = "REPORT ztest.\n WRITE: / 'hello'." + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.activate.return_value = (True, []) + + with patch("builtins.print") as mock_print: + cmd_format( + argparse.Namespace(name="ZTEST", type="report", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("格式化完成", printed) + client.pretty_print.assert_called_once() + client.set_source.assert_called_once() + client.activate.assert_called_once() + + def test_format_no_change(self): + """格式化结果与原始一致,不写回。""" + from sapcli.commands.quality import cmd_format + + client = _make_client() + original = "REPORT ztest.\nWRITE: / 'hello'." + client.get_source.return_value = original + client.pretty_print.return_value = original # 无变化 + + with patch("builtins.print") as mock_print: + cmd_format( + argparse.Namespace(name="ZTEST", type="report", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无需修改", printed) + client.set_source.assert_not_called() + + def test_format_get_source_fails(self): + from sapcli.commands.quality import cmd_format + + client = _make_client() + client.get_source.side_effect = Exception("Read error") + + with patch("builtins.print") as mock_print: + cmd_format( + argparse.Namespace(name="ZTEST", type="report", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_format_unsupported_type(self): + """functiongroup 没有 src_uri,应提前返回。""" + from sapcli.commands.quality import cmd_format + + client = _make_client() + + with patch("builtins.print") as mock_print: + cmd_format( + argparse.Namespace(name="ZTEST_FG", type="functiongroup", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("不支持", printed) + client.get_source.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════ +# cds.py — cds download +# ═══════════════════════════════════════════════════════════════ + +class TestCdsDownload(unittest.TestCase): + """cds download 成功。""" + + def test_cds_download_success(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + ddl_source = "define view z_test as select from sflight { carrid, connid, fldate };" + client.get_cds_source.return_value = ddl_source + + with tempfile.TemporaryDirectory() as td: + cmd_cds( + argparse.Namespace(cds_action="download", name="Z_TEST", path=td, config=None), + client, + ) + expected_file = os.path.join(td, "z_test.ddl") + self.assertTrue(os.path.isfile(expected_file)) + with open(expected_file, "r", encoding="utf-8") as f: + content = f.read() + self.assertEqual(content, ddl_source) + + def test_cds_download_api_error(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.get_cds_source.side_effect = Exception("Not found") + + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace(cds_action="download", name="Z_MISSING", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_cds_download_creates_dir(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.get_cds_source.return_value = "define view z_test as select from mara { matnr };" + + with tempfile.TemporaryDirectory() as td: + subdir = os.path.join(td, "new_dir") + cmd_cds( + argparse.Namespace(cds_action="download", name="Z_TEST", path=subdir, config=None), + client, + ) + self.assertTrue(os.path.isfile(os.path.join(subdir, "z_test.ddl"))) + + +# ═══════════════════════════════════════════════════════════════ +# cds.py — cds create +# ═══════════════════════════════════════════════════════════════ + +class TestCdsCreate(unittest.TestCase): + """cds create 有 ddl_path 时。""" + + def test_cds_create_with_ddl_file(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.create_cds.return_value = ("/uri/z_test", "/uri/z_test/source/main") + + ddl_content = "@AbapCatalog.sqlViewName: 'ZTEST'\ndefine view z_test as select from sflight { carrid };" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".ddl", delete=False, encoding="utf-8" + ) as f: + f.write(ddl_content) + ddl_file = f.name + try: + with tempfile.TemporaryDirectory() as td: + cmd_cds( + argparse.Namespace( + cds_action="create", + name="Z_TEST", + description="Test CDS", + ddl_path=ddl_file, + path=td, + config=None, + ), + client, + ) + client.create_cds.assert_called_once() + # 验证 CDS 创建时传入了文件内容 + call_args = client.create_cds.call_args + self.assertIn(ddl_content, call_args[0]) + finally: + os.unlink(ddl_file) + + def test_cds_create_default_template(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.create_cds.return_value = ("/uri/z_test", "/uri/z_test/source/main") + + with tempfile.TemporaryDirectory() as td: + cmd_cds( + argparse.Namespace( + cds_action="create", + name="Z_TEST", + description="Test CDS", + ddl_path=None, + path=td, + config=None, + ), + client, + ) + client.create_cds.assert_called_once() + # 检查默认模板被使用 + call_args = client.create_cds.call_args + ddl_arg = call_args[0][2] + self.assertIn("define view", ddl_arg) + + def test_cds_create_failure(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.create_cds.side_effect = Exception("Already exists") + + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="create", + name="Z_DUP", + description="Duplicate", + ddl_path=None, + path=".", + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# cds.py — cds sync +# ═══════════════════════════════════════════════════════════════ + +class TestCdsSync(unittest.TestCase): + """cds sync 成功。""" + + def test_cds_sync_existing_object(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.activate.return_value = (True, []) + + ddl_content = "define view z_test as select from sflight { carrid };" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".ddl", delete=False, encoding="utf-8" + ) as f: + f.write(ddl_content) + ddl_file = f.name + try: + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="sync", + name="Z_TEST", + path=ddl_file, + config=None, + ), + client, + ) + client.lock.assert_called_once() + client.set_source.assert_called_once() + client.activate.assert_called_once() + finally: + os.unlink(ddl_file) + + def test_cds_sync_nonexistent_object_creates(self): + """CDS 不存在时自动创建。""" + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.object_exists.return_value = False + client.create_cds.return_value = ("/uri/z_test", "/uri/z_test/source/main") + + ddl_content = "define view z_test as select from sflight { carrid };" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".ddl", delete=False, encoding="utf-8" + ) as f: + f.write(ddl_content) + ddl_file = f.name + try: + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="sync", + name="Z_TEST", + path=ddl_file, + config=None, + ), + client, + ) + client.create_cds.assert_called_once() + finally: + os.unlink(ddl_file) + + def test_cds_sync_ddl_file_not_found(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="sync", + name="Z_TEST", + path="/nonexistent/file.ddl", + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("不存在", printed) + + def test_cds_sync_activation_failure(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.activate.return_value = ( + False, + [{"type": "E", "line": "5", "text": "Syntax error"}], + ) + + ddl_content = "define view z_test as select from sflight { invalid_field };" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".ddl", delete=False, encoding="utf-8" + ) as f: + f.write(ddl_content) + ddl_file = f.name + try: + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="sync", + name="Z_TEST", + path=ddl_file, + config=None, + ), + client, + ) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("激活失败", printed) + finally: + os.unlink(ddl_file) + + def test_cds_unknown_action(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace(cds_action=None, name="Z_TEST", path=".", config=None), + client, + ) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("用法", printed) + + +# ═══════════════════════════════════════════════════════════════ +# transport.py — transport list +# ═══════════════════════════════════════════════════════════════ + +class TestTransportList(unittest.TestCase): + """transport list 成功。""" + + def test_transport_list_with_requests(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "Dev request 1", "owner": "USER1"}, + {"number": "DEVK002", "description": "Dev request 2", "owner": "USER2"}, + ] + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="list", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("2", printed) + self.assertIn("DEVK001", printed) + + def test_transport_list_empty(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.list_transport_requests.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="list", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到", printed) + + def test_transport_list_api_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.list_transport_requests.side_effect = Exception("Connection failed") + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="list", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# transport.py — transport info +# ═══════════════════════════════════════════════════════════════ + +class TestTransportInfo(unittest.TestCase): + """transport info 成功。""" + + def test_transport_info_success(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_info.return_value = { + "number": "DEVK001", + "description": "Transport for feature X", + "status": "D", + "owner": "TESTUSER", + } + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="info", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("feature X", printed) + self.assertIn("TESTUSER", printed) + client.transport_info.assert_called_once_with("DEVK001") + + def test_transport_info_api_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_info.side_effect = Exception("Not found") + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="info", corr_nr="DEVK999", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# transport.py — transport release +# ═══════════════════════════════════════════════════════════════ + +class TestTransportRelease(unittest.TestCase): + """transport release 成功。""" + + def test_transport_release_success(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_release.return_value = True + + with patch("builtins.input", return_value="yes"): + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="release", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("成功释放", printed) + + def test_transport_release_cancelled(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_release.return_value = True + + with patch("builtins.input", return_value="no"): + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="release", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("取消", printed) + client.transport_release.assert_not_called() + + def test_transport_release_failure(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_release.return_value = False + + with patch("builtins.input", return_value="yes"): + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="release", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_transport_release_api_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_release.side_effect = Exception("Timeout") + + with patch("builtins.input", return_value="yes"): + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="release", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# transport.py — transport objects +# ═══════════════════════════════════════════════════════════════ + +class TestTransportObjects(unittest.TestCase): + """transport objects 成功。""" + + def test_transport_objects_with_results(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_objects.return_value = [ + {"name": "ZTEST_REPORT", "type": "PROG/P"}, + {"name": "ZCL_MY_CLASS", "type": "CLAS/OC"}, + ] + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="objects", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("2", printed) + self.assertIn("ZTEST_REPORT", printed) + + def test_transport_objects_empty(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_objects.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="objects", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无对象", printed) + + def test_transport_objects_api_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_objects.side_effect = Exception("Server error") + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="objects", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_transport_unknown_action(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action=None, config=None), + client, + ) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("用法", printed) + + +# ═══════════════════════════════════════════════════════════════ +# package_cmd.py — package create +# ═══════════════════════════════════════════════════════════════ + +class TestTransportCreate(unittest.TestCase): + """transport create — 新建传输请求。 + + 历史背景:该子命令曾只存在于分发副本(源码 v2.1 重构时未纳入), + 刷新分发仓前先回迁到源码,避免同步时功能静默消失。 + """ + + def test_transport_create_success(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.create_transport_request.return_value = "DEVK900123" + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="create", + description="CEM BIP 传输请求", config=None), + client, + ) + + client.create_transport_request.assert_called_once_with("CEM BIP 传输请求") + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("DEVK900123", printed) + + def test_transport_create_client_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.create_transport_request.side_effect = Exception("HTTP 403") + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="create", + description="X", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_transport_create_empty_number(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.create_transport_request.return_value = "" + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="create", + description="X", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_transport_usage_lists_create(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + with patch("builtins.print") as mock_print: + cmd_transport(argparse.Namespace(transport_action=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("create", printed) + + +class TestPackageCreate(unittest.TestCase): + """package create 成功。""" + + def test_package_create_success(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.create_package.return_value = True + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="create", + name="Z_TEST_PKG", + description="Test Package", + superpackage=None, + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("创建成功", printed) + client.create_package.assert_called_once_with("Z_TEST_PKG", "Test Package", None) + + def test_package_create_failure(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.create_package.return_value = False + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="create", + name="Z_DUP_PKG", + description="Duplicate", + superpackage=None, + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_package_create_api_error(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.create_package.side_effect = Exception("Auth failed") + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="create", + name="Z_ERR_PKG", + description="Error Pkg", + superpackage=None, + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_package_create_with_superpackage(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.create_package.return_value = True + + cmd_package( + argparse.Namespace( + package_action="create", + name="Z_SUB_PKG", + description="Sub Package", + superpackage="Z_PARENT", + config=None, + ), + client, + ) + client.create_package.assert_called_once_with("Z_SUB_PKG", "Sub Package", "Z_PARENT") + + +# ═══════════════════════════════════════════════════════════════ +# package_cmd.py — package info +# ═══════════════════════════════════════════════════════════════ + +class TestPackageInfo(unittest.TestCase): + """package info 成功。""" + + def test_package_info_success(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.get_package_info.return_value = { + "name": "Z_TEST", + "description": "Test Package", + "owner": "TESTUSER", + "superpackage": "Z_PARENT", + } + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="info", name="Z_TEST", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("Test Package", printed) + self.assertIn("Z_PARENT", printed) + client.get_package_info.assert_called_once_with("Z_TEST") + + def test_package_info_no_superpackage(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.get_package_info.return_value = { + "name": "Z_TOP", + "description": "Top level", + "owner": "ADMIN", + "superpackage": "", + } + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="info", name="Z_TOP", config=None + ), + client, + ) + + # info.get('superpackage', '(无)') returns '' when key exists but is empty + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + client.get_package_info.assert_called_once() + + def test_package_info_api_error(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.get_package_info.side_effect = Exception("Not found") + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="info", name="Z_MISSING", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# package_cmd.py — package list +# ═══════════════════════════════════════════════════════════════ + +class TestPackageList(unittest.TestCase): + """package list 成功。""" + + def test_package_list_with_objects(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.list_objects.return_value = [ + {"name": "Z_REPORT1", "type": "PROG/P", "description": "Report 1"}, + {"name": "Z_CLASS1", "type": "CLAS/OC", "description": "Class 1"}, + ] + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="list", name="Z_TEST_PKG", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("2", printed) + self.assertIn("Z_REPORT1", printed) + + def test_package_list_empty(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.list_objects.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="list", name="Z_EMPTY_PKG", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("没有找到", printed) + + def test_package_list_api_error(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.list_objects.side_effect = Exception("Timeout") + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="list", name="Z_ERR_PKG", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_package_unknown_action(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace(package_action=None, name="Z_TEST", config=None), + client, + ) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("用法", printed) + + +# ═══════════════════════════════════════════════════════════════ +# upload.py — cmd_upload(仅锁定→写入→解锁,不检查不激活) +# ═══════════════════════════════════════════════════════════════ + +class TestUploadCommand(unittest.TestCase): + """cmd_upload 仅执行锁定→写入→解锁,绝不触发语法检查和激活。""" + + def _write_tmp_source(self, source="REPORT ztest.\nWRITE: / 'hello'.\n"): + """写入临时 .abap 源码文件,返回路径。""" + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".abap", delete=False, encoding="utf-8" + ) + f.write(source) + f.close() + return f.name + + def test_upload_success(self): + """成功上传:lock→set_source→unlock 被调用,syntax_check/activate 未被调用。""" + from sapcli.commands.upload import cmd_upload + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK901362") + client.set_source.return_value = True + client.unlock.return_value = True + + tmpfile = self._write_tmp_source() + try: + cmd_upload( + _args(name="ZTEST", type="report", path=tmpfile, corr_nr=None), + client, + ) + finally: + os.unlink(tmpfile) + + client.object_exists.assert_called_once() + client.lock.assert_called_once() + client.set_source.assert_called_once() + client.unlock.assert_called_once() + # 关键:区分"真实现"和"假实现"——绝不能触发检查和激活 + client.syntax_check.assert_not_called() + client.activate.assert_not_called() + + def test_upload_nonexistent_object(self): + """对象不存在时抛 ObjectNotFoundError(不自动创建,区别于 sync)。""" + from sapcli.commands.upload import cmd_upload + from sapcli.exceptions import ObjectNotFoundError + + client = _make_client() + client.object_exists.return_value = False + + tmpfile = self._write_tmp_source() + try: + with self.assertRaises(ObjectNotFoundError) as ctx: + cmd_upload( + _args(name="ZMISSING", type="report", path=tmpfile, corr_nr=None), + client, + ) + self.assertIn("ZMISSING", str(ctx.exception)) + finally: + os.unlink(tmpfile) + + # 对象不存在时不该尝试锁定/写入 + client.lock.assert_not_called() + client.set_source.assert_not_called() + + def test_upload_type_without_source(self): + """functiongroup 无源码类型抛 InvalidNameError。""" + from sapcli.commands.upload import cmd_upload + from sapcli.exceptions import InvalidNameError + + client = _make_client() + with self.assertRaises(InvalidNameError): + cmd_upload( + _args(name="ZTEST_FG", type="functiongroup", path=".", corr_nr=None), + client, + ) + + client.lock.assert_not_called() + client.set_source.assert_not_called() + + def test_upload_with_corr_nr(self): + """通过 --corr_nr 指定传输请求号,lock 使用该号。""" + from sapcli.commands.upload import cmd_upload + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK901362") + client.set_source.return_value = True + client.unlock.return_value = True + + tmpfile = self._write_tmp_source() + try: + cmd_upload( + _args(name="ZTEST", type="class", path=tmpfile, corr_nr="DEVK901362"), + client, + ) + finally: + os.unlink(tmpfile) + + # corr_nr 可作为位置参数或关键字参数传入 + call_args = client.lock.call_args + passed_corr = ( + call_args[1].get("corr_nr") + if "corr_nr" in call_args[1] + else call_args[0][-1] + ) + self.assertEqual(passed_corr, "DEVK901362") + client.set_source.assert_called_once() + + def test_upload_file_not_found(self): + """本地文件不存在时抛 ConfigError。""" + from sapcli.commands.upload import cmd_upload + from sapcli.exceptions import ConfigError + + client = _make_client() + with self.assertRaises(ConfigError): + cmd_upload( + _args( + name="ZTEST", type="report", + path="/nonexistent/no_such_file.abap", corr_nr=None, + ), + client, + ) + + client.lock.assert_not_called() + client.set_source.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════ +# syntax_check.py — cmd_syntax_check(远程对象语法检查,不上传不激活) +# ═══════════════════════════════════════════════════════════════ + +class TestSyntaxCheckCommand(unittest.TestCase): + """cmd_syntax_check 检查远程对象语法,不上传代码、不激活。""" + + def test_syntax_check_passed(self): + """语法检查通过,输出含"通过"。""" + from sapcli.commands.syntax_check import cmd_syntax_check + + client = _make_client() + client.object_exists.return_value = True + client.syntax_check.return_value = (True, []) + + with patch("builtins.print") as mock_print: + cmd_syntax_check(_args(name="ZTEST", type="class"), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("通过", printed) + client.syntax_check.assert_called_once() + # 区分"真实现"和"假实现":绝不锁定/写入/激活 + client.lock.assert_not_called() + client.set_source.assert_not_called() + client.activate.assert_not_called() + + def test_syntax_check_failed(self): + """语法检查失败,输出含错误行号和描述。""" + from sapcli.commands.syntax_check import cmd_syntax_check + + client = _make_client() + client.object_exists.return_value = True + client.syntax_check.return_value = ( + False, + [{"type": "E", "line": "42", "text": "Field UNKNOWN not found", "href": ""}], + ) + + with patch("builtins.print") as mock_print: + cmd_syntax_check(_args(name="ZTEST", type="report"), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("42", printed) + self.assertIn("UNKNOWN", printed) + + def test_syntax_check_nonexistent(self): + """对象不存在时抛 ObjectNotFoundError。""" + from sapcli.commands.syntax_check import cmd_syntax_check + from sapcli.exceptions import ObjectNotFoundError + + client = _make_client() + client.object_exists.return_value = False + + with self.assertRaises(ObjectNotFoundError): + cmd_syntax_check(_args(name="ZMISSING", type="report"), client) + + client.syntax_check.assert_not_called() + + def test_syntax_check_type_without_source(self): + """无源码类型抛 InvalidNameError。""" + from sapcli.commands.syntax_check import cmd_syntax_check + from sapcli.exceptions import InvalidNameError + + client = _make_client() + with self.assertRaises(InvalidNameError): + cmd_syntax_check(_args(name="ZTEST_FG", type="functiongroup"), client) + + client.syntax_check.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════ +# ddl_query.py — cmd_show_table / cmd_read_table +# ═══════════════════════════════════════════════════════════════ + +class TestShowTableCommand(unittest.TestCase): + """cmd_show_table 查询 DDIC 表字段结构。""" + + def test_show_table_success(self): + from sapcli.commands.ddl_query import cmd_show_table + + client = _make_client() + client.get_table_fields.return_value = [ + {"name": "MANDT", "type": "CLNT", "length": "3", "key_attribute": "X", "description": "Client"}, + {"name": "OBJ_ID", "type": "CHAR", "length": "20", "key_attribute": "X", "description": "Object ID"}, + ] + + with patch("builtins.print") as mock_print: + cmd_show_table(_args(name="ZMY_TABLE"), client) + + # 区分真/假实现:get_table_fields 被调用一次,且参数是大写表名 + client.get_table_fields.assert_called_once_with("ZMY_TABLE") + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("ZMY_TABLE", printed) + self.assertIn("OBJ_ID", printed) + + def test_show_table_no_fields(self): + from sapcli.commands.ddl_query import cmd_show_table + + client = _make_client() + client.get_table_fields.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_show_table(_args(name="ZEMPTY"), client) + + # 空字段列表 → 打印"无字段信息"并返回 + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无字段信息", printed) + client.get_table_fields.assert_called_once_with("ZEMPTY") + + def test_show_table_uppercase(self): + """传入小写表名,client.get_table_fields 收到的应是大写。""" + from sapcli.commands.ddl_query import cmd_show_table + + client = _make_client() + client.get_table_fields.return_value = [ + {"name": "F1", "type": "CHAR", "length": "10", "key_attribute": "", "description": "F"}, + ] + + with patch("builtins.print"): + cmd_show_table(_args(name="zmy_table"), client) + + # 大小写归一化在 cmd 内完成,client 收到 "ZMY_TABLE" + client.get_table_fields.assert_called_once_with("ZMY_TABLE") + + +class TestReadTableCommand(unittest.TestCase): + """cmd_read_table 通过 freestyle SQL 查询表数据。""" + + def test_read_table_basic(self): + from sapcli.commands.ddl_query import cmd_read_table + + client = _make_client() + client.query_table_data.return_value = { + "columns": ["MANDT", "OBJ_ID"], + "rows": [["100", "OBJ1"], ["100", "OBJ2"]], + "total_rows": 2, + } + + with patch("builtins.print") as mock_print: + cmd_read_table(_args(name="ZMY_TABLE", max_rows=50), client) + + # 区分真/假实现:query_table_data 收到的 SQL 含大写表名和 UP TO N ROWS + sql_arg = client.query_table_data.call_args[0][0] + self.assertIn("ZMY_TABLE", sql_arg) + self.assertIn("UP TO 50 ROWS", sql_arg) + # max_rows 也作为关键字参数传给 query_table_data + self.assertEqual(client.query_table_data.call_args[1].get("max_rows"), 50) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("OBJ1", printed) + + def test_read_table_with_where(self): + from sapcli.commands.ddl_query import cmd_read_table + + client = _make_client() + client.query_table_data.return_value = { + "columns": ["MANDT"], + "rows": [["100"]], + "total_rows": 1, + } + + with patch("builtins.print"): + cmd_read_table( + _args(name="ZMY_TABLE", where="FIELD1 = 'X'", max_rows=200), + client, + ) + + sql_arg = client.query_table_data.call_args[0][0] + self.assertIn("WHERE", sql_arg) + self.assertIn("FIELD1 = 'X'", sql_arg) + + def test_read_table_with_fields(self): + from sapcli.commands.ddl_query import cmd_read_table + + client = _make_client() + client.query_table_data.return_value = { + "columns": ["F1", "F2"], + "rows": [["v1", "v2"]], + "total_rows": 1, + } + + with patch("builtins.print"): + cmd_read_table( + _args(name="ZMY_TABLE", fields="F1,F2", max_rows=200), + client, + ) + + # 指定字段 → SELECT F1,F2 而非 SELECT * + sql_arg = client.query_table_data.call_args[0][0] + self.assertIn("F1,F2", sql_arg) + self.assertTrue(sql_arg.startswith("SELECT F1,F2")) + + def test_read_table_empty_data(self): + from sapcli.commands.ddl_query import cmd_read_table + + client = _make_client() + client.query_table_data.return_value = { + "columns": ["MANDT"], + "rows": [], + "total_rows": 0, + } + + with patch("builtins.print") as mock_print: + cmd_read_table(_args(name="ZMY_TABLE", max_rows=200), client) + + # 空 rows → 打印"无数据"并返回 + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无数据", printed) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_crud_extra.py b/tests/unit/test_crud_extra.py new file mode 100644 index 0000000..d8133c8 --- /dev/null +++ b/tests/unit/test_crud_extra.py @@ -0,0 +1,1120 @@ +"""commands/crud.py 深入单元测试 — 补全 _sync_single / cmd_info / cmd_delete / +_select_transport_request / cmd_create / _create_ddic / manifest 更新等分支。 + +运行: python tests/unit/test_crud_extra.py +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + +from sapcli.client import ADTClient +from sapcli.exceptions import ( + CreateError, + DeleteError, + InvalidNameError, + LockError, + ObjectNotFoundError, + SapCliError, +) + + +# ── helpers ────────────────────────────────────────────────────── + +def _mock_resp(status_code=200, text="", content=b"", headers=None): + r = MagicMock() + r.status_code = status_code + r.text = text + r.content = content + r.headers = headers or {} + r.raise_for_status = MagicMock() + return r + + +def _make_client(): + """创建完全 mock 的 ADTClient。""" + client = MagicMock(spec=ADTClient) + client.host = "https://sap.example.com" + client.sap_client = "100" + client.csrf_token = "test-csrf-token" + client.user = "TESTUSER" + client.session = MagicMock() + client._headers.return_value = { + "content-type": "application/xml", + "x-csrf-token": "test-csrf-token", + } + return client + + +def _args(**kwargs): + defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +def _write_source(content="REPORT ztest.\nWRITE: / 'hello'.\n"): + """写入临时 .abap 文件,返回路径。""" + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".abap", delete=False, encoding="utf-8" + ) + f.write(content) + f.close() + return f.name + + +def _info_xml(name="ZTEST", version="active", package=None): + head = ( + b'' + b'' + return ( + head + b'>' + b'' + b'' + ) + + +# ═══════════════════════════════════════════ +# cmd_download / cmd_sync — 无源码类型 +# ═══════════════════════════════════════════ + +class TestCmdDownloadNoSource(unittest.TestCase): + def test_download_functiongroup_raises(self): + from sapcli.commands.crud import cmd_download + + client = _make_client() + with self.assertRaises(InvalidNameError): + cmd_download( + _args(type="functiongroup", name="ZFG", path="."), client + ) + client.get_source.assert_not_called() + + +class TestCmdSyncNoSource(unittest.TestCase): + def test_sync_functiongroup_raises(self): + from sapcli.commands.crud import cmd_sync + + client = _make_client() + tmp = _write_source() + try: + with self.assertRaises(InvalidNameError): + cmd_sync( + _args(type="functiongroup", name="ZFG", path=tmp), client + ) + finally: + os.unlink(tmp) + + +# ═══════════════════════════════════════════ +# cmd_sync — project_path 探测 + 成功/失败清单更新 +# ═══════════════════════════════════════════ + +class TestCmdSyncProjectPathDetection(unittest.TestCase): + @patch("sapcli.commands.crud._sync_single") + def test_detects_project_path_from_manifest(self, mock_sync): + """文件同目录有 manifest.json → project_path 被设为该目录。""" + from sapcli.commands.crud import cmd_sync + + client = _make_client() + mock_sync.return_value = (True, None, "DEVK001") + + with tempfile.TemporaryDirectory() as td: + filepath = os.path.join(td, "ztest.abap") + with open(filepath, "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + + args = _args(type="report", name="ZTEST", path=filepath) + cmd_sync(args, client) + self.assertEqual(args.project_path, td) + + @patch("sapcli.commands.crud._sync_single") + def test_no_manifest_sets_project_path_none(self, mock_sync): + """文件同目录无 manifest.json → project_path = None。""" + from sapcli.commands.crud import cmd_sync + + client = _make_client() + mock_sync.return_value = (True, None, None) + + with tempfile.TemporaryDirectory() as td: + filepath = os.path.join(td, "ztest.abap") + with open(filepath, "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + args = _args(type="report", name="ZTEST", path=filepath) + cmd_sync(args, client) + self.assertIsNone(args.project_path) + + @patch("sapcli.commands.crud._sync_single") + def test_sync_failure_raises_and_updates_manifest(self, mock_sync): + """sync 失败时调用 _update_manifest_after_sync(failed=True) 并抛 SapCliError。""" + from sapcli.commands.crud import cmd_sync + + client = _make_client() + mock_sync.return_value = (False, "激活失败: 行3", "DEVK001") + + with tempfile.TemporaryDirectory() as td: + filepath = os.path.join(td, "ztest.abap") + with open(filepath, "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + args = _args(type="report", name="ZTEST", path=filepath) + with self.assertRaises(SapCliError): + cmd_sync(args, client) + # 清单被写入失败标记 + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertEqual( + data["objects"]["ZTEST"]["last_sync_result"], "failed" + ) + + +# ═══════════════════════════════════════════ +# _sync_single — 各分支 +# ═══════════════════════════════════════════ + +class TestSyncSingleBranches(unittest.TestCase): + """_sync_single 核心同步逻辑的各种分支。""" + + def test_no_source_type_returns_false(self): + from sapcli.commands.crud import _sync_single + + client = _make_client() + ok, err, corr = _sync_single("ZFG", "functiongroup", "/x.abap", client) + self.assertFalse(ok) + self.assertIn("没有源代码", err) + self.assertIsNone(corr) + + def test_file_not_found_returns_false(self): + from sapcli.commands.crud import _sync_single + + client = _make_client() + ok, err, corr = _sync_single( + "ZTEST", "report", "/nonexistent/no.abap", client + ) + self.assertFalse(ok) + self.assertIn("文件不存在", err) + self.assertIsNone(corr) + + def test_object_not_exists_auto_creates(self): + """对象不存在时自动 create_object。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = False + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + self.assertTrue(ok) + finally: + os.unlink(tmp) + client.create_object.assert_called_once() + + def test_create_object_failure_returns_false(self): + """自动创建对象失败 → 返回 False。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = False + client.create_object.side_effect = Exception("create boom") + + tmp = _write_source() + try: + ok, err, _ = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("创建失败", err) + + def test_lock_with_corr_nr(self): + """指定 corr_nr 时用该号锁定。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertTrue(ok) + self.assertEqual(corr, "DEVK001") + # lock 第二位置参数为 corr_nr + self.assertEqual(client.lock.call_args[0][1], "DEVK001") + + def test_lock_without_corr_detects_transport(self): + """无 corr_nr 但 lock 返回检测到的传输请求号。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK901999") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client) + finally: + os.unlink(tmp) + self.assertTrue(ok) + self.assertEqual(corr, "DEVK901999") + + def test_lock_without_corr_local_object(self): + """无 corr_nr 且 lock 未返回传输请求号 → 本地对象。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", None) + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client) + finally: + os.unlink(tmp) + self.assertTrue(ok) + self.assertIsNone(corr) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_lock_error_then_select_transport(self, mock_select): + """lock 抛 LockError → 交互式选择传输请求后重试锁定。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.side_effect = [LockError("need corr"), ("lh_1", "DEVK001")] + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client) + finally: + os.unlink(tmp) + self.assertTrue(ok) + self.assertEqual(corr, "DEVK001") + mock_select.assert_called_once() + + @patch("sapcli.commands.crud._select_transport_request", return_value=None) + def test_lock_error_select_returns_none_fails(self, mock_select): + """lock 抛 LockError 且无法获取传输请求号 → 返回失败。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.side_effect = [LockError("need corr")] + + tmp = _write_source() + try: + ok, err, corr = _sync_single("ZTEST", "report", tmp, client) + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("传输请求号", err) + + def test_lock_error_outer_caught(self): + """带 corr_nr 时 lock 直接抛 LockError(不走选择)→ 返回失败。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.side_effect = LockError("locked by other") + + tmp = _write_source() + try: + ok, err, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("locked by other", err) + + def test_syntax_check_failure_returns_false(self): + """语法检查未通过 → 返回失败,列出错误。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = ( + False, + [ + {"type": "E", "line": "10", "text": "field missing"}, + {"type": "W", "line": "5", "text": "warning msg"}, + ], + ) + + tmp = _write_source() + try: + ok, err, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("语法检查未通过", err) + self.assertIn("10", err) + self.assertEqual(corr, "DEVK001") + client.activate.assert_not_called() + + def test_syntax_check_exception_skips_to_activate(self): + """语法检查抛异常 → 跳过检查直接激活。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.side_effect = Exception("check svc down") + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, _ = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertTrue(ok) + client.activate.assert_called_once() + + def test_activation_failure_returns_false(self): + """激活失败 → 返回失败并列出错误。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = ( + False, + [ + {"type": "E", "line": "3", "text": "act error"}, + {"type": "W", "line": "1", "text": "act warn"}, + ], + ) + + tmp = _write_source() + try: + ok, err, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("激活失败", err) + self.assertIn("3", err) + + +# ═══════════════════════════════════════════ +# _update_manifest_after_sync +# ═══════════════════════════════════════════ + +class TestUpdateManifestAfterSync(unittest.TestCase): + def _manifest_with_entry(self, td, name="ZTEST"): + path = os.path.join(td, "manifest.json") + with open(path, "w", encoding="utf-8") as f: + json.dump( + { + "version": 1, + "objects": { + name: { + "type": "report", "file": "reports/ztest.abap", + "system_status": "active", + } + }, + }, + f, + ) + return path + + def test_no_project_path_returns(self): + from sapcli.commands.crud import _update_manifest_after_sync + _update_manifest_after_sync( + argparse.Namespace(project_path=None), "ZTEST", "report", "/x.abap", None + ) + + def test_no_manifest_file_returns(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZTEST", "report", "/x.abap", None + ) + + def test_existing_entry_updated_success(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + mpath = self._manifest_with_entry(td) + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZTEST", "report", + os.path.join(td, "ztest.abap"), "DEVK001", + ) + with open(mpath, encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["objects"]["ZTEST"]["corr_nr"], "DEVK001") + self.assertEqual(data["objects"]["ZTEST"]["last_sync_result"], "success") + self.assertEqual(data["objects"]["ZTEST"]["system_status"], "active") + + def test_existing_entry_failure_keeps_status(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + mpath = self._manifest_with_entry(td) + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZTEST", "report", + os.path.join(td, "ztest.abap"), "DEVK001", failed=True, + ) + with open(mpath, encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["objects"]["ZTEST"]["last_sync_result"], "failed") + # failed 时 system_status 不变(仍 active) + self.assertEqual(data["objects"]["ZTEST"]["system_status"], "active") + + def test_new_entry_upserted(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZNEW", "report", + os.path.join(td, "reports", "znew.abap"), "DEVK001", + ) + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertIn("ZNEW", data["objects"]) + self.assertEqual(data["objects"]["ZNEW"]["system_status"], "active") + + def test_new_entry_failure_marked_inactive(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZNEW", "report", + os.path.join(td, "znew.abap"), None, failed=True, + ) + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["objects"]["ZNEW"]["system_status"], "inactive") + self.assertEqual(data["objects"]["ZNEW"]["last_sync_result"], "failed") + + def test_corrupt_manifest_exception_swallowed(self): + """Manifest.load 抛异常时被吞掉,不传播。""" + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + f.write("NOT VALID JSON") + # 不抛异常 + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZTEST", "report", "/x.abap", None + ) + + +# ═══════════════════════════════════════════ +# cmd_info — 状态图标 +# ═══════════════════════════════════════════ + +class TestCmdInfoStatusIcons(unittest.TestCase): + def test_inactive_version_icon(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_info_xml(version="inactive")) + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未激活", printed) + + def test_unknown_version_icon(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_info_xml(version="draft")) + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("draft", printed) + + +# ═══════════════════════════════════════════ +# cmd_info — 开发包字段 +# ═══════════════════════════════════════════ + +class TestCmdInfoPackage(unittest.TestCase): + def test_package_displayed(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp( + 200, content=_info_xml(package="ZMY_PKG") + ) + client.query_table_data.return_value = {"rows": []} + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("开发包", printed) + self.assertIn("ZMY_PKG", printed) + + def test_package_missing_shows_unspecified(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_info_xml()) + client.query_table_data.return_value = {"rows": []} + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("(未指定)", printed) + + +# ═══════════════════════════════════════════ +# cmd_delete — 分支 +# ═══════════════════════════════════════════ + +class TestCmdDeleteBranches(unittest.TestCase): + def test_delete_object_not_found_raises(self): + from sapcli.commands.crud import cmd_delete + client = _make_client() + client.object_exists.return_value = False + with self.assertRaises(ObjectNotFoundError): + cmd_delete(_args(), client) + + @patch("builtins.input", return_value="no") + def test_delete_cancelled_returns(self, mock_input): + from sapcli.commands.crud import cmd_delete + client = _make_client() + client.object_exists.return_value = True + with patch("builtins.print") as mock_print: + cmd_delete(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("取消", printed) + client.delete_object.assert_not_called() + + @patch("builtins.input", return_value="yes") + def test_delete_no_corr_nr(self, mock_input): + """无传输请求号时仍尝试删除。""" + from sapcli.commands.crud import cmd_delete + client = _make_client() + client.object_exists.return_value = True + client.get_transport_request.return_value = None + client.delete_object.return_value = (True, "") + with patch("builtins.print") as mock_print: + cmd_delete(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无传输号", printed) + client.delete_object.assert_called_once() + + @patch("builtins.input", return_value="yes") + def test_delete_failure_raises(self, mock_input): + from sapcli.commands.crud import cmd_delete + client = _make_client() + client.object_exists.return_value = True + client.get_transport_request.return_value = "DEVK001" + client.delete_object.return_value = (False, "locked") + with patch("builtins.print"): + with self.assertRaises(DeleteError) as ctx: + cmd_delete(_args(), client) + self.assertIn("locked", str(ctx.exception)) + + +# ═══════════════════════════════════════════ +# _update_manifest_after_delete +# ═══════════════════════════════════════════ + +class TestUpdateManifestAfterDelete(unittest.TestCase): + def test_no_project_path_returns(self): + from sapcli.commands.crud import _update_manifest_after_delete + _update_manifest_after_delete(argparse.Namespace(project_path=None), "ZTEST") + + def test_no_manifest_file_returns(self): + from sapcli.commands.crud import _update_manifest_after_delete + with tempfile.TemporaryDirectory() as td: + _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZTEST") + + def test_removes_existing_entry(self): + from sapcli.commands.crud import _update_manifest_after_delete + with tempfile.TemporaryDirectory() as td: + mpath = os.path.join(td, "manifest.json") + with open(mpath, "w", encoding="utf-8") as f: + json.dump( + {"version": 1, "objects": {"ZTEST": {"type": "report", "file": "x.abap"}}}, + f, + ) + _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZTEST") + with open(mpath, encoding="utf-8") as f: + data = json.load(f) + self.assertNotIn("ZTEST", data["objects"]) + + def test_remove_nonexistent_entry_no_save(self): + """移除不存在的条目时 remove 返回 False,不保存。""" + from sapcli.commands.crud import _update_manifest_after_delete + with tempfile.TemporaryDirectory() as td: + mpath = os.path.join(td, "manifest.json") + with open(mpath, "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + mtime_before = os.path.getmtime(mpath) + _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZNONE") + # 不抛异常即可(remove 返回 False → 不 save) + + def test_corrupt_manifest_exception_swallowed(self): + from sapcli.commands.crud import _update_manifest_after_delete + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + f.write("BAD JSON") + _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZTEST") + + +# ═══════════════════════════════════════════ +# _select_transport_request — 交互式 +# ═══════════════════════════════════════════ + +class TestSelectTransportRequest(unittest.TestCase): + def test_api_error_returns_none(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.side_effect = Exception("fail") + with patch("builtins.print"): + self.assertIsNone(_select_transport_request(client)) + + def test_choose_existing_by_index(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + {"number": "DEVK002", "description": "b", "owner": "u"}, + ] + with patch("builtins.input", return_value="1"): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_choose_zero_no_transport(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", return_value="0"): + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_choose_new_request_success(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + {"number": "DEVK002", "description": "b", "owner": "u"}, + ] + # "3" = 新建(2 + 1),随后输入描述 + with patch("builtins.input", side_effect=["3", "new feature"]): + client.create_transport_request.return_value = "DEVK999" + result = _select_transport_request(client) + self.assertEqual(result, "DEVK999") + + def test_choose_new_request_create_returns_none(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + # "2" = 新建,描述,create 返回 None + with patch("builtins.input", side_effect=["2", "desc"]): + client.create_transport_request.return_value = None + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_choose_new_request_create_raises(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", side_effect=["2", "desc"]): + client.create_transport_request.side_effect = Exception("no perm") + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_invalid_input_then_valid(self): + """非数字输入后重新输入有效索引。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", side_effect=["abc", "1"]): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_empty_input_then_valid(self): + """空输入(直接回车)时重新提示。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", side_effect=["", "1"]): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_new_request_empty_desc_then_valid(self): + """新建请求但描述为空 → 提示后重新选择,再选已有请求。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + # "2"=新建, ""=空描述(continue), "1"=选 DEVK001 + with patch("builtins.input", side_effect=["2", "", "1"]): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_out_of_range_then_valid(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", side_effect=["99", "1"]): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_empty_list_create_new(self): + """无可修改请求时,选择新建。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [] + with patch("builtins.input", side_effect=["y", "new desc"]): + client.create_transport_request.return_value = "DEVK888" + result = _select_transport_request(client) + self.assertEqual(result, "DEVK888") + + def test_empty_list_decline(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [] + with patch("builtins.input", return_value="n"): + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_empty_list_empty_description(self): + """空列表 + 选择新建但描述为空 → 无传输号创建。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [] + with patch("builtins.input", side_effect=["y", ""]): + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_empty_list_create_raises(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [] + with patch("builtins.input", side_effect=["y", "desc"]): + client.create_transport_request.side_effect = Exception("err") + result = _select_transport_request(client) + self.assertIsNone(result) + + +# ═══════════════════════════════════════════ +# cmd_create — function / functiongroup / 异常 +# ═══════════════════════════════════════════ + +class TestCmdCreateBranches(unittest.TestCase): + def test_create_function_without_slash_raises(self): + from sapcli.commands.crud import cmd_create + client = _make_client() + with self.assertRaises(InvalidNameError): + cmd_create( + _args(type="function", name="Z_NO_SLASH", source=None, + description="d", package="$TMP", corr_nr="DEVK001"), + client, + ) + + def test_create_object_already_exists_raises(self): + """非 functiongroup 类型且对象已存在 → ObjectAlreadyExistsError。""" + from sapcli.commands.crud import cmd_create + from sapcli.exceptions import ObjectAlreadyExistsError + client = _make_client() + client.object_exists.return_value = True + with self.assertRaises(ObjectAlreadyExistsError): + cmd_create( + _args(type="class", name="ZCL_EXISTS", source=None, + description="d", package="$TMP", corr_nr="DEVK001"), + client, + ) + + @patch("sapcli.commands.crud.Manifest") + def test_create_corr_nr_specified_skips_select(self, MockManifest): + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_object.return_value = ("/uri/z", "/uri/z/source") + cmd_create( + _args(type="report", name="ZTEST", source=None, + description="d", package="$TMP", corr_nr="DEVK001"), + client, + ) + client.create_object.assert_called_once() + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_function_creates_group(self, MockManifest, mock_transport): + """function 类型且函数组不存在 → 自动创建函数组。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.function_group_exists.return_value = False + client.create_function_group.return_value = "/uri/group" + client.create_object.return_value = ("/uri/func", "/uri/func/source") + cmd_create( + _args(type="function", name="ZGRP/Z_FUNC", source=None, + description="d", package="$TMP", corr_nr=None), + client, + ) + client.create_function_group.assert_called_once() + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_function_group_exists(self, MockManifest, mock_transport): + """function 类型且函数组已存在 → 跳过创建。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.function_group_exists.return_value = True + client.create_object.return_value = ("/uri/func", "/uri/func/source") + cmd_create( + _args(type="function", name="ZGRP/Z_FUNC", source=None, + description="d", package="$TMP", corr_nr=None), + client, + ) + client.create_function_group.assert_not_called() + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_create_function_group_create_raises(self, mock_transport): + """function 函数组创建失败 → CreateError。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.function_group_exists.return_value = False + client.create_function_group.side_effect = Exception("group fail") + with self.assertRaises(CreateError): + cmd_create( + _args(type="function", name="ZGRP/Z_FUNC", source=None, + description="d", package="$TMP", corr_nr=None), + client, + ) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_create_functiongroup_create_raises(self, mock_transport): + """functiongroup 创建失败 → CreateError。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.function_group_exists.return_value = False + client.create_function_group.side_effect = Exception("boom") + with self.assertRaises(CreateError): + cmd_create( + _args(type="functiongroup", name="ZFG", source=None, + description="d", package="$TMP", corr_nr=None), + client, + ) + + @patch("sapcli.commands.crud.Manifest") + def test_create_object_raises(self, MockManifest): + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_object.side_effect = Exception("create boom") + with self.assertRaises(CreateError): + cmd_create( + _args(type="report", name="ZTEST", source=None, + description="d", package="$TMP", corr_nr="DEVK001"), + client, + ) + + +# ═══════════════════════════════════════════ +# _update_manifest_after_create +# ═══════════════════════════════════════════ + +class TestUpdateManifestAfterCreate(unittest.TestCase): + def test_no_project_path_returns(self): + from sapcli.commands.crud import _update_manifest_after_create + _update_manifest_after_create( + argparse.Namespace(project_path=None), "ZTEST", "report", "DEVK001" + ) + + def test_no_manifest_file_returns(self): + from sapcli.commands.crud import _update_manifest_after_create + with tempfile.TemporaryDirectory() as td: + _update_manifest_after_create( + argparse.Namespace(project_path=td), "ZTEST", "report", "DEVK001" + ) + + def test_report_gets_reports_dir(self): + from sapcli.commands.crud import _update_manifest_after_create + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + _update_manifest_after_create( + argparse.Namespace(project_path=td), "ZTEST", "report", "DEVK001" + ) + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["objects"]["ZTEST"]["file"], "reports/ztest.abap") + + def test_function_gets_functions_dir(self): + from sapcli.commands.crud import _update_manifest_after_create + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + _update_manifest_after_create( + argparse.Namespace(project_path=td), "ZGRP/Z_FUNC", "function", None + ) + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertEqual( + data["objects"]["ZGRP/Z_FUNC"]["file"], "functions/zgrp/z_func.abap" + ) + + def test_corrupt_manifest_exception_swallowed(self): + from sapcli.commands.crud import _update_manifest_after_create + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + f.write("BAD") + _update_manifest_after_create( + argparse.Namespace(project_path=td), "ZTEST", "report", None + ) + + +# ═══════════════════════════════════════════ +# _create_ddic — 各类型 + 默认模板 + 异常 +# ═══════════════════════════════════════════ + +class TestCreateDdic(unittest.TestCase): + def _run_create(self, obj_type, name, definition_file=None): + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_ddic_object.return_value = ("/uri", "/uri/source") + with patch("sapcli.commands.crud.Manifest"): + cmd_create( + _args(type=obj_type, name=name, source=None, + definition=definition_file, description="d", + package="$TMP", corr_nr="DEVK001"), + client, + ) + return client + + def _write_def(self, payload): + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) + json.dump(payload, f) + f.close() + return f.name + + def test_structure_with_definition(self): + from sapcli.commands.crud import cmd_create + deffile = self._write_def({ + "fields": [{"name": "F1", "type": "char10"}], + "enhancement_category": "#NOT_CLASSIFIED", + }) + try: + client = self._run_create("structure", "ZSTRUCT", deffile) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(deffile) + + def test_tabletype_with_definition(self): + from sapcli.commands.crud import cmd_create + deffile = self._write_def({ + "line_type": "ZSTRUCT", + "key_type": "#USER_DEFINED", + "access_mode": "#STANDARD", + }) + try: + client = self._run_create("tabletype", "ZTT", deffile) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(deffile) + + def test_domain_default_template(self): + """domain 无 definition → 默认 DomainDefinition 模板。""" + client = self._run_create("domain", "ZDOM", None) + client.create_ddic_object.assert_called_once() + body = client.create_ddic_object.call_args[0][2] + # 默认模板应生成非空 XML body + self.assertIsInstance(body, str) + self.assertTrue(len(body) > 0) + + def test_table_default_template(self): + """table 无 definition → 默认 TableDefinition 模板。""" + client = self._run_create("table", "ZTAB", None) + client.create_ddic_object.assert_called_once() + + def test_dataelement_default_template(self): + """dataelement 无 definition → 默认模板。""" + client = self._run_create("dataelement", "ZDE", None) + client.create_ddic_object.assert_called_once() + + def test_structure_default_template(self): + """structure 无 definition → 默认 StructureDefinition 模板。""" + client = self._run_create("structure", "ZSTRUCT", None) + client.create_ddic_object.assert_called_once() + + def test_create_ddic_object_raises(self): + """create_ddic_object 失败 → CreateError。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_ddic_object.side_effect = Exception("ddic fail") + with patch("sapcli.commands.crud.Manifest"): + with self.assertRaises(CreateError): + cmd_create( + _args(type="domain", name="ZDOM", source=None, + definition=None, description="d", + package="$TMP", corr_nr="DEVK001"), + client, + ) + + def test_domain_with_definition_fix_values(self): + """domain definition 含 fix_values → 仍走 to_xml。""" + from sapcli.commands.crud import cmd_create + deffile = self._write_def({ + "datatype": "CHAR", "length": 1, + "fix_values": [{"low": "A", "text": "Active"}], + }) + try: + client = self._run_create("domain", "ZDOM", deffile) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(deffile) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_ddic.py b/tests/unit/test_ddic.py new file mode 100644 index 0000000..729afab --- /dev/null +++ b/tests/unit/test_ddic.py @@ -0,0 +1,1076 @@ +"""client/_ddic.py 单元测试 — DdicMixin 的 DDIC/CRUD/查询/版本/增强方法。 + +与 test_client.py 互补:后者覆盖 _ddic 的部分公共方法,本文件聚焦 +未覆盖的方法与分支(function 创建、各 _build_create_body 类型、DDIC 写入、 +表查询、ABAP Unit、版本历史、增强实现、XML namespace helpers 等)。 + +运行: python -m pytest tests/unit/test_ddic.py -v +""" +from __future__ import annotations + +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +import xml.etree.ElementTree as ET + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + +from sapcli.client import ADTClient +from sapcli.client._ddic import _local, _attr_local, _find_local +from sapcli.exceptions import CreateError, DeleteError +from sapcli.types import parse_object_name + +# ADT XML 命名空间 +ADTCORE_NS = "http://www.sap.com/adt/core" +LOCK_NS = "http://www.sap.com/adt/lock" +DP_NS = "http://www.sap.com/adt/dataPreview" + + +def _make_client() -> ADTClient: + """创建 mock 好的 ADTClient(绕过 __init__,手动注入依赖)。""" + client = ADTClient.__new__(ADTClient) + client.host = "https://sap.example.com" + client.sap_client = "100" + client.user = "TESTUSER" + client.password = "TESTPASS" + client.csrf_token = "test-csrf-token" + client.session = MagicMock() + client._stateful = False + return client + + +def _mock_resp(status_code=200, text="", content=b"", headers=None): + """快速创建 mock response。""" + resp = MagicMock() + resp.status_code = status_code + resp.text = text + resp.content = content + resp.headers = headers or {} + resp.raise_for_status = MagicMock() + return resp + + +def _lock_resp(handle="lh", corr=""): + """构造 lock POST 成功响应。""" + return _mock_resp(200, content=( + f'{handle}' + f'{corr}' + ).encode()) + + +# ═══════════════════════════════════════════ +# 模块级 XML namespace helpers +# ═══════════════════════════════════════════ + +class TestLocalHelpers(unittest.TestCase): + """_local / _attr_local / _find_local — 本地名匹配,忽略命名空间。""" + + def test_local_with_namespace(self): + self.assertEqual(_local("{http://www.sap.com/adt/core}name"), "name") + + def test_local_without_namespace(self): + self.assertEqual(_local("name"), "name") + + def test_local_empty(self): + self.assertEqual(_local(""), "") + + def test_attr_local_found(self): + el = ET.fromstring(f'') + self.assertEqual(_attr_local(el, "name"), "ZTEST") + + def test_attr_local_plain_name(self): + """无命名空间前缀的属性也能匹配。""" + el = ET.fromstring("") + self.assertEqual(_attr_local(el, "name"), "ZTEST") + + def test_attr_local_missing_returns_empty(self): + el = ET.fromstring("") + self.assertEqual(_attr_local(el, "name"), "") + + def test_find_local_found(self): + root = ET.fromstring( + f'' + ) + summary = _find_local(root, "summary") + self.assertIsNotNone(summary) + self.assertEqual(summary.attrib.get("tests"), "3") + + def test_find_local_missing_returns_none(self): + root = ET.fromstring("") + self.assertIsNone(_find_local(root, "summary")) + + +# ═══════════════════════════════════════════ +# delete_object — corr_nr 分支 +# ═══════════════════════════════════════════ + +class TestDeleteObjectCorrNr(unittest.TestCase): + + def test_with_corr_nr(self): + """带 corr_nr 时 DELETE 请求参数应包含 corrNr。""" + client = _make_client() + client.session.post.return_value = _lock_resp(handle="lh_del") + client.session.delete.return_value = _mock_resp(204) + ok, msg = client.delete_object("/uri", corr_nr="DEVK001") + self.assertTrue(ok) + # 验证 DELETE 调用带上了 lockHandle + corrNr + _, kwargs = client.session.delete.call_args + self.assertEqual(kwargs["params"]["lockHandle"], "lh_del") + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + + +# ═══════════════════════════════════════════ +# parse_object_name — tabletype 存在性检查端点(缺陷2) +# ═══════════════════════════════════════════ + +class TestTableTypeExistsUri(unittest.TestCase): + """tabletype 的存在性检查必须用 DDIC 端点,而非 VIT 端点。 + + VIT 端点 /vit/wb/object_type/ttypda/object_name/{name} 对任意名称都返回 200, + 导致 create 永远走到"对象已存在"分支。obj_uri 仍保留 VIT 供 info 查询完整元数据。 + """ + + def test_tabletype_exists_uri_uses_ddic_endpoint(self): + parsed = parse_object_name("Z_MY_TT", "tabletype") + self.assertEqual( + parsed.exists_uri, + "/sap/bc/adt/ddic/tabletypes/z_my_tt", + ) + + def test_tabletype_obj_uri_still_vit_for_info(self): + """obj_uri 仍是 VIT 端点(info 命令依赖它返回完整元数据)。""" + parsed = parse_object_name("Z_MY_TT", "tabletype") + self.assertIn("/vit/wb/object_type/ttypda/object_name/Z_MY_TT", parsed.obj_uri) + # exists_uri 必须不同于 VIT obj_uri,否则存在性检查失效 + self.assertNotEqual(parsed.exists_uri, parsed.obj_uri) + + def test_non_tabletype_exists_uri_equals_obj_uri(self): + """非 tabletype 类型 exists_uri 回退为 obj_uri(行为不变)。""" + parsed = parse_object_name("ZTEST_DOM", "domain") + self.assertEqual(parsed.exists_uri, parsed.obj_uri) + + +# ═══════════════════════════════════════════ +# create_object — function 分支 + corr_nr +# ═══════════════════════════════════════════ + +class TestCreateObjectFunction(unittest.TestCase): + + def test_function_with_group(self): + """function 类型 'GROUP/FM' 格式正确解析。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + obj_uri, src_uri = client.create_object("function", "ZGROUP/Z_FUNC", "Test fm") + # obj_uri 形如 /sap/bc/adt/functions/groups/zgroup/fmodules/z_func + self.assertIn("zgroup", obj_uri) + self.assertIn("z_func", obj_uri) + self.assertIsNotNone(src_uri) + + def test_function_missing_slash_raises(self): + """function 类型缺少 '/' 分隔符 → CreateError。""" + client = _make_client() + with self.assertRaises(CreateError): + client.create_object("function", "NOSLASH", "Test") + + def test_with_corr_nr_passes_param(self): + """带 corr_nr 时 POST 请求参数应包含 corrNr(覆盖 line 81)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + client.create_object("report", "ZTEST", "Test", corr_nr="DEVK001") + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + + +# ═══════════════════════════════════════════ +# _build_create_body — 未覆盖的 6 种类型 +# ═══════════════════════════════════════════ + +class TestBuildCreateBodyRemainingTypes(unittest.TestCase): + + def _check(self, obj_type, name, expected_marker): + body = _make_client()._build_create_body(obj_type, name, f"Test {obj_type}") + self.assertIn(name, body) + self.assertIn(expected_marker, body) + + def test_function(self): + self._check("function", "Z_FUNC", "abapFunctionModule") + + def test_include(self): + self._check("include", "ZINCL", "PROG/I") + + def test_include_uses_includes_namespace(self): + """include 使用 programs/includes 命名空间 + abapInclude 元素(缺陷1)。 + + 参考 abap-adt-api objectcreator.ts: + rootName="include:abapInclude", + nameSpace="http://www.sap.com/adt/programs/includes" + 不应复用 report 的 ,否则 POST /programs/includes 返回 400。 + """ + body = _make_client()._build_create_body("include", "ZINCL", "Test include") + self.assertIn("include:abapInclude", body) + self.assertIn("http://www.sap.com/adt/programs/includes", body) + self.assertNotIn("abapProgram", body) + + def test_messageclass(self): + self._check("messageclass", "ZMSG", "t100MessageClass") + + def test_view(self): + self._check("view", "ZVIEW", "dictionary/views") + + def test_searchhelp(self): + self._check("searchhelp", "ZSHLP", "searchHelp") + + def test_lockobject(self): + self._check("lockobject", "ZLOCK", "lockObject") + + def test_description_defaults_to_name(self): + """未提供 description 时用 name 作为描述。""" + body = _make_client()._build_create_body("include", "ZINCL", None) + self.assertIn('description="ZINCL"', body) + + +# ═══════════════════════════════════════════ +# create_ddic_object — corr_nr + 激活失败 +# ═══════════════════════════════════════════ + +_DDIC_DOMAIN_XML = ( + '' + '' + '' + '' +) + + +class TestCreateDDICObjectExtra(unittest.TestCase): + + def test_with_corr_nr(self): + """带 corr_nr 走非 table 分支,POST 参数含 corrNr。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "activate", return_value=(True, [])) as mock_act: + obj_uri, src_uri = client.create_ddic_object( + "domain", "ZTEST_DOM", _DDIC_DOMAIN_XML, corr_nr="DEVK001" + ) + # POST 创建带 corrNr + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + # activate 收到 corr_nr + mock_act.assert_called_once() + self.assertIn("ztest_dom", obj_uri) + + def test_activation_failure_with_errors_raises(self): + """激活失败且含 type=E 消息 → CreateError。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "activate", return_value=( + False, [{"type": "E", "text": "domain error"}] + )): + with self.assertRaises(CreateError) as ctx: + client.create_ddic_object("domain", "ZTEST_DOM", _DDIC_DOMAIN_XML) + self.assertIn("domain error", str(ctx.exception)) + + def test_activation_failure_no_errors_returns(self): + """激活失败但无 type=E 消息 → 不抛异常,正常返回。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "activate", return_value=( + False, [{"type": "W", "text": "warning only"}] + )): + obj_uri, _ = client.create_ddic_object("domain", "ZTEST_DOM", _DDIC_DOMAIN_XML) + self.assertIn("ztest_dom", obj_uri) + + def test_structure_posts_entity_then_puts_ddl(self): + """structure 先 POST 创建对象实体,再 PUT 写入 DDL(缺陷3)。 + + 之前 create_ddic_object 对 table/structure 直接 _put_ddl_source, + PUT 到尚未创建的对象 /source/main 返回 405。修复后先 POST 集合端点创建实体。 + """ + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "_put_ddl_source") as mock_ddl, \ + patch.object(client, "activate", return_value=(True, [])): + obj_uri, _ = client.create_ddic_object("structure", "ZTEST_STR", "") + # 先 POST 创建对象实体 + client.session.post.assert_called_once() + post_url = client.session.post.call_args[0][0] + self.assertIn("/ddic/structures", post_url) + # 再 PUT 写入 DDL 定义 + mock_ddl.assert_called_once() + self.assertIn("ztest_str", obj_uri) + + def test_table_posts_entity_then_puts_ddl(self): + """table 同样先 POST 再 PUT。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "_put_ddl_source") as mock_ddl, \ + patch.object(client, "activate", return_value=(True, [])): + client.create_ddic_object("table", "ZTEST_TAB", "") + client.session.post.assert_called_once() + self.assertIn("/ddic/tables", client.session.post.call_args[0][0]) + mock_ddl.assert_called_once() + + def test_table_post_failure_raises_before_put(self): + """POST 实体创建失败 → CreateError,且不执行 PUT。""" + client = _make_client() + client.session.post.return_value = _mock_resp(405, text="Method Not Allowed") + with patch.object(client, "_put_ddl_source") as mock_ddl, \ + patch.object(client, "activate", return_value=(True, [])): + with self.assertRaises(CreateError): + client.create_ddic_object("table", "ZTEST_TAB", "") + mock_ddl.assert_not_called() + + +# ═══════════════════════════════════════════ +# _put_ddic_xml +# ═══════════════════════════════════════════ + +class TestPutDdicXml(unittest.TestCase): + + def test_success_with_lock_and_corr(self): + client = _make_client() + client.session.put.return_value = _mock_resp(200) + self.assertTrue( + client._put_ddic_xml("/uri", "", lock_handle="lh", corr_nr="DEVK001") + ) + _, kwargs = client.session.put.call_args + self.assertEqual(kwargs["params"]["lockHandle"], "lh") + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + self.assertEqual(kwargs["headers"]["Accept"], "*/*") + + def test_success_no_lock_no_corr(self): + client = _make_client() + client.session.put.return_value = _mock_resp(200) + self.assertTrue(client._put_ddic_xml("/uri", "")) + _, kwargs = client.session.put.call_args + # 无 lock_handle/corr_nr 时不带对应参数 + self.assertNotIn("lockHandle", kwargs["params"]) + self.assertNotIn("corrNr", kwargs["params"]) + + def test_failure_raises_create_error(self): + client = _make_client() + client.session.put.return_value = _mock_resp(400, text="bad xml") + with self.assertRaises(CreateError): + client._put_ddic_xml("/uri", "") + + +# ═══════════════════════════════════════════ +# _put_ddl_with_auto_lock +# ═══════════════════════════════════════════ + +class TestPutDdlWithAutoLock(unittest.TestCase): + + def test_lock_succeeds_unlock_called(self): + client = _make_client() + with patch.object(client, "lock", return_value=("lh", "DEVK001")) as mock_lock, \ + patch.object(client, "set_source", return_value=True) as mock_src, \ + patch.object(client, "unlock") as mock_unlock: + result = client._put_ddl_with_auto_lock("/src", "ddl", "/uri", corr_nr="DEVK001") + self.assertTrue(result) + mock_lock.assert_called_once_with("/uri", "DEVK001") + mock_src.assert_called_once_with("/src", "ddl", "lh", "DEVK001") + mock_unlock.assert_called_once_with("/uri", "lh") + + def test_lock_fails_no_unlock(self): + """lock 抛异常 → 用空锁句柄继续,且不调 unlock。""" + client = _make_client() + with patch.object(client, "lock", side_effect=Exception("lock boom")), \ + patch.object(client, "set_source", return_value=True) as mock_src, \ + patch.object(client, "unlock") as mock_unlock: + result = client._put_ddl_with_auto_lock("/src", "ddl", "/uri") + self.assertTrue(result) + # set_source 收到空 lock_handle + self.assertEqual(mock_src.call_args.args[2], "") + mock_unlock.assert_not_called() + + +# ═══════════════════════════════════════════ +# _put_ddl_source +# ═══════════════════════════════════════════ + +class TestPutDdlSource(unittest.TestCase): + + def test_delegates_to_set_source(self): + client = _make_client() + with patch.object(client, "set_source", return_value=True) as mock_src: + result = client._put_ddl_source("/src", "ddl", "lh", corr_nr="DEVK001") + self.assertTrue(result) + mock_src.assert_called_once_with("/src", "ddl", "lh", "DEVK001") + + +# ═══════════════════════════════════════════ +# get_object_status — 全分支 +# ═══════════════════════════════════════════ + +class TestGetObjectStatusExtra(unittest.TestCase): + + def test_unexpected_status_returns_not_exists(self): + """非 200/404 状态码 → not_exists(覆盖 warning 分支)。""" + client = _make_client() + client.session.get.return_value = _mock_resp(500) + result = client.get_object_status("/uri") + self.assertFalse(result["exists"]) + self.assertEqual(result["status"], "not_exists") + + def test_inactive_version(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + with patch.object(client, "lock", return_value=("lh", "")), \ + patch.object(client, "unlock", return_value=True): + result = client.get_object_status("/uri") + self.assertTrue(result["exists"]) + self.assertEqual(result["status"], "inactive") + + def test_custom_version_string(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + with patch.object(client, "lock", return_value=("lh", "")), \ + patch.object(client, "unlock", return_value=True): + result = client.get_object_status("/uri") + self.assertEqual(result["status"], "custom_v2") + + def test_unparseable_content_stays_active(self): + """XML 解析失败 → status 保持 active。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=b"<>") + with patch.object(client, "lock", return_value=("lh", "")), \ + patch.object(client, "unlock", return_value=True): + result = client.get_object_status("/uri") + self.assertEqual(result["status"], "active") + + def test_corr_nr_detected_via_lock_probe(self): + """lock 探测返回 corr_nr → 写入结果。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + with patch.object(client, "lock", return_value=("lh", "DEVK901999")), \ + patch.object(client, "unlock", return_value=True) as mock_unlock: + result = client.get_object_status("/uri") + self.assertEqual(result["corr_nr"], "DEVK901999") + mock_unlock.assert_called_once_with("/uri", "lh") + + def test_lock_probe_failure_keeps_corr_none(self): + """lock 探测异常 → corr_nr 保持 None,不抛出。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + with patch.object(client, "lock", side_effect=Exception("no perm")): + result = client.get_object_status("/uri") + self.assertIsNone(result["corr_nr"]) + self.assertTrue(result["exists"]) + + +# ═══════════════════════════════════════════ +# create_function_group — corr_nr +# ═══════════════════════════════════════════ + +class TestCreateFunctionGroupCorrNr(unittest.TestCase): + + def test_with_corr_nr(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_function_group("ZGROUP", corr_nr="DEVK001")) + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["groupname"], "ZGROUP") + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + + +# ═══════════════════════════════════════════ +# create_package — superpackage +# ═══════════════════════════════════════════ + +class TestCreatePackageSuperpackage(unittest.TestCase): + + def test_with_superpackage(self): + """带 superpackage → body 含 packageRef(覆盖 line 604)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_package("Z_TEST", "desc", superpackage="Z_PARENT")) + _, kwargs = client.session.post.call_args + body = kwargs["data"].decode("utf-8") + self.assertIn("packageRef", body) + self.assertIn("Z_PARENT", body) + + def test_without_superpackage(self): + """无 superpackage → body 以自闭合结尾。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_package("Z_TEST")) + _, kwargs = client.session.post.call_args + body = kwargs["data"].decode("utf-8") + self.assertNotIn("packageRef", body) + self.assertTrue(body.rstrip().endswith("/>")) + + +# ═══════════════════════════════════════════ +# get_package_info — packageRef +# ═══════════════════════════════════════════ + +class TestGetPackageInfoSuperpackage(unittest.TestCase): + + def test_with_package_ref(self): + """XML 含 adtcore:packageRef → 解析 superpackage(覆盖 line 650)。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + info = client.get_package_info("Z_TEST") + self.assertEqual(info["name"], "Z_TEST") + self.assertEqual(info["description"], "Test pkg") + self.assertEqual(info["owner"], "DEVUSER") + self.assertEqual(info["superpackage"], "Z_PARENT") + + def test_without_package_ref(self): + """XML 无 packageRef → superpackage 保持空字符串。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + info = client.get_package_info("Z_TEST") + self.assertEqual(info["superpackage"], "") + + +# ═══════════════════════════════════════════ +# atc_check — variant +# ═══════════════════════════════════════════ + +class TestATCCheckVariant(unittest.TestCase): + + def test_with_variant(self): + """带 variant → POST 参数含 variant(覆盖 line 677)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + ).encode()) + ok, findings = client.atc_check("ZTEST", "/uri", variant="MY_VARIANT") + self.assertTrue(ok) + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["variant"], "MY_VARIANT") + self.assertEqual(kwargs["params"]["context"], "/uri") + + def test_findings_severity_one_treated_as_error(self): + """severity="1" 同样视为错误。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + ok, findings = client.atc_check("ZTEST", "/uri") + self.assertFalse(ok) + self.assertEqual(len(findings), 1) + + +# ═══════════════════════════════════════════ +# get_table_fields +# ═══════════════════════════════════════════ + +_TABLE_FIELDS_XML = ( + f'' + f'' + f'' + f'' + f'' + f'' + f'' +).encode() + + +class TestGetTableFields(unittest.TestCase): + + def test_parses_fields(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TABLE_FIELDS_XML) + fields = client.get_table_fields("ZTEST_TAB") + self.assertEqual(len(fields), 2) + self.assertEqual(fields[0]["name"], "MANDT") + self.assertEqual(fields[0]["type"], "CHAR") + self.assertEqual(fields[0]["length"], "3") + self.assertEqual(fields[0]["description"], "Client") + self.assertEqual(fields[0]["key_attribute"], "true") + # 未声明 keyAttribute 时默认 "false" + self.assertEqual(fields[1]["key_attribute"], "false") + + def test_uses_lowercase_table_name(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TABLE_FIELDS_XML) + client.get_table_fields("ZTEST_TAB") + url = client.session.get.call_args.args[0] + self.assertIn("/ztest_tab/", url) + + def test_http_error_propagates(self): + client = _make_client() + mock = _mock_resp(500) + mock.raise_for_status.side_effect = Exception("500") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.get_table_fields("ZTEST_TAB") + + +# ═══════════════════════════════════════════ +# query_table_data +# ═══════════════════════════════════════════ + +_FREESTYLE_XML = ( + f'' + f'' + f'' + f'' + f'100200' + f'' + f'' + f'' + f'A001A002' + f'' + f'2' + f'0.012' + f'' +).encode() + + +class TestQueryTableData(unittest.TestCase): + + def test_parses_and_transposes(self): + """列存储 → 行存储转置。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_FREESTYLE_XML) + result = client.query_table_data("SELECT * FROM ztest", max_rows=50) + self.assertEqual(result["columns"], ["MANDT", "MATNR"]) + self.assertEqual(result["rows"], [["100", "A001"], ["200", "A002"]]) + self.assertEqual(result["total_rows"], 2) + self.assertEqual(result["execution_time"], "0.012") + + def test_max_rows_param_sent(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_FREESTYLE_XML) + client.query_table_data("SELECT * FROM ztest", max_rows=500) + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["rowNumber"], "500") + + def test_columns_without_dataset_yields_empty_values(self): + """某列无 dataSet → 该列补空字符串(覆盖 dataset is None 分支)。""" + xml = ( + f'' + f'' + f'' + f'' + f'100' + f'' + f'' + f'' + ).encode() + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=xml) + result = client.query_table_data("SELECT * FROM ztest") + self.assertEqual(result["rows"], [["100", ""]]) + + def test_no_columns_returns_empty(self): + """无任何 columns → 覆盖 max(..., default=0)。""" + xml = ( + f'' + f'' + f'0' + f'' + ).encode() + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=xml) + result = client.query_table_data("SELECT * FROM ztest") + self.assertEqual(result["columns"], []) + self.assertEqual(result["rows"], []) + self.assertEqual(result["total_rows"], 0) + + +# ═══════════════════════════════════════════ +# run_program +# ═══════════════════════════════════════════ + +class TestRunProgram(unittest.TestCase): + + def test_success_returns_text(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="PROGRAM OUTPUT") + self.assertEqual(client.run_program("ZSAPILOT_SETUP"), "PROGRAM OUTPUT") + + def test_url_uses_lowercase_name(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="out") + client.run_program("ZPROG") + url = client.session.post.call_args.args[0] + self.assertIn("/programrun/zprog", url) + + def test_http_error_propagates(self): + client = _make_client() + mock = _mock_resp(500) + mock.raise_for_status.side_effect = Exception("500") + client.session.post.return_value = mock + with self.assertRaises(Exception): + client.run_program("ZPROG") + + +# ═══════════════════════════════════════════ +# run_unit_test + _parse_aunit_result +# ═══════════════════════════════════════════ + +_AUNIT_XML = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' +).encode() + + +class TestRunUnitTest(unittest.TestCase): + + def test_success_returns_parsed(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_AUNIT_XML) + result = client.run_unit_test("/sap/bc/adt/oo/classes/zcl_test") + self.assertEqual(result["summary"]["tests"], "3") + self.assertEqual(result["summary"]["failures"], "1") + self.assertEqual(len(result["classes"]), 1) + # 验证 body 含 obj_uri + _, kwargs = client.session.post.call_args + self.assertIn("/sap/bc/adt/oo/classes/zcl_test", kwargs["data"].decode("utf-8")) + + def test_http_error_propagates(self): + client = _make_client() + mock = _mock_resp(500) + mock.raise_for_status.side_effect = Exception("500") + client.session.post.return_value = mock + with self.assertRaises(Exception): + client.run_unit_test("/uri") + + +class TestParseAunitResult(unittest.TestCase): + + def test_full_parse(self): + result = _make_client()._parse_aunit_result(_AUNIT_XML) + self.assertEqual(result["summary"]["duration"], "450") + cls = result["classes"][0] + self.assertEqual(cls["name"], "ZCL_TEST") + methods = {m["name"]: m for m in cls["methods"]} + self.assertIn("test_foo", methods) + self.assertEqual(methods["test_foo"]["duration"], "0.45") + self.assertEqual(methods["test_foo"]["alert"], "assertion error") + self.assertEqual(methods["test_foo"]["line"], "42") + # test_bar 无 alert + self.assertEqual(methods["test_bar"]["alert"], "") + self.assertEqual(methods["test_bar"]["line"], "") + + def test_empty_result_defaults(self): + """无 summary/testClass → 默认值。""" + xml = ( + '' + '' + ).encode() + result = _make_client()._parse_aunit_result(xml) + self.assertEqual(result["summary"]["tests"], "0") + self.assertEqual(result["classes"], []) + + def test_multiple_alerts_uses_first_title(self): + """多个 alert → alert 取首个 title。""" + xml = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_aunit_result(xml) + m = result["classes"][0]["methods"][0] + self.assertEqual(m["alert"], "first") + self.assertEqual(m["line"], "9") + + +# ═══════════════════════════════════════════ +# get_object_versions + _get_versions_link + _parse_versions +# ═══════════════════════════════════════════ + +_VERSIONS_LINK_XML = ( + f'' + f'' + f'' + f'' +).encode() + +_VERSIONS_FEED_XML = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' +).encode() + + +class TestGetObjectVersions(unittest.TestCase): + + def test_no_link_returns_empty(self): + client = _make_client() + with patch.object(client, "_get_versions_link", return_value=None): + self.assertEqual(client.get_object_versions("/uri"), []) + + def test_relative_href_builds_full_url(self): + client = _make_client() + with patch.object(client, "_get_versions_link", + return_value="/sap/.../versions"), \ + patch.object(client, "_parse_versions", return_value=[{"version": "0001"}]) as mock_parse: + client.session.get.return_value = _mock_resp(200, content=_VERSIONS_FEED_XML) + result = client.get_object_versions("/uri") + url = client.session.get.call_args.args[0] + self.assertTrue(url.startswith("https://sap.example.com/sap/")) + self.assertEqual(result, [{"version": "0001"}]) + mock_parse.assert_called_once() + + def test_absolute_href_used_as_is(self): + client = _make_client() + with patch.object(client, "_get_versions_link", + return_value="http://other.host/versions"): + client.session.get.return_value = _mock_resp(200, content=_VERSIONS_FEED_XML) + client.get_object_versions("/uri") + url = client.session.get.call_args.args[0] + self.assertEqual(url, "http://other.host/versions") + + +class TestGetVersionsLink(unittest.TestCase): + + def test_finds_versions_link(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_VERSIONS_LINK_XML) + href = client._get_versions_link("/uri") + self.assertEqual(href, "/sap/bc/adt/oo/classes/ztest/versions") + + def test_non_200_returns_none(self): + client = _make_client() + client.session.get.return_value = _mock_resp(404) + self.assertIsNone(client._get_versions_link("/uri")) + + def test_unparseable_returns_none(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=b"<>") + self.assertIsNone(client._get_versions_link("/uri")) + + def test_no_versions_link_returns_none(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + ).encode()) + self.assertIsNone(client._get_versions_link("/uri")) + + def test_empty_href_skipped(self): + """rel 含 versions 但 href 为空 → 跳过,返回 None。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + ).encode()) + self.assertIsNone(client._get_versions_link("/uri")) + + +class TestParseVersions(unittest.TestCase): + + def test_unparseable_returns_empty(self): + self.assertEqual(_make_client()._parse_versions(b"<>"), []) + + def test_feed_with_entries(self): + result = _make_client()._parse_versions(_VERSIONS_FEED_XML) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["version"], "0001") # versionId 别名 → version + self.assertEqual(result[0]["author"], "DEVUSER") + self.assertEqual(result[0]["date"], "2026-06-10T14:20:00") + self.assertEqual(result[0]["versionTitle"], "Initial") + self.assertEqual(result[1]["version"], "0002") + + def test_fallback_no_entries(self): + """无 entry 但含 version/versionId 属性的元素 → 各成一行。""" + xml = ( + '' + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_versions(xml) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["version"], "0001") + self.assertEqual(result[0]["author"], "DEV") + self.assertEqual(result[1]["version"], "0002") + + +# ═══════════════════════════════════════════ +# get_enhancements + _parse_enhancements +# ═══════════════════════════════════════════ + +_ENHANCEMENTS_XML = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' +).encode() + + +class TestGetEnhancements(unittest.TestCase): + + def test_success_returns_parsed(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_ENHANCEMENTS_XML) + result = client.get_enhancements("/sap/bc/adt/oo/classes/zcl_test") + self.assertEqual(len(result), 2) + # body 含 obj_uri + _, kwargs = client.session.post.call_args + self.assertIn("/sap/bc/adt/oo/classes/zcl_test", kwargs["data"].decode("utf-8")) + + def test_http_error_propagates(self): + client = _make_client() + mock = _mock_resp(500) + mock.raise_for_status.side_effect = Exception("500") + client.session.post.return_value = mock + with self.assertRaises(Exception): + client.get_enhancements("/uri") + + +class TestParseEnhancements(unittest.TestCase): + + def test_unparseable_returns_empty(self): + self.assertEqual(_make_client()._parse_enhancements(b"<>"), []) + + def test_full_parse(self): + result = _make_client()._parse_enhancements(_ENHANCEMENTS_XML) + self.assertEqual(len(result), 2) + + first = result[0] + self.assertEqual(first["name"], "Z_ENH1") + self.assertEqual(first["type"], "ENHO") + self.assertEqual(first["enhanced_name"], "ZCL_TARGET") + self.assertEqual(first["enhanced_type"], "CLAS/OC") + self.assertEqual(len(first["elements"]), 1) + el = first["elements"][0] + self.assertEqual(el["name"], "method1") + self.assertEqual(el["type"], "METHOD") + self.assertEqual(el["mode"], "overwrite") + self.assertEqual(el["replacing"], "OLD_M") + + # 第二项用 enhanced 回退 + sourceCodePluginElement + second = result[1] + self.assertEqual(second["enhanced_name"], "ZIF_TARGET") + self.assertEqual(second["enhanced_type"], "INTF/OI") + self.assertEqual(second["elements"][0]["name"], "impl1") + self.assertEqual(second["elements"][0]["type"], "INCLUDE") + + def test_enho_type_matching_via_type_attr(self): + """type 属性含 ENHO 但标签名非标准 → 也能匹配。""" + xml = ( + '' + '' + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_enhancements(xml) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["name"], "Z_X") + self.assertEqual(result[0]["elements"][0]["name"], "m") + + def test_element_with_plain_name_type_fallback(self): + """pluginElement 用 name/type 而非 elementName/elementType。""" + xml = ( + '' + '' + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_enhancements(xml) + self.assertEqual(result[0]["elements"][0]["name"], "plain") + self.assertEqual(result[0]["elements"][0]["type"], "METHOD") + + def test_enhancement_without_enhanced_object(self): + """无 enhancedObject/enhanced → enhanced_* 为空字符串。""" + xml = ( + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_enhancements(xml) + self.assertEqual(result[0]["enhanced_name"], "") + self.assertEqual(result[0]["enhanced_type"], "") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_enhancement.py b/tests/unit/test_enhancement.py new file mode 100644 index 0000000..8553ae8 --- /dev/null +++ b/tests/unit/test_enhancement.py @@ -0,0 +1,171 @@ +"""commands/enhancement.py 单元测试 — cmd_enhancement。 + +运行: python tests/unit/test_enhancement.py +""" +from __future__ import annotations + +import argparse +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + + +def _args(**kwargs): + defaults = {"type": "class", "name": "ZCL_TEST"} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +class TestCmdEnhancement(unittest.TestCase): + """cmd_enhancement 查询对象的增强实现。""" + + def test_object_not_found_raises(self): + from sapcli.commands.enhancement import cmd_enhancement + from sapcli.exceptions import ObjectNotFoundError + + client = MagicMock() + client.object_exists.return_value = False + + with self.assertRaises(ObjectNotFoundError) as ctx: + cmd_enhancement(_args(), client) + self.assertIn("ZCL_TEST", str(ctx.exception)) + + def test_api_error_prints_failure_and_returns(self): + """get_enhancements 抛异常时打印失败并返回。""" + from sapcli.commands.enhancement import cmd_enhancement + + client = MagicMock() + client.object_exists.return_value = True + client.get_enhancements.side_effect = Exception("network error") + + with patch("builtins.print") as mock_print: + cmd_enhancement(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_no_enhancements(self): + """无增强实现时打印提示。""" + from sapcli.commands.enhancement import cmd_enhancement + + client = MagicMock() + client.object_exists.return_value = True + client.get_enhancements.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_enhancement(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到增强实现", printed) + + def test_with_enhancements_and_full_elements(self): + """增强实现含完整字段(enhanced_name、elements、mode、replacing)。""" + from sapcli.commands.enhancement import cmd_enhancement + + client = MagicMock() + client.object_exists.return_value = True + client.get_enhancements.return_value = [ + { + "name": "ZENH_TEST", + "type": "ENHO", + "enhanced_name": "ZCL_TEST", + "enhanced_type": "CLAS/OC", + "elements": [ + { + "type": "METHOD", "name": "HELLO", + "mode": "overwrite", "replacing": "ZCL_TEST=>HELLO", + }, + ], + }, + ] + + with patch("builtins.print") as mock_print: + cmd_enhancement(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("ZENH_TEST", printed) + self.assertIn("ENHO", printed) + self.assertIn("Enhanced Object", printed) + self.assertIn("ZCL_TEST", printed) + self.assertIn("METHOD: HELLO", printed) + self.assertIn("Mode: overwrite", printed) + self.assertIn("Replacing: ZCL_TEST=>HELLO", printed) + self.assertIn("Element 1", printed) + + def test_element_label_name_only(self): + """element 只有 name 时 label 为 name。""" + from sapcli.commands.enhancement import cmd_enhancement + + client = MagicMock() + client.object_exists.return_value = True + client.get_enhancements.return_value = [ + { + "name": "ZENH1", "elements": [{"name": "ONLY_NAME"}], + }, + ] + + with patch("builtins.print") as mock_print: + cmd_enhancement(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("ONLY_NAME", printed) + + def test_element_label_type_only(self): + """element 只有 type 时 label 为 type。""" + from sapcli.commands.enhancement import cmd_enhancement + + client = MagicMock() + client.object_exists.return_value = True + client.get_enhancements.return_value = [ + { + "name": "ZENH1", "elements": [{"type": "ONLY_TYPE"}], + }, + ] + + with patch("builtins.print") as mock_print: + cmd_enhancement(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("ONLY_TYPE", printed) + + def test_enhancement_minimal_fields(self): + """增强实现仅含 name(无 type/enhanced_name/elements)。""" + from sapcli.commands.enhancement import cmd_enhancement + + client = MagicMock() + client.object_exists.return_value = True + client.get_enhancements.return_value = [{"name": "ZENH_MIN"}] + + with patch("builtins.print") as mock_print: + cmd_enhancement(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("ZENH_MIN", printed) + # 默认 type 为 ENHO + self.assertIn("ENHO", printed) + # 无 enhanced_name 时不打印 Enhanced Object 行 + self.assertNotIn("Enhanced Object", printed) + + def test_multiple_enhancements_count(self): + """多个增强实现时打印数量。""" + from sapcli.commands.enhancement import cmd_enhancement + + client = MagicMock() + client.object_exists.return_value = True + client.get_enhancements.return_value = [ + {"name": "ZENH1", "elements": []}, + {"name": "ZENH2", "elements": []}, + ] + + with patch("builtins.print") as mock_print: + cmd_enhancement(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("2", printed) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_history.py b/tests/unit/test_history.py new file mode 100644 index 0000000..9a073ea --- /dev/null +++ b/tests/unit/test_history.py @@ -0,0 +1,139 @@ +"""commands/history.py 单元测试 — cmd_history + _format_date。 + +运行: python tests/unit/test_history.py +""" +from __future__ import annotations + +import argparse +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + + +def _args(**kwargs): + defaults = {"type": "class", "name": "ZCL_TEST"} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +# ═══════════════════════════════════════════ +# _format_date +# ═══════════════════════════════════════════ + +class TestFormatDate(unittest.TestCase): + """_format_date 将 ISO 日期转为可读格式。""" + + def test_empty_returns_empty(self): + from sapcli.commands.history import _format_date + self.assertEqual(_format_date(""), "") + + def test_iso_datetime(self): + from sapcli.commands.history import _format_date + self.assertEqual(_format_date("2026-06-15T10:30:00"), "2026-06-15 10:30:00") + + def test_iso_with_z_suffix(self): + from sapcli.commands.history import _format_date + self.assertEqual(_format_date("2026-06-15T10:30:00Z"), "2026-06-15 10:30:00") + + def test_none_value(self): + from sapcli.commands.history import _format_date + self.assertEqual(_format_date(None), "") + + +# ═══════════════════════════════════════════ +# cmd_history +# ═══════════════════════════════════════════ + +class TestCmdHistory(unittest.TestCase): + """cmd_history 查询对象版本历史。""" + + def test_object_not_found_raises(self): + from sapcli.commands.history import cmd_history + from sapcli.exceptions import ObjectNotFoundError + + client = MagicMock() + client.object_exists.return_value = False + + with self.assertRaises(ObjectNotFoundError) as ctx: + cmd_history(_args(), client) + self.assertIn("ZCL_TEST", str(ctx.exception)) + + def test_api_error_prints_failure_and_returns(self): + """get_object_versions 抛异常时打印失败并返回。""" + from sapcli.commands.history import cmd_history + + client = MagicMock() + client.object_exists.return_value = True + client.get_object_versions.side_effect = Exception("server down") + + with patch("builtins.print") as mock_print: + cmd_history(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_no_versions(self): + """无版本历史时打印提示。""" + from sapcli.commands.history import cmd_history + + client = MagicMock() + client.object_exists.return_value = True + client.get_object_versions.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_history(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到版本历史", printed) + + def test_with_versions_prints_table(self): + """有版本时打印版本表格。""" + from sapcli.commands.history import cmd_history + + client = MagicMock() + client.object_exists.return_value = True + client.get_object_versions.return_value = [ + { + "version": "0001", "author": "ALICE", + "date": "2026-06-15T10:30:00", "versionTitle": "initial import", + }, + { + "version": "", "author": "BOB", + "date": "", "versionTitle": "current", + }, + ] + + with patch("builtins.print") as mock_print: + cmd_history(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("0001", printed) + self.assertIn("ALICE", printed) + self.assertIn("initial import", printed) + # version 为空时回退到 "active" + self.assertIn("active", printed) + self.assertIn("2", printed) # 找到 2 个版本 + + def test_versions_missing_version_key(self): + """版本记录缺 version 键时回退到 active。""" + from sapcli.commands.history import cmd_history + + client = MagicMock() + client.object_exists.return_value = True + client.get_object_versions.return_value = [ + {"author": "CAROL", "date": "2026-01-01T00:00:00", "versionTitle": "v1"}, + ] + + with patch("builtins.print") as mock_print: + cmd_history(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("active", printed) + self.assertIn("CAROL", printed) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_modules.py b/tests/unit/test_modules.py new file mode 100644 index 0000000..e0ee61c --- /dev/null +++ b/tests/unit/test_modules.py @@ -0,0 +1,898 @@ +"""散落模块(config / manifest / scanner / sorter / auth)单元测试。 + +运行: python tests/unit/test_modules.py +""" +from __future__ import annotations + +import argparse +import io +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "assets")) + +from sapcli.config import SAPConfig, _find_config_file, list_profiles, load_config +from sapcli.exceptions import CyclicDependencyError, ConfigError +from sapcli.manifest import ( + CURRENT_VERSION, + MANIFEST_FILENAME, + Manifest, + ManifestEntry, + init_manifest, +) +from sapcli.scanner import DIRECTORY_TYPE_MAP, ScannedObject, scan_project +from sapcli.sorter import TYPE_PRIORITY, topological_sort + + +# ═══════════════════════════════════════════════════════════════════ +# config.py +# ═══════════════════════════════════════════════════════════════════ + +class TestSAPConfig(unittest.TestCase): + """SAPConfig dataclass 创建与校验。""" + + def test_create_valid(self): + cfg = SAPConfig(host="myhost", client="100", user="USER1", password="pass") + self.assertEqual(cfg.host, "myhost") + self.assertEqual(cfg.client, "100") + self.assertEqual(cfg.user, "USER1") + self.assertEqual(cfg.password, "pass") + + def test_frozen(self): + cfg = SAPConfig(host="h", client="100", user="u", password="p") + with self.assertRaises(AttributeError): + cfg.host = "other" # type: ignore[misc] + + def test_missing_host_raises(self): + with self.assertRaises(ConfigError): + SAPConfig(host="", client="100", user="u", password="p") + + def test_missing_user_raises(self): + with self.assertRaises(ConfigError): + SAPConfig(host="h", client="100", user="", password="p") + + def test_missing_password_raises(self): + with self.assertRaises(ConfigError): + SAPConfig(host="h", client="100", user="u", password="") + + def test_missing_multiple_raises(self): + with self.assertRaises(ConfigError) as ctx: + SAPConfig(host="", client="100", user="", password="") + msg = str(ctx.exception) + self.assertIn("host", msg) + self.assertIn("user", msg) + + +class TestFindConfigFile(unittest.TestCase): + """_find_config_file 搜索路径逻辑。""" + + def test_returns_none_when_no_file(self): + with tempfile.TemporaryDirectory() as tmp: + with patch("os.getcwd", return_value=tmp): + result = _find_config_file() + if result is not None: + self.assertTrue(os.path.isfile(result)) + + def test_finds_file_in_cwd(self): + with tempfile.TemporaryDirectory() as tmp: + cfg_path = os.path.join(tmp, "config.ini") + with open(cfg_path, "w") as f: + f.write("[SAP]\nhost=example.com\n") + with patch("os.getcwd", return_value=tmp): + result = _find_config_file() + self.assertIsNotNone(result) + self.assertEqual(result, cfg_path) + + +def _write_tmp_ini(content: str) -> str: + """写临时 .ini 文件,返回路径(文件已关闭,可在 Windows 上删除)。""" + fd, path = tempfile.mkstemp(suffix=".ini") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + return path + + +class TestListProfiles(unittest.TestCase): + """list_profiles 从 config.ini 读取 sections。""" + + def test_reads_sections(self): + path = _write_tmp_ini("[SAP]\nhost=h1\n[DEV]\nhost=h2\n[PRD]\nhost=h3\n") + try: + profiles = list_profiles(config_path=path) + self.assertIn("SAP", profiles) + self.assertIn("DEV", profiles) + self.assertIn("PRD", profiles) + self.assertEqual(len(profiles), 3) + finally: + os.unlink(path) + + def test_empty_file_returns_empty(self): + path = _write_tmp_ini("") + try: + profiles = list_profiles(config_path=path) + self.assertEqual(profiles, []) + finally: + os.unlink(path) + + def test_no_config_path_no_file(self): + with patch("sapcli.config._find_config_file", return_value=None): + profiles = list_profiles(config_path=None) + self.assertEqual(profiles, []) + + +class TestLoadConfig(unittest.TestCase): + """load_config 从文件和环境变量读取。""" + + @staticmethod + def _write_config(path: str, section: str = "SAP", **kwargs: str) -> None: + import configparser + cfg = configparser.ConfigParser() + cfg[section] = kwargs + with open(path, "w", encoding="utf-8") as f: + cfg.write(f) + + def _clean_env(self): + return {k: v for k, v in os.environ.items() + if k not in ("SAP_HOST", "SAP_CLIENT", "SAP_USER", "SAP_PASSWORD")} + + def test_load_from_file(self): + fd, path = tempfile.mkstemp(suffix=".ini") + os.close(fd) + try: + self._write_config(path, host="myhost.com", client="200", + user="MYUSER", password="secret") + with patch.dict(os.environ, self._clean_env(), clear=True): + cfg, loaded_from = load_config(config_path=path) + self.assertEqual(cfg.host, "myhost.com") + self.assertEqual(cfg.client, "200") + self.assertEqual(cfg.user, "MYUSER") + self.assertIsNotNone(loaded_from) + finally: + os.unlink(path) + + def test_env_overrides_file(self): + fd, path = tempfile.mkstemp(suffix=".ini") + os.close(fd) + try: + self._write_config(path, host="file-host", client="100", + user="file-user", password="file-pass") + env = self._clean_env() + env["SAP_HOST"] = "env-host" + env["SAP_USER"] = "env-user" + env["SAP_PASSWORD"] = "env-pass" + with patch.dict(os.environ, env, clear=True): + cfg, _ = load_config(config_path=path) + self.assertEqual(cfg.host, "env-host") + self.assertEqual(cfg.user, "env-user") + finally: + os.unlink(path) + + def test_missing_host_raises_config_error(self): + fd, path = tempfile.mkstemp(suffix=".ini") + os.close(fd) + try: + self._write_config(path, client="100", user="u", password="p") + with patch.dict(os.environ, self._clean_env(), clear=True): + with self.assertRaises(ConfigError): + load_config(config_path=path) + finally: + os.unlink(path) + + def test_profile_parameter(self): + fd, path = tempfile.mkstemp(suffix=".ini") + os.close(fd) + try: + self._write_config(path, section="DEV", + host="dev-host", client="400", + user="DEVUSER", password="devpass") + with patch.dict(os.environ, self._clean_env(), clear=True): + cfg, _ = load_config(config_path=path, profile="DEV") + self.assertEqual(cfg.host, "dev-host") + self.assertEqual(cfg.client, "400") + finally: + os.unlink(path) + + +# ═══════════════════════════════════════════════════════════════════ +# manifest.py +# ═══════════════════════════════════════════════════════════════════ + +class TestManifestEntry(unittest.TestCase): + """ManifestEntry 序列化 / 反序列化。""" + + def test_to_dict_defaults(self): + entry = ManifestEntry(name="ZFOO", type="report", file="reports/zfoo.abap") + d = entry.to_dict() + self.assertEqual(d["type"], "report") + self.assertEqual(d["file"], "reports/zfoo.abap") + self.assertEqual(d["system_status"], "not_exists") + self.assertIsNone(d["corr_nr"]) + self.assertEqual(d["depends_on"], []) + self.assertIsNone(d["last_sync"]) + self.assertEqual(d["last_sync_result"], "pending") + + def test_to_dict_full(self): + entry = ManifestEntry( + name="ZBAR", + type="class", + file="classes/zbar.abap", + system_status="active", + corr_nr="NR001", + depends_on=["ZFOO"], + last_sync="2026-01-01T00:00:00", + last_sync_result="success", + ) + d = entry.to_dict() + self.assertEqual(d["system_status"], "active") + self.assertEqual(d["corr_nr"], "NR001") + self.assertEqual(d["depends_on"], ["ZFOO"]) + self.assertEqual(d["last_sync"], "2026-01-01T00:00:00") + self.assertEqual(d["last_sync_result"], "success") + + def test_from_dict_roundtrip(self): + original = ManifestEntry( + name="ZRT", + type="table", + file="tables/zrt.abap", + system_status="inactive", + corr_nr="C002", + depends_on=["ZA", "ZB"], + last_sync="2026-06-01T12:00:00", + last_sync_result="failed", + ) + d = original.to_dict() + restored = ManifestEntry.from_dict("ZRT", d) + self.assertEqual(restored.name, original.name) + self.assertEqual(restored.type, original.type) + self.assertEqual(restored.file, original.file) + self.assertEqual(restored.system_status, original.system_status) + self.assertEqual(restored.corr_nr, original.corr_nr) + self.assertEqual(restored.depends_on, original.depends_on) + self.assertEqual(restored.last_sync, original.last_sync) + self.assertEqual(restored.last_sync_result, original.last_sync_result) + + def test_from_dict_defaults(self): + entry = ManifestEntry.from_dict("ZNEW", {"type": "domain", "file": "domains/znew.abap"}) + self.assertEqual(entry.name, "ZNEW") + self.assertEqual(entry.system_status, "not_exists") + self.assertIsNone(entry.corr_nr) + self.assertEqual(entry.depends_on, []) + self.assertIsNone(entry.last_sync) + self.assertEqual(entry.last_sync_result, "pending") + + +class TestManifest(unittest.TestCase): + """Manifest load/save 往返和 CRUD。""" + + def test_save_and_load_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + manifest = Manifest( + version=CURRENT_VERSION, + last_init="2026-01-01T00:00:00", + last_refresh=None, + objects={ + "ZFOO": ManifestEntry( + name="ZFOO", type="report", file="reports/zfoo.abap", + system_status="active", last_sync_result="success", + ), + "ZBAR": ManifestEntry( + name="ZBAR", type="class", file="classes/zbar.abap", + depends_on=["ZFOO"], + ), + }, + _project_path=tmp, + ) + manifest.save() + + filepath = os.path.join(tmp, MANIFEST_FILENAME) + self.assertTrue(os.path.isfile(filepath)) + + loaded = Manifest.load(tmp) + self.assertEqual(loaded.version, CURRENT_VERSION) + self.assertEqual(len(loaded.objects), 2) + self.assertIn("ZFOO", loaded.objects) + self.assertIn("ZBAR", loaded.objects) + self.assertEqual(loaded.objects["ZFOO"].type, "report") + self.assertEqual(loaded.objects["ZBAR"].depends_on, ["ZFOO"]) + + def test_load_missing_file_raises(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(FileNotFoundError): + Manifest.load(tmp) + + def test_upsert_and_remove(self): + m = Manifest() + entry = ManifestEntry(name="Z1", type="report", file="reports/z1.abap") + m.upsert(entry) + self.assertIn("Z1", m.objects) + self.assertEqual(m.get("Z1").type, "report") + + entry2 = ManifestEntry(name="Z1", type="class", file="classes/z1.abap") + m.upsert(entry2) + self.assertEqual(m.get("Z1").type, "class") + + self.assertTrue(m.remove("Z1")) + self.assertIsNone(m.get("Z1")) + self.assertFalse(m.remove("Z1")) + + def test_pending_objects(self): + m = Manifest() + m.upsert(ManifestEntry(name="A", type="report", file="r/a.abap", + system_status="active", last_sync_result="success")) + m.upsert(ManifestEntry(name="B", type="class", file="c/b.abap", + system_status="not_exists", last_sync_result="pending")) + m.upsert(ManifestEntry(name="C", type="table", file="t/c.abap", + system_status="active", last_sync_result="failed")) + pending = m.pending_objects() + names = [e.name for e in pending] + self.assertIn("B", names) + self.assertIn("C", names) + self.assertNotIn("A", names) + + def test_active_up_to_date_objects(self): + m = Manifest() + m.upsert(ManifestEntry(name="A", type="report", file="r/a.abap", + system_status="active", last_sync_result="success")) + m.upsert(ManifestEntry(name="B", type="class", file="c/b.abap", + system_status="not_exists", last_sync_result="pending")) + active = m.active_up_to_date_objects() + self.assertEqual(len(active), 1) + self.assertEqual(active[0].name, "A") + + def test_file_path(self): + m = Manifest(_project_path="/project") + entry = ManifestEntry(name="Z1", type="report", file="reports/z1.abap") + fp = m.file_path(entry) + expected = os.path.join("/project", "reports", "z1.abap") + self.assertEqual(fp, expected) + + +class TestInitManifest(unittest.TestCase): + """init_manifest 创建空清单。""" + + def test_creates_manifest_with_init_time(self): + m = init_manifest("/tmp/fake") + self.assertEqual(m.version, CURRENT_VERSION) + self.assertIsNotNone(m.last_init) + self.assertEqual(len(m.objects), 0) + self.assertEqual(m._project_path, "/tmp/fake") + + +# ═══════════════════════════════════════════════════════════════════ +# scanner.py +# ═══════════════════════════════════════════════════════════════════ + +class TestScanProject(unittest.TestCase): + """scan_project 目录扫描。""" + + def _make_project(self, tmp: str) -> None: + """创建模拟项目目录结构。""" + # reports/zrpt001.abap + os.makedirs(os.path.join(tmp, "reports")) + with open(os.path.join(tmp, "reports", "zrpt001.abap"), "w") as f: + f.write("REPORT zrpt001.\n") + # reports/.hidden.abap — 应被跳过 + with open(os.path.join(tmp, "reports", ".hidden.abap"), "w") as f: + f.write("REPORT hidden.\n") + # reports/notes.txt — 非 .abap 应被跳过 + with open(os.path.join(tmp, "reports", "notes.txt"), "w") as f: + f.write("notes\n") + + # classes/zcl_demo.abap + os.makedirs(os.path.join(tmp, "classes")) + with open(os.path.join(tmp, "classes", "zcl_demo.abap"), "w") as f: + f.write("CLASS zcl_demo DEFINITION.\n") + + # functions/zgroup/zfunc1.abap + os.makedirs(os.path.join(tmp, "functions", "zgroup")) + with open(os.path.join(tmp, "functions", "zgroup", "zfunc1.abap"), "w") as f: + f.write("FUNCTION zfunc1.\n") + # functions/zgroup/zfunc2.abap + with open(os.path.join(tmp, "functions", "zgroup", "zfunc2.abap"), "w") as f: + f.write("FUNCTION zfunc2.\n") + + # functions/.hidden_grp/func.abap — 隐藏目录应被跳过 + os.makedirs(os.path.join(tmp, "functions", ".hidden_grp")) + with open(os.path.join(tmp, "functions", ".hidden_grp", "func.abap"), "w") as f: + f.write("FUNCTION func.\n") + + # domains/zdom_test.abap + os.makedirs(os.path.join(tmp, "domains")) + with open(os.path.join(tmp, "domains", "zdom_test.abap"), "w") as f: + f.write("DOMAIN zdom_test.\n") + + def test_scan_finds_all_objects(self): + with tempfile.TemporaryDirectory() as tmp: + self._make_project(tmp) + results = scan_project(tmp) + names = {r.name for r in results} + + self.assertIn("ZRPT001", names) + self.assertNotIn("HIDDEN", names) + self.assertIn("ZCL_DEMO", names) + self.assertIn("ZGROUP/ZFUNC1", names) + self.assertIn("ZGROUP/ZFUNC2", names) + self.assertNotIn("HIDDEN_GRP/FUNC", names) + self.assertIn("ZDOM_TEST", names) + + def test_scan_types(self): + with tempfile.TemporaryDirectory() as tmp: + self._make_project(tmp) + results = scan_project(tmp) + type_map = {r.name: r.type for r in results} + + self.assertEqual(type_map.get("ZRPT001"), "report") + self.assertEqual(type_map.get("ZCL_DEMO"), "class") + self.assertEqual(type_map.get("ZGROUP/ZFUNC1"), "function") + self.assertEqual(type_map.get("ZGROUP/ZFUNC2"), "function") + self.assertEqual(type_map.get("ZDOM_TEST"), "domain") + + def test_scan_file_paths(self): + with tempfile.TemporaryDirectory() as tmp: + self._make_project(tmp) + results = scan_project(tmp) + file_map = {r.name: r.file for r in results} + + self.assertEqual(file_map["ZRPT001"], "reports/zrpt001.abap") + self.assertEqual(file_map["ZCL_DEMO"], "classes/zcl_demo.abap") + self.assertEqual(file_map["ZGROUP/ZFUNC1"], "functions/zgroup/zfunc1.abap") + self.assertEqual(file_map["ZDOM_TEST"], "domains/zdom_test.abap") + + def test_scan_empty_directory(self): + with tempfile.TemporaryDirectory() as tmp: + results = scan_project(tmp) + self.assertEqual(results, []) + + def test_scanned_object_dataclass(self): + obj = ScannedObject(name="ZTEST", type="report", file="reports/ztest.abap") + self.assertEqual(obj.name, "ZTEST") + self.assertEqual(obj.type, "report") + self.assertEqual(obj.file, "reports/ztest.abap") + + +# ═══════════════════════════════════════════════════════════════════ +# sorter.py +# ═══════════════════════════════════════════════════════════════════ + +class TestTopologicalSort(unittest.TestCase): + """topological_sort 按类型优先级和依赖排序。""" + + def _entry(self, name: str, obj_type: str, depends_on: list[str] | None = None) -> ManifestEntry: + return ManifestEntry( + name=name, type=obj_type, file=f"{obj_type}s/{name.lower()}.abap", + depends_on=depends_on or [], + ) + + def test_empty_list(self): + self.assertEqual(topological_sort([]), []) + + def test_sorts_by_type_priority(self): + """无依赖时,按 TYPE_PRIORITY 数值从小到大排序。""" + objs = [ + self._entry("ZCL1", "class"), + self._entry("ZDOM1", "domain"), + self._entry("ZRPT1", "report"), + self._entry("ZTAB1", "table"), + ] + result = topological_sort(objs) + names = [o.name for o in result] + self.assertEqual(names, ["ZDOM1", "ZTAB1", "ZCL1", "ZRPT1"]) + + def test_respects_depends_on(self): + """有依赖时,被依赖的对象排在前面。""" + objs = [ + self._entry("ZRPT1", "report", depends_on=["ZCL1"]), + self._entry("ZCL1", "class", depends_on=["ZTAB1"]), + self._entry("ZTAB1", "table"), + ] + result = topological_sort(objs) + names = [o.name for o in result] + idx_tab = names.index("ZTAB1") + idx_cl = names.index("ZCL1") + idx_rpt = names.index("ZRPT1") + self.assertLess(idx_tab, idx_cl) + self.assertLess(idx_cl, idx_rpt) + + def test_cyclic_dependency_raises(self): + """循环依赖应抛出 CyclicDependencyError。""" + objs = [ + self._entry("A", "report", depends_on=["B"]), + self._entry("B", "class", depends_on=["A"]), + ] + with self.assertRaises(CyclicDependencyError): + topological_sort(objs) + + def test_external_dependency_ignored(self): + """depends_on 引用不在列表中的对象时,忽略该依赖。""" + objs = [ + self._entry("ZRPT1", "report", depends_on=["EXTERNAL"]), + ] + result = topological_sort(objs) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "ZRPT1") + + def test_unknown_type_gets_default_priority(self): + """未注册类型应获得默认优先级 (90)。""" + objs = [ + self._entry("ZUNKNOWN", "weird_type"), + self._entry("ZRPT1", "report"), + ] + result = topological_sort(objs) + names = [o.name for o in result] + self.assertEqual(names, ["ZRPT1", "ZUNKNOWN"]) + + def test_diamond_dependency(self): + """菱形依赖: A → B, A → C, B → D, C → D。D 最先。""" + objs = [ + self._entry("A", "report", depends_on=["B", "C"]), + self._entry("B", "class", depends_on=["D"]), + self._entry("C", "function", depends_on=["D"]), + self._entry("D", "domain"), + ] + result = topological_sort(objs) + names = [o.name for o in result] + idx_d = names.index("D") + idx_b = names.index("B") + idx_c = names.index("C") + idx_a = names.index("A") + self.assertLess(idx_d, idx_b) + self.assertLess(idx_d, idx_c) + self.assertLess(idx_b, idx_a) + self.assertLess(idx_c, idx_a) + + +# ═══════════════════════════════════════════════════════════════════ +# auth.py +# ═══════════════════════════════════════════════════════════════════ + +class TestServiceName(unittest.TestCase): + """_service_name 格式。""" + + def test_format(self): + from sapcli.auth import _service_name + self.assertEqual(_service_name("myhost.com", "100"), "sap-cli:myhost.com:100") + + def test_format_empty_client(self): + from sapcli.auth import _service_name + self.assertEqual(_service_name("host", ""), "sap-cli:host:") + + +class _AuthTestBase(unittest.TestCase): + """auth 测试基类,mock keyring 模块。""" + + def _patch_keyring(self): + """将 sapcli.auth / sapcli.password 的 _keyring / _KEYRING_AVAILABLE 替换为 mock。""" + import sapcli.auth as auth_mod + import sapcli.password as pwd_mod + + self.mock_kr = MagicMock() + # mock keyring module with errors attribute + self.mock_kr_mod = MagicMock() + self.mock_kr_mod.errors.PasswordDeleteError = type("PasswordDeleteError", (Exception,), {}) + + # 保存原始值以便恢复 + self._orig_keyring = getattr(auth_mod, "_keyring", None) + self._orig_available = getattr(auth_mod, "_KEYRING_AVAILABLE", None) + self._orig_kr_mod = getattr(auth_mod, "_keyring_mod", None) + self._had_kr_mod = hasattr(auth_mod, "_keyring_mod") + + self._orig_pwd_keyring = getattr(pwd_mod, "_keyring", None) + self._orig_pwd_available = getattr(pwd_mod, "_KEYRING_AVAILABLE", None) + self._orig_pwd_kr_mod = getattr(pwd_mod, "_keyring_mod", None) + self._had_pwd_kr_mod = hasattr(pwd_mod, "_keyring_mod") + + # auth 模块 + auth_mod._keyring = self.mock_kr + auth_mod._KEYRING_AVAILABLE = True + auth_mod._keyring_mod = self.mock_kr_mod + + # password 模块(resolve_password / get_password 实际所在) + pwd_mod._keyring = self.mock_kr + pwd_mod._KEYRING_AVAILABLE = True + pwd_mod._keyring_mod = self.mock_kr_mod + + self.addCleanup(self._restore_keyring) + + def _restore_keyring(self): + import sapcli.auth as auth_mod + import sapcli.password as pwd_mod + auth_mod._keyring = self._orig_keyring + auth_mod._KEYRING_AVAILABLE = self._orig_available + if self._had_kr_mod: + auth_mod._keyring_mod = self._orig_kr_mod + elif hasattr(auth_mod, "_keyring_mod"): + del auth_mod._keyring_mod + + pwd_mod._keyring = self._orig_pwd_keyring + pwd_mod._KEYRING_AVAILABLE = self._orig_pwd_available + if self._had_pwd_kr_mod: + pwd_mod._keyring_mod = self._orig_pwd_kr_mod + elif hasattr(pwd_mod, "_keyring_mod"): + del pwd_mod._keyring_mod + + +class TestGetPassword(_AuthTestBase): + """get_password 从 keyring 读取密码。""" + + def setUp(self): + self._patch_keyring() + + def test_returns_password(self): + from sapcli.auth import get_password + self.mock_kr.get_password.return_value = "secret123" + result = get_password("host", "100", "user1") + self.assertEqual(result, "secret123") + self.mock_kr.get_password.assert_called_once_with("sap-cli:host:100", "user1") + + def test_returns_none_on_exception(self): + from sapcli.auth import get_password + self.mock_kr.get_password.side_effect = Exception("boom") + result = get_password("host", "100", "user1") + self.assertIsNone(result) + + def test_returns_none_when_unavailable(self): + from sapcli.auth import get_password + with patch("sapcli.password._KEYRING_AVAILABLE", False): + result = get_password("host", "100", "user1") + self.assertIsNone(result) + + +class TestSetPassword(_AuthTestBase): + """set_password 存储密码到 keyring。""" + + def setUp(self): + self._patch_keyring() + + def test_success(self): + from sapcli.auth import set_password + result = set_password("host", "100", "user1", "pass") + self.assertTrue(result) + self.mock_kr.set_password.assert_called_once_with("sap-cli:host:100", "user1", "pass") + + def test_failure_returns_false(self): + from sapcli.auth import set_password + self.mock_kr.set_password.side_effect = Exception("fail") + result = set_password("host", "100", "user1", "pass") + self.assertFalse(result) + + def test_unavailable_returns_false(self): + from sapcli.auth import set_password + with patch("sapcli.auth._KEYRING_AVAILABLE", False): + result = set_password("host", "100", "user1", "pass") + self.assertFalse(result) + + +class TestDeletePassword(_AuthTestBase): + """delete_password 从 keyring 删除密码。""" + + def setUp(self): + self._patch_keyring() + + def test_success(self): + from sapcli.auth import delete_password + result = delete_password("host", "100", "user1") + self.assertTrue(result) + self.mock_kr.delete_password.assert_called_once_with("sap-cli:host:100", "user1") + + def test_failure_returns_false(self): + from sapcli.auth import delete_password + self.mock_kr.delete_password.side_effect = Exception("fail") + result = delete_password("host", "100", "user1") + self.assertFalse(result) + + def test_unavailable_returns_false(self): + from sapcli.auth import delete_password + with patch("sapcli.auth._KEYRING_AVAILABLE", False): + result = delete_password("host", "100", "user1") + self.assertFalse(result) + + +class TestResolvePassword(_AuthTestBase): + """resolve_password 按优先级解析密码。""" + + def setUp(self): + self._patch_keyring() + + def test_env_password_takes_priority(self): + from sapcli.auth import resolve_password + self.mock_kr.get_password.return_value = "kr_pass" + result = resolve_password("h", "100", "u", config_password="cfg_pass", env_password="env_pass") + self.assertEqual(result, "env_pass") + + def test_keyring_over_config(self): + from sapcli.auth import resolve_password + self.mock_kr.get_password.return_value = "kr_pass" + result = resolve_password("h", "100", "u", config_password="cfg_pass", env_password="") + self.assertEqual(result, "kr_pass") + + def test_config_as_fallback(self): + from sapcli.auth import resolve_password + self.mock_kr.get_password.return_value = None + result = resolve_password("h", "100", "u", config_password="cfg_pass", env_password="") + self.assertEqual(result, "cfg_pass") + + def test_all_empty(self): + from sapcli.auth import resolve_password + self.mock_kr.get_password.return_value = None + result = resolve_password("h", "100", "u", config_password="", env_password="") + self.assertEqual(result, "") + + +class TestCmdAuthStatus(unittest.TestCase): + """cmd_auth_status 输出。""" + + def test_prints_keyring_available(self): + from sapcli.auth import cmd_auth_status + mock_backend = MagicMock() + mock_backend.__class__ = type("TestBackend", (), {}) + with patch("sapcli.auth._KEYRING_AVAILABLE", True), \ + patch("sapcli.auth._keyring") as mock_kr: + mock_kr.get_keyring.return_value = mock_backend + with patch("sys.stdout", new_callable=io.StringIO) as out: + cmd_auth_status(argparse.Namespace()) + output = out.getvalue() + self.assertIn("keyring", output) + self.assertIn("可用", output) + + def test_prints_keyring_unavailable(self): + from sapcli.auth import cmd_auth_status + with patch("sapcli.auth._KEYRING_AVAILABLE", False): + with patch("sys.stdout", new_callable=io.StringIO) as out: + cmd_auth_status(argparse.Namespace()) + output = out.getvalue() + self.assertIn("keyring", output) + self.assertIn("不可用", output) + + +class TestCmdAuthLogin(unittest.TestCase): + """cmd_auth_login 交互式登录。""" + + def test_login_stores_password(self): + from sapcli.auth import cmd_auth_login + + fake_cfg = SAPConfig(host="h", client="100", user="u", password="p") + args = argparse.Namespace(config=None) + + with patch("sapcli.config.load_config", return_value=(fake_cfg, "/fake")), \ + patch("sapcli.auth.getpass") as mock_gp_mod, \ + patch("sapcli.auth.set_password", return_value=True) as mock_sp, \ + patch("sys.stdout", new_callable=io.StringIO): + + mock_gp_mod.getpass.return_value = "mypassword" + cmd_auth_login(args) + + mock_sp.assert_called_once_with("h", "100", "u", "mypassword") + + def test_login_empty_password_aborts(self): + from sapcli.auth import cmd_auth_login + + fake_cfg = SAPConfig(host="h", client="100", user="u", password="p") + + with patch("sapcli.config.load_config", return_value=(fake_cfg, "/fake")), \ + patch("sapcli.auth.getpass.getpass", return_value=""), \ + patch("sapcli.auth.set_password") as mock_sp, \ + patch("sys.stdout", new_callable=io.StringIO) as out: + + cmd_auth_login(argparse.Namespace(config=None)) + # 不应调用 set_password + mock_sp.assert_not_called() + self.assertIn("不能为空", out.getvalue()) + + +class TestCmdAuthLogout(unittest.TestCase): + """cmd_auth_logout 删除密码。""" + + def test_logout_deletes_password(self): + from sapcli.auth import cmd_auth_logout + + fake_cfg = SAPConfig(host="h", client="100", user="u", password="p") + + with patch("sapcli.config.load_config", return_value=(fake_cfg, "/fake")), \ + patch("sapcli.auth.delete_password", return_value=True) as mock_dp, \ + patch("sys.stdout", new_callable=io.StringIO) as out: + + cmd_auth_logout(argparse.Namespace(config=None)) + + mock_dp.assert_called_once_with("h", "100", "u") + self.assertIn("删除", out.getvalue()) + +class TestScanProjectThreeLayer(unittest.TestCase): + """scan_project 三层布局(src/<开发包>/<对象类型>/)+ 单复数目录名。""" + + def _make_project(self, tmp: str) -> None: + """src/TMP/<类型>/ 三层布局。""" + for sub in ( + "src/TMP/report", "src/TMP/class", "src/TMP/interface", + "src/TMP/domain", "src/TMP/structure", "src/TMP/tabletype", + ): + os.makedirs(os.path.join(tmp, *sub.split("/"))) + + def put(rel: str, body: str) -> None: + with open(os.path.join(tmp, *rel.split("/")), "w") as f: + f.write(body) + + put("src/TMP/report/zrpt001.abap", "REPORT zrpt001.\n") + put("src/TMP/report/notes.txt", "skip me\n") # 非 .abap 跳过 + put("src/TMP/class/zcl_demo.abap", "CLASS zcl_demo DEFINITION.\n") + put("src/TMP/interface/zif_demo.abap", "INTERFACE zif_demo.\n") + put("src/TMP/domain/zdom_demo.abap", "DOMAIN zdom_demo.\n") + put("src/TMP/structure/zst_demo.abap", "STRUCTURE zst_demo.\n") + put("src/TMP/tabletype/ztt_demo.abap", "TABLETYPE ztt_demo.\n") + + def _make_funcs(self, tmp: str) -> None: + """函数模块两种命名。""" + os.makedirs(os.path.join(tmp, "src", "TMP", "function", "zfg_one")) + with open(os.path.join(tmp, "src", "TMP", "function", "zfg_one", "zfm_a.abap"), "w") as f: + f.write("FUNCTION zfm_a.\n") + with open(os.path.join(tmp, "src", "TMP", "function", "zfg_two.fugr.zfm_b.abap"), "w") as f: + f.write("FUNCTION zfm_b.\n") + + def test_three_layer_finds_objects(self): + with tempfile.TemporaryDirectory() as tmp: + self._make_project(tmp) + results = scan_project(tmp) + by_name = {r.name: r for r in results} + + self.assertIn("ZRPT001", by_name) + self.assertNotIn("NOTES", by_name) + self.assertIn("ZCL_DEMO", by_name) + self.assertIn("ZIF_DEMO", by_name) + self.assertIn("ZDOM_DEMO", by_name) + self.assertIn("ZST_DEMO", by_name) + self.assertIn("ZTT_DEMO", by_name) + + # 类型识别正确(按目录名,而非文件名) + self.assertEqual(by_name["ZCL_DEMO"].type, "class") + self.assertEqual(by_name["ZST_DEMO"].type, "structure") + self.assertEqual(by_name["ZTT_DEMO"].type, "tabletype") + # 相对项目根路径保留三层 + self.assertEqual(by_name["ZCL_DEMO"].file, "src/TMP/class/zcl_demo.abap") + + def test_function_two_naming_styles(self): + with tempfile.TemporaryDirectory() as tmp: + self._make_funcs(tmp) + names = {r.name for r in scan_project(tmp)} + self.assertIn("ZFG_ONE/ZFM_A", names) # 子目录式 + self.assertIn("ZFG_TWO/ZFM_B", names) # ADT 式 .fugr. + + def test_plural_and_case_insensitive(self): + """classes/ 与 class/ 等价,目录名大小写不敏感。""" + with tempfile.TemporaryDirectory() as tmp: + os.makedirs(os.path.join(tmp, "src", "TMP", "CLASSES")) + with open(os.path.join(tmp, "src", "TMP", "CLASSES", "zcl_x.abap"), "w") as f: + f.write("CLASS zcl_x DEFINITION.\n") + res = scan_project(tmp) + self.assertEqual([(r.name, r.type) for r in res], [("ZCL_X", "class")]) + + def test_backward_compatible_flat_layout(self): + """旧的扁平布局仍可扫描。""" + with tempfile.TemporaryDirectory() as tmp: + os.makedirs(os.path.join(tmp, "reports")) + with open(os.path.join(tmp, "reports", "zold.abap"), "w") as f: + f.write("REPORT zold.\n") + os.makedirs(os.path.join(tmp, "classes")) + with open(os.path.join(tmp, "classes", "zcl_old.abap"), "w") as f: + f.write("CLASS zcl_old DEFINITION.\n") + names = {r.name for r in scan_project(tmp)} + self.assertIn("ZOLD", names) + self.assertIn("ZCL_OLD", names) + + def test_no_duplicate_when_both_layouts(self): + """同一对象在两处出现只保留一次。""" + with tempfile.TemporaryDirectory() as tmp: + os.makedirs(os.path.join(tmp, "src", "TMP", "report")) + os.makedirs(os.path.join(tmp, "reports")) + for d in ("src/TMP/report", "reports"): + with open(os.path.join(tmp, *d.split("/"), "zdup.abap"), "w") as f: + f.write("REPORT zdup.\n") + res = scan_project(tmp) + self.assertEqual(len([r for r in res if r.name == "ZDUP"]), 1) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/unit/test_program_run.py b/tests/unit/test_program_run.py new file mode 100644 index 0000000..69b934e --- /dev/null +++ b/tests/unit/test_program_run.py @@ -0,0 +1,105 @@ +"""commands/program_run.py 单元测试 — cmd_run_program。 + +运行: python tests/unit/test_program_run.py +""" +from __future__ import annotations + +import argparse +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + + +def _args(**kwargs): + defaults = {"name": "zprogram"} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +class TestCmdRunProgram(unittest.TestCase): + """cmd_run_program 远程执行 ABAP 程序。""" + + def test_output_present_is_printed(self): + """程序返回非空输出时打印输出内容。""" + from sapcli.commands.program_run import cmd_run_program + + client = MagicMock() + client.run_program.return_value = " Hello ABAP \n" + + with patch("builtins.print") as mock_print: + cmd_run_program(_args(name="zsetup"), client) + + # 程序名应大写后传给 client + client.run_program.assert_called_once_with("ZSETUP") + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("Hello ABAP", printed) + + def test_output_trailing_newlines_rstripped(self): + """输出尾随空白被 rstrip 去掉。""" + from sapcli.commands.program_run import cmd_run_program + + client = MagicMock() + client.run_program.return_value = "line1\nline2\n\n\n" + + with patch("builtins.print") as mock_print: + cmd_run_program(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("line2", printed) + + def test_empty_output_prints_no_output(self): + """程序返回纯空白输出时打印「无输出」。""" + from sapcli.commands.program_run import cmd_run_program + + client = MagicMock() + client.run_program.return_value = " \n " + + with patch("builtins.print") as mock_print: + cmd_run_program(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无输出", printed) + + def test_truly_empty_string_prints_no_output(self): + """程序返回空字符串时打印「无输出」。""" + from sapcli.commands.program_run import cmd_run_program + + client = MagicMock() + client.run_program.return_value = "" + + with patch("builtins.print") as mock_print: + cmd_run_program(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无输出", printed) + + def test_name_uppercased_before_call(self): + """小写程序名被 .upper() 后传给 client。""" + from sapcli.commands.program_run import cmd_run_program + + client = MagicMock() + client.run_program.return_value = "" + + cmd_run_program(_args(name="ztest_prog"), client) + + client.run_program.assert_called_once_with("ZTEST_PROG") + + def test_info_message_printed(self): + """执行前打印「远程执行程序」提示。""" + from sapcli.commands.program_run import cmd_run_program + + client = MagicMock() + client.run_program.return_value = "ok" + + with patch("builtins.print") as mock_print: + cmd_run_program(_args(name="zprog"), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("ZPROG", printed) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_repo_guards.py b/tests/unit/test_repo_guards.py new file mode 100644 index 0000000..514bcaa --- /dev/null +++ b/tests/unit/test_repo_guards.py @@ -0,0 +1,217 @@ +"""仓库不变量守卫(唯一源)。 + +本仓 sap-cli-skill 自 2026-09-11 起是 sap-cli 工具与技能文档的**唯一来源** +(原 sap-cli 源码仓已归档)。本测试守住几条「静默失效」类的仓库不变量: + +- `SKILL.md` 必须记录 parser 的**全部** CLI 命令(文档与代码不得脱节) +- 开发铁律 1-5 必须是真实小节标题(不能只靠正文交叉引用) +- 示例不得出现违反铁律 5 的 `--path ./src` 写法 +- `references/` 三份规则齐备且含关键规则 +- `VERSION` 与 `sapcli.__version__` 一致 + +历史背景:文档与代码曾双向脱节(SKILL.md 只写 9 个命令而 parser 有 31 个), +铁律 5 的目录规范也曾只写在文档里、工具却不支持。守卫把这类问题挡在提交前。 +""" +from __future__ import annotations + +import argparse +import os +import re +import sys +import unittest +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +ASSETS = PROJECT_ROOT / "assets" +sys.path.insert(0, str(ASSETS)) + +SKILL_MD = PROJECT_ROOT / "SKILL.md" +REFERENCES = PROJECT_ROOT / "references" +VERSION_FILE = PROJECT_ROOT / "VERSION" + +# 开发铁律必须在 SKILL.md 里 +IRON_RULE_MARKERS = ["⛔ 开发铁律", "铁律 1", "铁律 2", "铁律 3", "铁律 4", "铁律 5"] + +# 每份规则文件必须含的关键规则标记 +RULE_CONTENT_MARKERS: dict[str, list[str]] = { + "sap-tool-constraints.md": ["规则 7", "规则 8"], + "abap-coding-rules.md": ["E04", "E05"], + "error-handling.md": [], +} + + +class TestSkillDocStaysInSync(unittest.TestCase): + """SKILL.md 是唯一源文档,必须与真实 CLI 保持一致。""" + + def _text(self) -> str: + return SKILL_MD.read_text(encoding="utf-8") + + def test_skill_md_exists(self): + self.assertTrue(SKILL_MD.is_file(), f"缺少 SKILL.md: {SKILL_MD}") + + def test_every_cli_command_is_documented(self): + """parser 里的每个命令都必须在 SKILL.md 里有记录。""" + from sapcli.cli.parser import build_parser + + parser = build_parser() + subs = [a for a in parser._actions if isinstance(a, argparse._SubParsersAction)] + self.assertTrue(subs, "解析器未找到子命令") + commands = sorted(subs[0].choices.keys()) + + text = self._text() + undocumented = [ + cmd for cmd in commands + if not re.search(r"`" + re.escape(cmd) + r"(?=[ `/-])", text) + ] + self.assertFalse( + undocumented, + f"这些 CLI 命令在 SKILL.md 里没有记录(文档与代码脱节): {undocumented}", + ) + + def test_iron_rules_are_real_headings(self): + """铁律 1-5 必须是真实小节标题,不能只靠别处的交叉引用蒙过标记检查。 + + 实测缺口:把「### 铁律 5」改成「### 铁律五」时,纯字符串标记检查仍 + 通过——因为正文多处出现「见铁律 5」。标题级断言才拦得住。 + """ + text = self._text() + missing = [ + n for n in range(1, 6) + if not re.search(rf"^#{{2,4}}\s*铁律\s*{n}\b", text, re.MULTILINE) + ] + self.assertFalse(missing, f"SKILL.md 缺少这些铁律的小节标题: {missing}") + + def test_iron_rule_5_documents_tmp_layer(self): + text = self._text() + self.assertIn("铁律 5", text) + self.assertIn("src/TMP", text, "铁律 5 必须写明本地开发包层固定为 TMP") + + def test_no_path_violating_iron_rule_5(self): + """示例不得出现 `--path ./src`(铁律 5 要求指到对象类型目录)。 + + 仅放行「规则说明本身」的行——它们用反例讲铁律 5,会同时出现 + 「不合格」或「平铺」字样。 + """ + violations = [] + for line in self._text().splitlines(): + if re.search(r"--path\s+\./src(?!/TMP)", line): + if "不合格" not in line and "平铺" not in line: + violations.append(line.strip()) + self.assertFalse( + violations, + "SKILL.md 示例违反铁律 5(--path 必须指到 src/TMP/<对象类型>):\n " + + "\n ".join(violations), + ) + + +class TestReferencesAreComplete(unittest.TestCase): + """references/ 是规则的正本,必须齐备。""" + + def test_reference_files_exist(self): + for name in RULE_CONTENT_MARKERS: + self.assertTrue( + (REFERENCES / name).is_file(), + f"缺少规则文件: references/{name}", + ) + + def test_rule_contents_keep_key_rules(self): + for name, markers in RULE_CONTENT_MARKERS.items(): + path = REFERENCES / name + self.assertTrue(path.is_file(), f"缺少规则文件: {path}") + text = path.read_text(encoding="utf-8") + for marker in markers: + self.assertIn(marker, text, f"{name} 缺少关键规则「{marker}」") + + +class TestRepoLayout(unittest.TestCase): + """唯一源仓库的关键结构必须存在(归档旧仓后这里就是唯一的家)。""" + + def test_ci_workflow_present(self): + ci = PROJECT_ROOT / ".github" / "workflows" / "ci.yml" + self.assertTrue(ci.is_file(), f"缺少 CI 工作流: {ci}") + + def test_ci_targets_assets_layout(self): + """CI 必须按 assets/ 布局安装与统计覆盖率(曾因 pyproject 在 assets/ 下踩坑)。""" + text = (PROJECT_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + self.assertIn("./assets[dev]", text, "CI 未按 assets/ 布局安装依赖") + self.assertIn("assets/sapcli/*", text, "CI 覆盖率统计路径未指向 assets/sapcli") + + def test_no_obsolete_pack_pipeline(self): + """打包流程已废弃——存在即说明有回退或残留。""" + self.assertFalse((PROJECT_ROOT / "scripts" / "pack_skill.py").exists(), + "pack_skill.py 已废弃,不应出现在唯一源仓库") + self.assertFalse((PROJECT_ROOT / "skill-src").exists(), + "skill-src/ 已废弃(模板与 SKILL.md 会形成双源)") + + +class TestRuleCopiesInSync(unittest.TestCase): + """references/ 是规则正本,.claude/rules/ 是供 Claude Code 读的副本。 + + 两处内容必须一致——同一份规则存两份迟早漂移(本仓已因双份来源吃过亏)。 + 改规则时两处都要改,只改一处这里会红。 + """ + + def test_claude_rules_match_references(self): + claude_rules = PROJECT_ROOT / ".claude" / "rules" + if not claude_rules.is_dir(): + self.skipTest("本机无 .claude/rules/(可选目录)") + + shared = sorted(p.name for p in REFERENCES.glob("*.md")) + self.assertTrue(shared, "references/ 下没有规则文件") + + for name in shared: + rc = claude_rules / name + if not rc.exists(): + continue # 只比对两边都有的文件 + self.assertEqual( + rc.read_text(encoding="utf-8"), + (REFERENCES / name).read_text(encoding="utf-8"), + f"规则副本漂移: .claude/rules/{name} 与 references/{name} 内容不一致", + ) + + def test_no_extra_claude_rule(self): + """references/ 有的规则,.claude/rules/ 不应缺失(否则 Claude Code 读到旧规则集)。""" + claude_rules = PROJECT_ROOT / ".claude" / "rules" + if not claude_rules.is_dir(): + self.skipTest("本机无 .claude/rules/") + missing = [ + p.name for p in REFERENCES.glob("*.md") + if not (claude_rules / p.name).exists() + ] + self.assertFalse(missing, f".claude/rules/ 缺少这些规则副本: {missing}") + + +class TestVersionConsistency(unittest.TestCase): + """VERSION 文件与代码里的版本号必须一致(曾出现 2.3.0 vs 2.5.1 漂移)。""" + + def test_version_file_matches_package(self): + import sapcli + + file_version = VERSION_FILE.read_text(encoding="utf-8").strip() + self.assertEqual( + file_version, sapcli.__version__, + f"VERSION({file_version}) 与 sapcli.__version__({sapcli.__version__}) 不一致", + ) + + def test_version_file_is_parseable(self): + v = VERSION_FILE.read_text(encoding="utf-8").strip() + self.assertRegex(v, r"^\d+\.\d+(\.\d+)?$", f"VERSION 格式异常: {v!r}") + + def test_readme_version_matches(self): + """README 里写的版本号也要一致(README 是纯文本,最容易忘改)。""" + import sapcli + + text = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") + m = re.search(r"当前版本\*\*:\s*(\S+)", text) + self.assertIsNotNone(m, "README 找不到「当前版本」行") + self.assertEqual(m.group(1), sapcli.__version__, + f"README 版本({m.group(1)}) 与包版本({sapcli.__version__}) 不一致") + + def test_pyproject_version_is_dynamic_from_package(self): + """pyproject 的版本必须动态取自 sapcli.__version__,避免两处手改。""" + text = (ASSETS / "pyproject.toml").read_text(encoding="utf-8") + self.assertIn('version = {attr = "sapcli.__version__"}', text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_unit_test.py b/tests/unit/test_unit_test.py new file mode 100644 index 0000000..0db9e92 --- /dev/null +++ b/tests/unit/test_unit_test.py @@ -0,0 +1,206 @@ +"""commands/unit_test.py 单元测试 — cmd_unit_test + _parse_duration。 + +运行: python tests/unit/test_unit_test.py +""" +from __future__ import annotations + +import argparse +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + + +def _args(**kwargs): + defaults = {"type": "class", "name": "ZCL_TEST"} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +# ═══════════════════════════════════════════ +# _parse_duration +# ═══════════════════════════════════════════ + +class TestParseDuration(unittest.TestCase): + """_parse_duration 安全解析方法耗时。""" + + def test_valid_float_string(self): + from sapcli.commands.unit_test import _parse_duration + self.assertEqual(_parse_duration("1.5"), 1.5) + self.assertEqual(_parse_duration("0.25"), 0.25) + self.assertEqual(_parse_duration("3"), 3.0) + + def test_invalid_string_returns_zero(self): + from sapcli.commands.unit_test import _parse_duration + self.assertEqual(_parse_duration("abc"), 0.0) + + def test_none_returns_zero(self): + from sapcli.commands.unit_test import _parse_duration + self.assertEqual(_parse_duration(None), 0.0) + + def test_empty_string_returns_zero(self): + from sapcli.commands.unit_test import _parse_duration + self.assertEqual(_parse_duration(""), 0.0) + + def test_zero_string(self): + from sapcli.commands.unit_test import _parse_duration + self.assertEqual(_parse_duration("0"), 0.0) + + +# ═══════════════════════════════════════════ +# cmd_unit_test +# ═══════════════════════════════════════════ + +class TestCmdUnitTest(unittest.TestCase): + """cmd_unit_test 执行 ABAP Unit 测试并汇总结果。""" + + def test_api_error_prints_failure_and_returns(self): + """run_unit_test 抛异常时打印失败提示并返回(不抛)。""" + from sapcli.commands.unit_test import cmd_unit_test + + client = MagicMock() + client.run_unit_test.side_effect = Exception("connection refused") + + with patch("builtins.print") as mock_print: + cmd_unit_test(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_no_test_classes(self): + """无测试类时打印提示。""" + from sapcli.commands.unit_test import cmd_unit_test + + client = MagicMock() + client.run_unit_test.return_value = {"summary": {}, "classes": []} + + with patch("builtins.print") as mock_print: + cmd_unit_test(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到测试类", printed) + + def test_passed_class(self): + """全部通过时打印 PASSED 和方法数。""" + from sapcli.commands.unit_test import cmd_unit_test + + client = MagicMock() + client.run_unit_test.return_value = { + "summary": { + "tests": "2", "failures": "0", "errors": "0", "skipped": "0", + }, + "classes": [ + { + "name": "ZCL_TEST", + "methods": [ + {"name": "test1", "duration": "1.5"}, + {"name": "test2", "duration": "0.5"}, + ], + }, + ], + } + + with patch("builtins.print") as mock_print: + cmd_unit_test(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("PASSED", printed) + self.assertIn("2 methods", printed) + # 合计行 + self.assertIn("合计", printed) + + def test_failed_class_with_alert_and_line(self): + """方法失败时打印 FAILED、方法名、行号、告警内容。""" + from sapcli.commands.unit_test import cmd_unit_test + + client = MagicMock() + client.run_unit_test.return_value = { + "summary": { + "tests": "1", "failures": "1", "errors": "0", "skipped": "0", + }, + "classes": [ + { + "name": "ZCL_TEST", + "methods": [ + { + "name": "test_fail", "duration": "0.1", + "alert": "Assertion failed", "line": "42", + }, + ], + }, + ], + } + + with patch("builtins.print") as mock_print: + cmd_unit_test(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("FAILED", printed) + self.assertIn("test_fail", printed) + self.assertIn("42", printed) + self.assertIn("Assertion failed", printed) + self.assertIn("at line 42", printed) + + def test_failed_without_line_omits_line_info(self): + """失败方法无 line 时不打印 at line。""" + from sapcli.commands.unit_test import cmd_unit_test + + client = MagicMock() + client.run_unit_test.return_value = { + "summary": {}, + "classes": [ + { + "name": "ZCL_TEST", + "methods": [{"name": "test_x", "alert": "boom"}], + }, + ], + } + + with patch("builtins.print") as mock_print: + cmd_unit_test(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("FAILED", printed) + self.assertIn("boom", printed) + self.assertNotIn("at line", printed) + + def test_class_without_name_falls_back_to_display(self): + """class name 为空时回退到 parsed.display_name。""" + from sapcli.commands.unit_test import cmd_unit_test + + client = MagicMock() + client.run_unit_test.return_value = { + "summary": {}, + "classes": [ + {"name": None, "methods": [{"name": "test1"}]}, + ], + } + + with patch("builtins.print") as mock_print: + cmd_unit_test(_args(name="ZCL_FOO"), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("ZCL_FOO", printed) + + def test_summary_defaults_when_missing(self): + """summary 缺字段时使用默认值(? / 0)。""" + from sapcli.commands.unit_test import cmd_unit_test + + client = MagicMock() + client.run_unit_test.return_value = { + "summary": {}, + "classes": [{"name": "ZCL", "methods": [{"name": "t1"}]}], + } + + with patch("builtins.print") as mock_print: + cmd_unit_test(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + # tests 默认 "?" + self.assertIn("?", printed) + + +if __name__ == "__main__": + unittest.main(verbosity=2)