commit 1e39b6da8847c8d632ecedf2a459d72a90fa933b Author: MarkWuRY168 Date: Sat Jun 13 20:52:24 2026 +0800 feat: sap-cli skill v2.1.0 — self-contained distributable package - assets/: sap-cli source (v2.1.0, 26 commands, 16 object types) - references/: tool constraints + error handling (self-contained) - scripts/setup.py: one-click install/config/verify - SKILL.md: full command reference + dual-platform install guide - VERSION: 2.1.0 Built from D:/Codespace/sap-cli via scripts/pack_skill.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85450c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +assets/**/__pycache__/ +assets/log/ +assets/config.ini +*.pyc diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..eb96056 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,296 @@ +--- +name: sap-cli +description: "Use when you need to operate SAP ABAP development objects — syncing code, querying metadata, managing transports, reading tables, or any task involving SAP ADT REST API. Trigger on: ABAP objects, SAP development, transport requests, DDIC types, SE11/SE16N/SE38/SE80 equivalents, 'sync to SAP', 'download from SAP', 'activate ABAP', 'read SAP table'. NOT for developing sap-cli itself." +version: "2.1.0" +author: WuRangyu +license: MIT +--- + +# sap-cli — SAP ABAP 开发对象管理工具 + +sap-cli 是一个 Python CLI,封装 SAP ADT REST API,让你在终端完成 ABAP 开发对象的完整生命周期管理。 + +核心工作流:**download → 本地编辑 → sync 回 SAP** + +--- + +## 首次使用:安装与配置 + +### 第 1 步:安装 sap-cli + +如果 `python main.py --help` 无法执行,先运行安装脚本: + +```bash +python /scripts/setup.py install +``` + +此脚本会: +1. 安装依赖(`requests`) +2. 将 sap-cli 安装为可编辑的 Python 包 +3. 创建配置文件模板 + +安装完成后验证: +```bash +python main.py --help +``` + +### 第 2 步:配置 SAP 连接 + +编辑配置文件 `assets/config.ini`(从 `config.ini.example` 复制): + +```ini +[SAP] +host = http://your-sap-server:8000 +client = 100 +user = your_username +password = your_password +``` + +也可通过环境变量配置(优先级高于配置文件): +- `SAP_HOST` / `SAP_CLIENT` / `SAP_USER` / `SAP_PASSWORD` + +### 第 3 步:验证连接 + +```bash +python main.py config show +python main.py transport list +``` + +--- + +## 调用方式 + +```bash +cd /assets && python main.py [options] +``` + +全局参数(在命令之前): + +| 参数 | 说明 | +|------|------| +| `--profile ` | 使用指定配置 profile | +| `--verify-ssl` | 启用 SSL 验证(默认关闭) | + +--- + +## 命令速查 + +### CRUD(对象生命周期) + +```bash +# 创建新对象 +python main.py create --name ZMY_CLASS --type class --corr_nr DEVK901XXX +python main.py create --name ZMY_DOMAIN --type domain --definition domain.json --corr_nr DEVK901XXX + +# 查询元数据 +python main.py info --name ZMY_CLASS --type class + +# 下载源码 +python main.py download --name ZMY_CLASS --type class --path ./src + +# 同步到 SAP(lock → write → unlock → syntax check → activate) +python main.py sync --name ZMY_CLASS --type class --path ./src/zmy_class.abap --corr_nr DEVK901XXX + +# 删除对象 +python main.py delete --name ZMY_CLASS --type class + +# 单独激活(sap-cli 内置 NW 7.40 double-activate) +python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX +``` + +**create 关键参数**: + +| 参数 | 说明 | +|------|------| +| `--description` | 对象描述 | +| `--source <.abap>` | 自定义源码文件 | +| `--definition <.json>` | DDIC 定义文件(domain/dataelement/table/structure/tabletype) | +| `--package ` | SAP 包(默认 `$TMP`) | +| `--corr_nr` | 传输请求号 | + +### 批量操作 + +```bash +# 初始化项目结构 +python main.py init --path ./my_project + +# 刷新对象状态 +python main.py refresh --path ./my_project + +# 批量同步(自动按依赖拓扑排序) +python main.py sync --all --path ./my_project --corr_nr DEVK901XXX +python main.py sync --all --path ./my_project --corr_nr DEVK901XXX --dry-run +``` + +依赖排序:`domain(10) → dataelement(20) → table(30) → tabletype(40) → interface(50) → class(60) → function(70) → report(80)` + +### 数据查询 + +```bash +# 表结构(SE11 等价) +python main.py show-table --name ZMY_TABLE + +# 表数据(SE16N 等价) +python main.py read-table --name ZMY_TABLE +python main.py read-table --name ZMY_TABLE --fields "FIELD1,FIELD2" --where "FIELD1 = 'X'" --max-rows 50 + +# 列出对象 +python main.py list --type class --package ZSAPILOT + +# Where-Used +python main.py whereused --name ZMY_CLASS --type class + +# 源码搜索 +python main.py search --query "CALL FUNCTION 'Z_MY_FUNC'" +``` + +### 远程执行 + +```bash +# 执行 ABAP 程序(SA38 等价) +python main.py run-program --name ZSAPILOT_SETUP + +# 本地 vs SAP 差异 +python main.py diff --name ZMY_CLASS --type class --path ./src/zmy_class.abap +``` + +### 传输请求 + +```bash +python main.py transport list +python main.py transport info --corr_nr DEVK901XXX +python main.py transport release --corr_nr DEVK901XXX +python main.py transport objects --corr_nr DEVK901XXX +``` + +### 配置与认证 + +```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 # 密码保存到 keyring +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 create --name ZSAPILOT # 创建包 +python main.py cds download --name ZMY_CDS --path ./src # CDS View +python main.py analyze --path ./src # 依赖分析 +python main.py scaffold --name ZMY_RPT --template alv-report # 项目模板 +# 模板: alv-report, bapi-wrapper, interface-class, data-model +``` + +--- + +## 支持的对象类型(16 种) + +| 分类 | 类型 | 有源码 | +|------|------|--------| +| 程序 | `report`, `include` | ✅ | +| OOP | `class`, `interface` | ✅ | +| 函数 | `function` | ✅ | +| DDIC 基础 | `domain`, `dataelement`, `table`, `structure` | ✅ | +| DDIC 扩展 | `cdsview`, `view` | ✅ | +| DDIC 无源码 | `tabletype`, `messageclass`, `searchhelp`, `lockobject` | ❌ | +| 程序组 | `functiongroup` | ❌ | + +**函数命名格式**:必须使用 `组名/模块名` 格式,如 `ZMY_FGROUP/Z_MY_FUNC` + +--- + +## 常用工作流 + +### 1. 单对象编辑同步 + +```bash +python main.py download --name ZMY_CLASS --type class --path ./src +# 用编辑器修改 ./src/zmy_class.abap +python main.py sync --name ZMY_CLASS --type class --path ./src/zmy_class.abap --corr_nr DEVK901XXX +``` + +### 2. 批量项目同步 + +```bash +python main.py init --path ./my_project +# 添加对象到 src/ 目录 +python main.py sync --all --path ./my_project --corr_nr DEVK901XXX +``` + +### 3. DDIC 对象创建 + +```bash +# 准备 JSON 定义文件 +python main.py create --name ZMY_DOMAIN --type domain --definition domain.json --corr_nr DEVK901XXX +``` + +### 4. 数据查询 + +```bash +python main.py show-table --name ZMY_TABLE # 结构 +python main.py read-table --name ZMY_TABLE --max-rows 100 # 数据 +``` + +### 5. 远程执行 ABAP 程序 + +```bash +python main.py run-program --name ZSAPILOT_SETUP +``` + +--- + +## ⚠️ 使用约束(必须遵守) + +> 详细规则见 `references/sap-tool-constraints.md` 和 `references/error-handling.md` + +1. **只使用 `python main.py <命令>` 操作 SAP** — 禁止用 requests、curl 直接调 ADT/SOAP +2. **禁止修改 SAP 标准对象**(`CL_*`、`SAPL*` 等)— 只允许只读操作 +3. **禁止未授权操作系统表**(TADIR、E071、SEOCLASS 等)— SELECT 诊断可以 +4. **sync 必须带 `--corr_nr`** — 否则触发交互提示 +5. **NW 7.40 DDIC Lock 返回 HTTP 406** — 系统限制,报告用户去 SE09 + +--- + +## 常见问题速查 + +| 问题 | 原因 | 解决 | +|------|------|------| +| HTTP 406(DDIC Lock) | NW 7.40 不支持 DDIC ADT Lock | 报告用户 → SE09 | +| HTTP 403(Locked) | 残留 enqueue lock | 先试 `run-program` 清锁,不行 → SM12 | +| HTTP 400(SaveFailure) | 类 DEFINITION 与 SAP 不一致 | 对比本地 vs SAP 源码 | +| HTTP 423(Transport lock) | 对象绑定在传输请求中 | 报告用户 → SE09 | +| 激活失败 | NW 7.40 ADT 限制 | sap-cli 内置 double-activate,仍失败 → SE09 | +| `WITH EMPTY KEY` dump | NW 7.40 不支持 | 改用 `WITH NON-UNIQUE KEY` | +| 函数名格式错误 | 缺少 `/` 分隔符 | 使用 `ZGROUP/Z_FUNC` 格式 | + +## NW 7.40 兼容性 + +- **DDIC Lock**:HTTP 406,无法通过 ADT 锁定 DDIC 对象 +- **激活误报**:首次激活可能报失败但实际成功,sap-cli 已内置 double-activate +- **ABAP 语法限制**:不支持字符串模板 `|...|`、inline 声明 `DATA(...)`、`WITH EMPTY KEY` +- **CSRF Token**:所有写操作必须携带,sap-cli 自动处理 + +--- + +## 分发与安装(团队成员) + +### 方式 1:Hermes Agent +```bash +git clone ~/AppData/Local/hermes/skills/productivity/sap-cli +python ~/AppData/Local/hermes/skills/productivity/sap-cli/scripts/setup.py all +``` + +### 方式 2:Claude Code +```bash +git clone ~/.claude/skills/sap-cli +python ~/.claude/skills/sap-cli/scripts/setup.py all +``` + +安装后验证: +```bash +cd /assets && python main.py --help +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..7ec1d6d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.1.0 diff --git a/assets/config.ini.example b/assets/config.ini.example new file mode 100644 index 0000000..3f13ac6 --- /dev/null +++ b/assets/config.ini.example @@ -0,0 +1,18 @@ +[SAP] +host = http://your-sap-server:8000 +client = 100 +user = your_username +password = your_password + +; 多系统配置示例: +; [DEV] +; host = http://dev-sap-server:8000 +; client = 100 +; user = dev_user +; password = dev_password +; +; [QAS] +; host = http://qas-sap-server:8000 +; client = 200 +; user = qas_user +; password = qas_password diff --git a/assets/main.py b/assets/main.py new file mode 100644 index 0000000..243fbaf --- /dev/null +++ b/assets/main.py @@ -0,0 +1,16 @@ +""" +sap-cli — 基于 SAP ADT REST API 的 ABAP 开发对象管理工具 + +用法: + python main.py download --name ZMY_PROGRAM --type report --path ./output + python main.py sync --name ZMY_PROGRAM --type report --path ./zmy_program.abap + python main.py delete --name ZMY_PROGRAM --type report + +配置: + 默认读取脚本同目录下的 config.ini,也可通过 --config 指定。 + 环境变量 SAP_HOST / SAP_CLIENT / SAP_USER / SAP_PASSWORD 可覆盖配置文件。 +""" +from sapcli.cli.app import main + +if __name__ == "__main__": + main() diff --git a/assets/pyproject.toml b/assets/pyproject.toml new file mode 100644 index 0000000..75890fd --- /dev/null +++ b/assets/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +name = "sap-cli" +dynamic = ["version"] +description = "基于 SAP ADT REST API 的 ABAP 开发对象管理工具" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.10" +authors = [ + {name = "WuRangyu"}, +] +keywords = ["sap", "abap", "adt", "cli"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Code Generators", +] +dependencies = [ + "requests>=2.28.0", +] + +[project.urls] +Homepage = "https://gitee.com/markwury168/sap-cli" +Repository = "https://gitee.com/markwury168/sap-cli" +Issues = "https://gitee.com/markwury168/sap-cli/issues" + +[project.optional-dependencies] +keyring = ["keyring>=23.0"] +dev = ["pytest>=7.0", "keyring>=23.0"] + +[project.scripts] +sap-cli = "sapcli.cli.app:main" + +[tool.setuptools.packages.find] +include = ["sapcli*"] + +[tool.setuptools.package-data] +sapcli = ["py.typed"] + +[tool.setuptools.dynamic] +version = {attr = "sapcli.__version__"} diff --git a/assets/requirements.txt b/assets/requirements.txt new file mode 100644 index 0000000..4c9b5fe --- /dev/null +++ b/assets/requirements.txt @@ -0,0 +1,9 @@ +# sap-cli 依赖 +# 本文件供不使用 pyproject.toml 的场景(如直接 pip install -r requirements.txt)使用。 +# 推荐使用 pip install -e . 或 pip install -e ".[dev]" 安装项目。 +# +# 核心 +requests>=2.28.0 + +# 可选:安全密码存储 +# keyring>=23.0 diff --git a/assets/sapcli/__init__.py b/assets/sapcli/__init__.py new file mode 100644 index 0000000..c82081f --- /dev/null +++ b/assets/sapcli/__init__.py @@ -0,0 +1,5 @@ +""" +sap-cli — 基于 SAP ADT REST API 的 ABAP 开发对象管理工具 +""" + +__version__ = "2.1.0" diff --git a/assets/sapcli/__main__.py b/assets/sapcli/__main__.py new file mode 100644 index 0000000..aae2cd0 --- /dev/null +++ b/assets/sapcli/__main__.py @@ -0,0 +1,5 @@ +"""sapcli 包入口,支持 python -m sapcli 运行。""" +from sapcli.cli.app import main + +if __name__ == "__main__": + main() diff --git a/assets/sapcli/auth.py b/assets/sapcli/auth.py new file mode 100644 index 0000000..b8bca59 --- /dev/null +++ b/assets/sapcli/auth.py @@ -0,0 +1,124 @@ +"""凭证管理模块。 + +使用 keyring 库安全存储 SAP 密码,优雅降级到 config.ini。 +服务名格式: sap-cli:{host}:{client} +""" +from __future__ import annotations + +import argparse +import getpass +import logging + +# 从独立模块导入,避免与 config 循环 import +from sapcli.password import ( # noqa: F401 – re-export + _KEYRING_AVAILABLE, + _keyring, + _keyring_mod, + _service_name, + get_password, + resolve_password, +) + +logger = logging.getLogger("sapcli.auth") + + +def set_password(host: str, client: str, user: str, password: str) -> bool: + """存储密码到 keyring。 + + Returns: + 是否成功存储 + """ + if not _KEYRING_AVAILABLE: + print(" ✗ keyring 库不可用,无法安全存储密码") + print(" 安装方法: pip install keyring") + return False + try: + _keyring.set_password(_service_name(host, client), user, password) + return True + except Exception as e: + print(f" ✗ 存储密码失败: {e}") + return False + + +def delete_password(host: str, client: str, user: str) -> bool: + """从 keyring 删除密码。 + + Returns: + 是否成功删除 + """ + if not _KEYRING_AVAILABLE: + print(" ✗ keyring 库不可用") + return False + try: + _keyring.delete_password(_service_name(host, client), user) + return True + except _keyring_mod.errors.PasswordDeleteError: + print(" ℹ keyring 中未找到该密码") + return False + except Exception as e: + print(f" ✗ 删除密码失败: {e}") + return False + + +def cmd_auth_login(args: argparse.Namespace, client=None) -> None: + """auth login: 交互式保存密码到 keyring。""" + import os + from sapcli.config import load_config + + try: + cfg, _ = load_config(getattr(args, "config", None)) + except Exception as e: + print(f" ✗ 无法加载配置: {e}") + return + + print("=" * 60) + print(" sap-cli 密钥登录") + print("=" * 60) + print(f" 主机: {cfg.host}") + print(f" Client: {cfg.client}") + print(f" 用户: {cfg.user}") + + password = getpass.getpass(" 请输入密码: ") + if not password: + print(" ✗ 密码不能为空") + return + + if set_password(cfg.host, cfg.client, cfg.user, password): + print(f" ✓ 密码已安全存储到 keyring") + print(f" 服务名: {_service_name(cfg.host, cfg.client)}") + else: + print(" ✗ 密码存储失败") + + +def cmd_auth_logout(args: argparse.Namespace, client=None) -> None: + """auth logout: 从 keyring 删除密码。""" + from sapcli.config import load_config + + try: + cfg, _ = load_config(getattr(args, "config", None)) + except Exception as e: + print(f" ✗ 无法加载配置: {e}") + return + + if delete_password(cfg.host, cfg.client, cfg.user): + print(" ✓ 密码已从 keyring 删除") + else: + print(" ✗ 删除失败") + + +def cmd_auth_status(args: argparse.Namespace, client=None) -> None: + """auth status: 显示 keyring 状态。""" + print("=" * 60) + print(" sap-cli 密钥状态") + print("=" * 60) + + if _KEYRING_AVAILABLE: + print(f" keyring: ✓ 可用") + try: + backend = _keyring.get_keyring().__class__.__name__ + print(f" 后端: {backend}") + except Exception: + logger.debug("获取 keyring 后端名称失败", exc_info=True) + else: + print(f" keyring: ✗ 不可用") + print(f" 安装方法: pip install keyring") diff --git a/assets/sapcli/cli/__init__.py b/assets/sapcli/cli/__init__.py new file mode 100644 index 0000000..1304bdb --- /dev/null +++ b/assets/sapcli/cli/__init__.py @@ -0,0 +1 @@ +"""sapcli 命令行解析与输出模块。""" diff --git a/assets/sapcli/cli/app.py b/assets/sapcli/cli/app.py new file mode 100644 index 0000000..3b3ad11 --- /dev/null +++ b/assets/sapcli/cli/app.py @@ -0,0 +1,167 @@ +"""sap-cli 应用入口:解析参数、初始化连接、路由命令。""" +from __future__ import annotations + +import io +import logging +import os +import sys + +import requests + +from sapcli.config import load_config +from sapcli.client import ADTClient +from sapcli.commands import ( + cmd_create, + cmd_delete, + cmd_download, + cmd_info, + cmd_init, + cmd_refresh, + cmd_sync, + 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, + cmd_show_table, + cmd_read_table, + cmd_run_program, + cmd_activate, +) +from sapcli.auth import cmd_auth_login, cmd_auth_logout, cmd_auth_status +from sapcli.exceptions import SapCliError, ConfigError +from sapcli.cli.parser import build_parser + + +def _setup_encoding() -> None: + """确保 stdout/stderr 使用 UTF-8 编码。""" + if hasattr(sys.stdout, "buffer"): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + if hasattr(sys.stderr, "buffer"): + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + + +def _setup_logging() -> None: + """配置日志(写入文件,不影响 stdout)。""" + log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "log") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "adt_tools.log") + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s | %(levelname)-7s | %(message)s", + handlers=[ + logging.FileHandler(log_file, encoding="utf-8", mode="a"), + ], + ) + + +def _suppress_ssl_warnings() -> None: + """禁用 urllib3 的 InsecureRequestWarning。""" + requests.packages.urllib3.disable_warnings( + requests.packages.urllib3.exceptions.InsecureRequestWarning + ) + + +def main() -> None: + """sap-cli 主入口。""" + _setup_encoding() + _setup_logging() + _suppress_ssl_warnings() + + parser = build_parser() + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(1) + + # config 命令不需要 SAP 连接 + if args.command == "config": + cmd_config(args) + return + + # auth 不需要 SAP 连接 + if args.command == "auth": + auth_action = getattr(args, "auth_action", None) + if auth_action == "login": + cmd_auth_login(args) + elif auth_action == "logout": + cmd_auth_logout(args) + else: + cmd_auth_status(args) + return + + # 加载配置 + try: + sap_cfg, loaded_from = load_config(args.config, profile=getattr(args, "profile", None)) + if loaded_from: + logging.getLogger("sapcli").info("Config loaded from: %s", loaded_from) + except ConfigError as e: + print(f" ✗ {e}") + print(f" 请创建配置文件 (config.ini) 或设置环境变量:") + print(f" SAP_HOST, SAP_CLIENT, SAP_USER, SAP_PASSWORD") + sys.exit(1) + + # 建立 SAP 连接 + verify_ssl = getattr(args, "verify_ssl", False) + client = ADTClient(sap_cfg.host, sap_cfg.client, sap_cfg.user, sap_cfg.password, verify_ssl=verify_ssl) + + print("\n → 正在登录...") + client.login() + print(" ✓ 登录成功\n") + + # 命令路由表 + command_map = { + "download": cmd_download, + "sync": cmd_sync_all if getattr(args, "all", False) else cmd_sync, + "info": cmd_info, + "delete": cmd_delete, + "create": cmd_create, + "init": cmd_init, + "refresh": cmd_refresh, + "list": cmd_list, + "whereused": cmd_whereused, + "search": cmd_search, + "transport": cmd_transport, + "check": cmd_check, + "format": cmd_format, + "activate": cmd_activate, + "diff": cmd_diff, + "package": cmd_package, + "cds": cmd_cds, + "analyze": cmd_analyze, + "scaffold": cmd_scaffold, + "show-table": cmd_show_table, + "read-table": cmd_read_table, + "run-program": cmd_run_program, + } + + # 批量 sync 参数校验 + if args.command == "sync" and getattr(args, "all", False): + if args.name and args.type: + print(" ✗ --all 模式下不需要 --name 和 --type 参数") + sys.exit(1) + elif args.command == "sync" and not getattr(args, "all", False): + if not args.name or not args.type: + print(" ✗ 单对象模式需要 --name 和 --type 参数") + sys.exit(1) + + # 执行命令 + handler = command_map.get(args.command) + if handler is None: + print(f" ✗ 未知命令: {args.command}") + sys.exit(1) + + try: + handler(args, client) + except SapCliError as e: + print(f"\n ✗ {e}") + sys.exit(1) diff --git a/assets/sapcli/cli/output.py b/assets/sapcli/cli/output.py new file mode 100644 index 0000000..cda45fc --- /dev/null +++ b/assets/sapcli/cli/output.py @@ -0,0 +1,50 @@ +"""格式化输出工具函数。""" +from __future__ import annotations + + +def print_separator(char: str = "=", width: int = 60) -> None: + """打印分隔线。""" + print(char * width) + + +def print_header(title: str) -> None: + """打印带标题的分隔头部。""" + print_separator() + print(f" {title}") + print_separator() + + +def print_source_preview(source: str, max_lines: int = 20) -> None: + """打印源代码预览。""" + lines = source.splitlines() + print(f" ┌─── 源代码 (前 {max_lines} 行) ──────────────────────") + for i, line in enumerate(lines[:max_lines], 1): + print(f" │ {i:4d} | {line}") + if len(lines) > max_lines: + print(f" │ ... 省略剩余 {len(lines) - max_lines} 行 ...") + print(f" └────────────────────────────────────────────") + + +def print_success(msg: str) -> None: + """打印成功信息。""" + print(f" ✓ {msg}") + + +def print_error(msg: str) -> None: + """打印错误信息。""" + print(f" ✗ {msg}") + + +def print_info(msg: str) -> None: + """打印信息提示。""" + print(f" ℹ {msg}") + + +def print_warning(msg: str) -> None: + """打印警告信息。""" + print(f" ⚠ {msg}") + + +def print_step(msg: str) -> None: + """打印步骤信息。""" + print(f" → {msg}") diff --git a/assets/sapcli/cli/parser.py b/assets/sapcli/cli/parser.py new file mode 100644 index 0000000..598b4aa --- /dev/null +++ b/assets/sapcli/cli/parser.py @@ -0,0 +1,251 @@ +"""argparse 命令行参数定义。""" +from __future__ import annotations + +import argparse + +from sapcli.types import all_type_keys + + +def build_parser() -> argparse.ArgumentParser: + """构建并返回主命令行解析器。""" + parser = argparse.ArgumentParser( + description="sap-cli — SAP ADT 源代码下载/同步激活/删除", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python main.py download --name ZMY_REPORT --type report --path ./output + python main.py sync --name ZMY_REPORT --type report --path ./zmy_report.abap + python main.py delete --name ZMY_REPORT --type report + python main.py info --name ZCL_MY_CLASS --type class + python main.py create --name ZMY_REPORT --type report --description "我的报表" + python main.py create --name ZMY_REPORT --type report --corr_nr DEVK901362 + python main.py --config ./my_config.ini download --name ZMY_REPORT --type report --path ./output + +批量同步: + python main.py init --path ./my_project + python main.py sync --all --path ./my_project + python main.py sync --all --path ./my_project --dry-run + python main.py refresh --path ./my_project + +搜索与浏览: + python main.py list --type report --prefix Z* + python main.py whereused --name ZMY_REPORT --type report + python main.py search --query "CALL FUNCTION" + +传输管理: + 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 + +代码质量: + python main.py check --name ZMY_REPORT --type report + python main.py format --name ZMY_REPORT --type report + +代码差异: + python main.py diff --name ZMY_REPORT --type report --path ./zmy_report.abap + +包管理: + python main.py package create --name ZMY_PACKAGE --description "我的包" + python main.py package info --name ZMY_PACKAGE + +CDS View: + python main.py cds download --name ZMY_CDS_VIEW --path ./output + python main.py cds create --name ZMY_CDS_VIEW --description "我的CDS视图" + +依赖分析: + python main.py analyze --path ./my_project + +项目模板: + python main.py scaffold --name ZMY_REPORT --template alv-report + +配置: + 配置文件默认读取脚本同目录下的 config.ini。 + 环境变量 SAP_HOST / SAP_CLIENT / SAP_USER / SAP_PASSWORD 可覆盖配置文件。 + """, + ) + parser.add_argument("--config", default=None, help="配置文件路径 (默认: 脚本同目录/config.ini)") + parser.add_argument( + "--profile", "-p", default=None, + help="配置 profile 名称 (config.ini 中的 section 名, 默认: SAP)", + ) + parser.add_argument( + "--verify-ssl", action="store_true", default=False, + help="启用 SSL 证书验证 (默认: 关闭,适用于 SAP 自签名证书环境)", + ) + + subparsers = parser.add_subparsers(dest="command", help="操作命令") + type_choices = all_type_keys() + + # ── download ── + dl_parser = subparsers.add_parser("download", help="下载源代码到本地文件") + dl_parser.add_argument("--name", required=True, help="对象名称 (function 类型用'组名/模块名')") + dl_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") + dl_parser.add_argument("--path", required=True, help="保存目录路径") + + # ── sync ── + sync_parser = subparsers.add_parser("sync", help="同步源代码到 SAP 并激活") + sync_parser.add_argument("--name", default=None, help="对象名称 (--all 模式不需要)") + sync_parser.add_argument("--type", default=None, choices=type_choices, help="对象类型 (--all 模式不需要)") + sync_parser.add_argument("--path", required=True, help="本地源代码文件路径 (.abap) 或项目根目录 (--all 模式)") + sync_parser.add_argument("--corr_nr", default=None, help="直接指定传输请求号 (跳过交互选择)") + sync_parser.add_argument("--all", action="store_true", dest="all", help="批量模式:同步项目清单中的所有对象") + sync_parser.add_argument("--dry-run", action="store_true", help="仅输出执行计划,不实际操作 (批量模式)") + sync_parser.add_argument("--fail-fast", action="store_true", help="遇到失败立即停止 (批量模式)") + + # ── delete ── + del_parser = subparsers.add_parser("delete", help="从 SAP 系统删除对象") + del_parser.add_argument("--name", required=True, help="对象名称 (function 类型用'组名/模块名')") + del_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") + del_parser.add_argument("--path", default=None, help="项目根目录 (可选,用于更新清单)") + + # ── info ── + info_parser = subparsers.add_parser("info", help="查询对象元数据信息") + info_parser.add_argument("--name", required=True, help="对象名称 (function 类型用'组名/模块名')") + info_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") + + # ── create ── + create_parser = subparsers.add_parser("create", help="在 SAP 系统创建开发对象") + create_parser.add_argument("--name", required=True, help="对象名称 (function 类型用'组名/模块名')") + create_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") + create_parser.add_argument("--description", default=None, help="对象描述 (默认: 使用对象名称)") + create_parser.add_argument("--source", default=None, help="源代码文件路径 (.abap),不指定则使用默认模板") + create_parser.add_argument("--definition", default=None, help="DDIC 定义文件路径 (.json),用于 domain/dataelement/table/structure/tabletype") + create_parser.add_argument("--corr_nr", default=None, help="直接指定传输请求号 (跳过交互选择)") + create_parser.add_argument("--package", default="$TMP", help="SAP 包名 (默认: $TMP)") + create_parser.add_argument("--path", default=None, help="项目根目录 (可选,用于写入清单)") + + # ── init ── + init_parser = subparsers.add_parser("init", help="初始化项目清单") + init_parser.add_argument("--path", required=True, help="项目根目录") + + # ── refresh ── + refresh_parser = subparsers.add_parser("refresh", help="刷新清单中对象的 SAP 状态") + refresh_parser.add_argument("--path", required=True, help="项目根目录") + + # ── config ── + config_parser = subparsers.add_parser("config", help="配置管理") + config_sub = config_parser.add_subparsers(dest="config_action", help="配置操作") + config_sub.add_parser("show", help="显示当前配置") + config_sub.add_parser("list-profiles", help="列出所有 profile") + config_set_parser = config_sub.add_parser("set", help="设置配置项") + config_set_parser.add_argument("key", help="配置项名称 (host/client/user/password)") + config_set_parser.add_argument("value", help="配置值") + + # ── list (对象列表浏览) ── + list_parser = subparsers.add_parser("list", help="列出 SAP 对象") + list_parser.add_argument("--type", default=None, choices=type_choices, help="对象类型过滤") + list_parser.add_argument("--package", default=None, help="包名过滤") + list_parser.add_argument("--prefix", default=None, help="对象名前缀 (支持通配符 *)") + + # ── whereused (Where-Used 引用查询) ── + wu_parser = subparsers.add_parser("whereused", help="Where-Used 引用查询") + wu_parser.add_argument("--name", required=True, help="对象名称") + wu_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") + + # ── search (源代码搜索) ── + search_parser = subparsers.add_parser("search", help="源代码搜索") + search_parser.add_argument("--query", required=True, help="搜索关键词") + search_parser.add_argument("--type", default=None, choices=type_choices, help="限定对象类型") + + # ── transport (传输请求管理) ── + transport_parser = subparsers.add_parser("transport", help="传输请求管理") + transport_sub = transport_parser.add_subparsers(dest="transport_action", help="传输操作") + transport_sub.add_parser("list", help="列出可修改的传输请求") + tr_info = transport_sub.add_parser("info", help="查看传输请求详情") + tr_info.add_argument("--corr_nr", required=True, help="传输请求编号") + tr_release = transport_sub.add_parser("release", help="释放传输请求") + tr_release.add_argument("--corr_nr", required=True, help="传输请求编号") + tr_objects = transport_sub.add_parser("objects", help="列出传输请求中的对象") + tr_objects.add_argument("--corr_nr", required=True, help="传输请求编号") + + # ── check (ATC 代码检查) ── + check_parser = subparsers.add_parser("check", help="ATC 代码检查") + check_parser.add_argument("--name", required=True, help="对象名称") + check_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") + check_parser.add_argument("--variant", default=None, help="ATC 检查变体 (可选)") + + # ── format (代码格式化) ── + fmt_parser = subparsers.add_parser("format", help="代码格式化 (ABAP Pretty Printer)") + fmt_parser.add_argument("--name", required=True, help="对象名称") + fmt_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") + + # ── activate (单独激活) ── + act_parser = subparsers.add_parser("activate", help="单独激活 SAP 对象(无需重新 sync)") + act_parser.add_argument("--name", default=None, help="对象名称(单对象模式)") + act_parser.add_argument("--type", default=None, choices=type_choices, help="对象类型(单对象模式)") + act_parser.add_argument("--names", default=None, help="逗号分隔的多个对象名称(批量模式)") + act_parser.add_argument("--types", default=None, help="逗号分隔的多个对象类型(批量模式,与 --names 一一对应)") + act_parser.add_argument("--corr_nr", default=None, help="传输请求编号") + + # ── diff (代码差异对比) ── + diff_parser = subparsers.add_parser("diff", help="本地 vs SAP 代码差异对比") + diff_parser.add_argument("--name", required=True, help="对象名称") + diff_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") + diff_parser.add_argument("--path", default=None, help="本地文件路径 (.abap)") + + # ── package (包管理) ── + package_parser = subparsers.add_parser("package", help="ABAP 包操作") + package_sub = package_parser.add_subparsers(dest="package_action", help="包操作") + pkg_create = package_sub.add_parser("create", help="创建 ABAP 包") + pkg_create.add_argument("--name", required=True, help="包名") + pkg_create.add_argument("--description", default=None, help="包描述") + pkg_create.add_argument("--superpackage", default=None, help="上级包名") + pkg_info = package_sub.add_parser("info", help="查看包详情") + pkg_info.add_argument("--name", required=True, help="包名") + pkg_list = package_sub.add_parser("list", help="列出包中的对象") + pkg_list.add_argument("--name", required=True, help="包名") + + # ── cds (CDS View 操作) ── + cds_parser = subparsers.add_parser("cds", help="CDS View 操作") + cds_sub = cds_parser.add_subparsers(dest="cds_action", help="CDS 操作") + cds_dl = cds_sub.add_parser("download", help="下载 CDS View DDL 源码") + cds_dl.add_argument("--name", required=True, help="CDS View 名称") + cds_dl.add_argument("--path", default=".", help="保存目录路径") + cds_sync = cds_sub.add_parser("sync", help="同步本地 DDL 到 SAP") + cds_sync.add_argument("--name", required=True, help="CDS View 名称") + cds_sync.add_argument("--path", required=True, help="本地 DDL 文件路径") + cds_create = cds_sub.add_parser("create", help="创建新的 CDS View") + cds_create.add_argument("--name", required=True, help="CDS View 名称") + cds_create.add_argument("--description", default=None, help="CDS View 描述") + cds_create.add_argument("--ddl_path", default=None, help="DDL 文件路径 (可选)") + cds_create.add_argument("--path", default=".", help="保存目录路径") + + # ── analyze (依赖分析) ── + analyze_parser = subparsers.add_parser("analyze", help="依赖自动分析") + analyze_parser.add_argument("--path", required=True, help="项目目录路径") + + # ── scaffold (项目模板) ── + scaffold_parser = subparsers.add_parser("scaffold", help="项目模板创建") + scaffold_parser.add_argument("--name", required=True, help="对象名称") + scaffold_parser.add_argument( + "--template", required=False, + choices=["alv-report", "bapi-wrapper", "interface-class", "data-model"], + help="模板类型 (不指定则列出可用模板)", + ) + scaffold_parser.add_argument("--package", default="$TMP", help="SAP 包名 (默认: $TMP)") + scaffold_parser.add_argument("--path", default=".", help="输出目录") + + # ── auth (密钥管理) ── + auth_parser = subparsers.add_parser("auth", help="密钥管理 (keyring)") + auth_sub = auth_parser.add_subparsers(dest="auth_action", help="密钥操作") + auth_sub.add_parser("login", help="保存密码到 keyring") + auth_sub.add_parser("logout", help="从 keyring 删除密码") + auth_sub.add_parser("status", help="显示 keyring 状态") + + # ── show-table ── + p_show_table = subparsers.add_parser("show-table", help="查看 DDIC 表字段结构 (SE11)") + p_show_table.add_argument("--name", required=True, help="表名") + + # ── read-table ── + p_read_table = subparsers.add_parser("read-table", help="查询表数据 (SE16N)") + p_read_table.add_argument("--name", required=True, help="表名") + p_read_table.add_argument("--fields", help="查询字段列表,逗号分隔 (默认: *)") + p_read_table.add_argument("--where", help="WHERE 条件 (如 \"status = 'error'\")") + p_read_table.add_argument("--max-rows", type=int, default=200, help="最大行数 (默认: 200)") + + # ── run-program ── + p_run = subparsers.add_parser("run-program", help="远程执行 ABAP 程序 (SA38)") + p_run.add_argument("--name", required=True, help="程序名") + + return parser diff --git a/assets/sapcli/client.py b/assets/sapcli/client.py new file mode 100644 index 0000000..17c4234 --- /dev/null +++ b/assets/sapcli/client.py @@ -0,0 +1,11 @@ +"""Backward-compatible re-export shim. + +``sapcli/client.py`` → ``sapcli/client/__init__.py`` + +All public names are preserved so existing ``from sapcli.client import ADTClient`` +imports continue to work unchanged. +""" + +from sapcli.client import ADTClient # noqa: F401 – re-export + +__all__ = ["ADTClient"] diff --git a/assets/sapcli/client/__init__.py b/assets/sapcli/client/__init__.py new file mode 100644 index 0000000..ab34a49 --- /dev/null +++ b/assets/sapcli/client/__init__.py @@ -0,0 +1,29 @@ +"""sapcli.client — ADT client split into Mixin modules. + +Usage:: + + from sapcli.client import ADTClient + +The public API is identical to the original monolithic ``client.py``. +""" + +from sapcli.client._base import ADTClientBase +from sapcli.client._source import SourceMixin +from sapcli.client._transport import TransportMixin +from sapcli.client._search import SearchMixin +from sapcli.client._ddic import DdicMixin + + +class ADTClient( + ADTClientBase, + SourceMixin, + TransportMixin, + SearchMixin, + DdicMixin, +): + """Full ADT client composed from domain-specific Mixins.""" + + pass + + +__all__ = ["ADTClient"] diff --git a/assets/sapcli/client/_base.py b/assets/sapcli/client/_base.py new file mode 100644 index 0000000..7d502df --- /dev/null +++ b/assets/sapcli/client/_base.py @@ -0,0 +1,88 @@ +"""ADTClient base — connection, authentication, and generic request helpers.""" + +from __future__ import annotations + +import logging + +import requests +from requests.auth import HTTPBasicAuth + +from sapcli.exceptions import LoginError + +logger = logging.getLogger("sapcli.client") + + +class ADTClientBase: + """Connection / authentication foundation shared by all mixins.""" + + def __init__( + self, + host: str, + client: str, + user: str, + password: str, + verify_ssl: bool = False, + ) -> None: + self.host: str = host.rstrip("/") + self.csrf_token: str = "fetch" + self.session: requests.Session = requests.Session() + self.session.auth = HTTPBasicAuth(user, password) + self.session.verify = verify_ssl + self._stateful: bool = False + self.sap_client: str = client + + # ------------------------------------------------------------------ + # Context-manager protocol + # ------------------------------------------------------------------ + + def __enter__(self): # type: ignore[override] + self.login() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.session.close() + + # ------------------------------------------------------------------ + # Headers / auth helpers + # ------------------------------------------------------------------ + + def _headers(self, content_type: str = "application/xml") -> dict[str, str]: + return { + "Accept": "*/*", + "Cache-Control": "no-cache", + "x-csrf-token": self.csrf_token, + "X-sap-adt-sessiontype": "stateful" if self._stateful else "stateless", + "content-type": content_type, + "sap-client": self.sap_client, + } + + def login(self) -> bool: + url = f"{self.host}/sap/bc/adt/compatibility/graph" + hdrs = self._headers() + logger.info("LOGIN: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("LOGIN RESPONSE: HTTP %s", resp.status_code) + if resp.status_code == 200: + self.csrf_token = resp.headers.get("x-csrf-token", "") + if self.csrf_token: + logger.info( + "CSRF Token: %s...(%d chars)", + self.csrf_token[:8], + len(self.csrf_token), + ) + return True + raise LoginError(f"登录失败: HTTP {resp.status_code}") + + def object_exists(self, obj_uri: str) -> bool: + url = f"{self.host}{obj_uri}" + hdrs = self._headers() + hdrs["Accept"] = "*/*" + logger.info("CHECK EXISTS: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("CHECK EXISTS RESPONSE: HTTP %s", resp.status_code) + if resp.status_code == 200: + return True + if resp.status_code == 404: + return False + logger.warning("Unexpected status %d for existence check", resp.status_code) + return resp.status_code < 400 diff --git a/assets/sapcli/client/_ddic.py b/assets/sapcli/client/_ddic.py new file mode 100644 index 0000000..719322c --- /dev/null +++ b/assets/sapcli/client/_ddic.py @@ -0,0 +1,756 @@ +"""DdicMixin — object CRUD, DDIC helpers, CDS, packages, ATC, pretty-print.""" + +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET +from typing import Any + +from sapcli.exceptions import CreateError, DeleteError +from sapcli.types import ObjectTypeConfig, get_type_config + +logger = logging.getLogger("sapcli.client") + + +class DdicMixin: + """Object creation / deletion, DDIC operations, CDS, packages, ATC, pretty-print.""" + + # ------------------------------------------------------------------ + # Object create / delete + # ------------------------------------------------------------------ + + def delete_object( + self, + obj_uri: str, + corr_nr: str | None = None, + ) -> tuple[bool, str]: + lock_handle, _ = self.lock(obj_uri, corr_nr) + url = f"{self.host}{obj_uri}" + params: dict[str, str] = {"lockHandle": lock_handle} + if corr_nr: + params["corrNr"] = corr_nr + hdrs = self._headers() + logger.info("DELETE: DELETE %s", url) + resp = self.session.delete(url, headers=hdrs, params=params) + logger.info("DELETE RESPONSE: HTTP %s", resp.status_code) + if resp.status_code in (200, 204): + logger.info("删除成功") + return True, "" + error_text = resp.text[:500] if resp.text else f"HTTP {resp.status_code}" + logger.error("删除失败: %s", error_text) + raise DeleteError(f"删除失败: {error_text}") + + def create_object( + self, + obj_type: str, + name: str, + description: str | None = None, + corr_nr: str | None = None, + source: str | None = None, + package: str = "$TMP", + ) -> tuple[str, str | None]: + config = get_type_config(obj_type) + params: dict[str, str] = {} + if corr_nr: + params["corrNr"] = corr_nr + + if obj_type == "function": + if "/" not in name: + raise CreateError("function 类型需要'函数组名/函数模块名' 格式") + group, fm = name.split("/", 1) + collection_url = config.format_collection_uri(group=group.lower()) + obj_name = fm.upper() + obj_uri = config.format_obj_uri(fm.lower(), group=group.lower()) + src_uri = config.format_src_uri(fm.lower(), group=group.lower()) + else: + collection_url = config.collection_uri + obj_name = name.upper() + obj_uri = config.format_obj_uri(name.lower()) + src_uri = config.format_src_uri(name.lower()) + + body = self._build_create_body(obj_type, obj_name, description, package) + url = f"{self.host}{collection_url}" + hdrs = self._headers(config.create_content_type) + logger.info("CREATE: POST %s name=%s", url, obj_name) + resp = self.session.post( + url, headers=hdrs, params=params, data=body.encode("utf-8") + ) + logger.info("CREATE RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + logger.error("CREATE ERROR: %s", resp.text[:2000]) + raise CreateError( + f"创建对象失败: HTTP {resp.status_code} — {resp.text[:500]}" + ) + + logger.info("对象已创建: %s", obj_uri) + + if source and src_uri: + lock_handle, _ = self.lock(obj_uri, corr_nr) + try: + self.set_source(src_uri, source, lock_handle, corr_nr) + finally: + self.unlock(obj_uri, lock_handle) + self.activate(obj_name, obj_uri, corr_nr) + + return obj_uri, src_uri + + def _build_create_body( + self, + obj_type: str, + name: str, + description: str | None, + package: str = "$TMP", + ) -> str: + desc = description or name + if obj_type == "report": + return ( + '' + '' + f'' + "" + ) + elif obj_type == "class": + return ( + '' + '' + f'' + "" + ) + elif obj_type == "function": + return ( + '' + '' + f'' + "" + ) + elif obj_type == "functiongroup": + return ( + '' + '' + f'' + "" + ) + elif obj_type == "interface": + return ( + '' + '' + f'' + "" + ) + elif obj_type == "domain": + return ( + '' + '' + f'' + "" + ) + elif obj_type == "dataelement": + return ( + '' + '' + f'' + "" + ) + elif obj_type == "table": + return ( + '' + '' + f'' + "" + ) + elif obj_type == "tabletype": + return ( + '' + '' + f'' + "" + ) + raise ValueError(f"不支持的对象类型: {obj_type}") + + # ------------------------------------------------------------------ + # DDIC helpers + # ------------------------------------------------------------------ + + def create_ddic_object( + self, + obj_type: str, + name: str, + definition_body: str, + corr_nr: str | None = None, + ) -> tuple[str, str | None]: + config = get_type_config(obj_type) + obj_uri = config.format_obj_uri(name.lower()) + src_uri = config.format_src_uri(name.lower()) + + if obj_type in ("table", "structure"): + self._put_ddl_source(src_uri, definition_body, "", corr_nr) + else: + obj_name = name.upper() + body = self._build_create_body(obj_type, obj_name, obj_name) + collection_url = config.collection_uri + params: dict[str, str] = {} + if corr_nr: + params["corrNr"] = corr_nr + hdrs = self._headers(config.create_content_type) + logger.info( + "CREATE DDIC: POST %s name=%s (with full definition)", + collection_url, + obj_name, + ) + resp = self.session.post( + f"{self.host}{collection_url}", + headers=hdrs, + params=params, + data=definition_body.encode("utf-8"), + ) + logger.info("CREATE DDIC RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + raise CreateError( + f"创建 DDIC 对象失败: HTTP {resp.status_code} — {resp.text[:500]}" + ) + + obj_name = name.upper() + success, messages = self.activate(obj_name, obj_uri, corr_nr) + if not success: + errors = [m for m in messages if m["type"] == "E"] + if errors: + raise CreateError( + f"DDIC 对象激活失败: {'; '.join(e['text'] for e in errors)}" + ) + + logger.info("DDIC 对象已创建并激活: %s", obj_uri) + return obj_uri, src_uri + + def _put_ddic_xml( + self, + obj_uri: str, + xml_body: str, + lock_handle: str = "", + corr_nr: str | None = None, + ) -> bool: + url = f"{self.host}{obj_uri}" + params: dict[str, str] = {} + if lock_handle: + params["lockHandle"] = lock_handle + if corr_nr: + params["corrNr"] = corr_nr + hdrs = self._headers() + hdrs["Accept"] = "*/*" + logger.info("PUT DDIC XML: PUT %s (%d chars)", url, len(xml_body)) + resp = self.session.put( + url, headers=hdrs, params=params, data=xml_body.encode("utf-8") + ) + logger.info("PUT DDIC XML RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + raise CreateError( + f"写入 DDIC XML 失败: HTTP {resp.status_code} — {resp.text[:500]}" + ) + return True + + def _put_ddl_with_auto_lock( + self, + src_uri: str, + ddl_body: str, + obj_uri: str, + corr_nr: str | None = None, + ) -> bool: + try: + lock_handle, _ = self.lock(obj_uri, corr_nr) + except Exception: + logger.debug("自动锁定失败,使用空锁句柄继续", exc_info=True) + lock_handle = "" + try: + return self.set_source(src_uri, ddl_body, lock_handle, corr_nr) + finally: + if lock_handle: + self.unlock(obj_uri, lock_handle) + + def _put_ddl_source( + self, + src_uri: str, + ddl_body: str, + lock_handle: str, + corr_nr: str | None = None, + ) -> bool: + return self.set_source(src_uri, ddl_body, lock_handle, corr_nr) + + def get_object_status(self, obj_uri: str) -> dict[str, Any]: + """查询对象在 SAP 系统中的状态。 + + Returns: + {"exists": bool, "status": str, "corr_nr": str|None} + status: "active" / "inactive" / "not_exists" + """ + url = f"{self.host}{obj_uri}" + hdrs = self._headers() + hdrs["Accept"] = "*/*" + logger.info("GET OBJECT STATUS: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("GET OBJECT STATUS RESPONSE: HTTP %s", resp.status_code) + + if resp.status_code == 404: + return {"exists": False, "status": "not_exists", "corr_nr": None} + + if resp.status_code != 200: + logger.warning( + "Unexpected status %d for object status check: %s", + resp.status_code, + obj_uri, + ) + return {"exists": False, "status": "not_exists", "corr_nr": None} + + # 解析 XML 获取 version(active/inactive) + status = "active" + corr_nr: str | None = None + try: + root = ET.fromstring(resp.content) + ns = {"adtcore": "http://www.sap.com/adt/core"} + version = root.attrib.get(f"{{{ns['adtcore']}}}version", "") + if version == "inactive": + status = "inactive" + elif version and version != "active": + status = version + except ET.ParseError: + pass + + # 尝试获取 corr_nr:通过快速 lock → unlock 探测 + try: + lock_handle, detected_corr = self.lock(obj_uri) + corr_nr = detected_corr + self.unlock(obj_uri, lock_handle) + except Exception: + # 锁定失败也正常(可能权限问题),corr_nr 保持 None + logger.debug("探测 corr_nr 失败", exc_info=True) + + return {"exists": True, "status": status, "corr_nr": corr_nr} + + # ------------------------------------------------------------------ + # Function-group helpers + # ------------------------------------------------------------------ + + def function_group_exists(self, group_name: str) -> bool: + url = f"{self.host}/sap/bc/adt/functions/groups/{group_name.lower()}" + hdrs = self._headers() + logger.info("CHECK FG EXISTS: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("CHECK FG EXISTS RESPONSE: HTTP %s", resp.status_code) + return resp.status_code == 200 + + def create_function_group( + self, + group_name: str, + description: str | None = None, + corr_nr: str | None = None, + ) -> bool: + config = get_type_config("functiongroup") + params: dict[str, str] = {"groupname": group_name.upper()} + if corr_nr: + params["corrNr"] = corr_nr + desc = description or group_name + body = self._build_create_body("functiongroup", group_name.upper(), desc) + url = f"{self.host}{config.collection_uri}" + hdrs = self._headers(config.create_content_type) + logger.info("CREATE FG: POST %s name=%s", url, group_name.upper()) + resp = self.session.post( + url, headers=hdrs, params=params, data=body.encode("utf-8") + ) + logger.info("CREATE FG RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + logger.error("CREATE FG ERROR: %s", resp.text[:2000]) + raise CreateError( + f"创建函数组失败: HTTP {resp.status_code} — {resp.text[:500]}" + ) + return True + + # ------------------------------------------------------------------ + # CDS View + # ------------------------------------------------------------------ + + def get_cds_source(self, name: str) -> str: + """读取 CDS View DDL 源码。 + + Args: + name: CDS 名称。 + + Returns: + DDL 源码字符串。 + """ + url = f"{self.host}/sap/bc/adt/ddic/ddlsources/{name.lower()}/source/main" + hdrs = self._headers() + hdrs["Accept"] = "text/plain" + logger.info("GET CDS SOURCE: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("GET CDS SOURCE RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + return resp.text + + def create_cds( + self, + name: str, + description: str, + ddl_source: str, + ) -> tuple[str, str]: + """创建 CDS View 并写入 DDL 源码。 + + Args: + name: CDS 名称。 + description: 描述。 + ddl_source: DDL 源码。 + + Returns: + ``(obj_uri, src_uri)`` 元组。 + """ + obj_uri = f"/sap/bc/adt/ddic/ddlsources/{name.lower()}" + src_uri = f"/sap/bc/adt/ddic/ddlsources/{name.lower()}/source/main" + + # 1) 创建 CDS 对象 + create_url = f"{self.host}/sap/bc/adt/ddic/ddlsources" + desc = description or name + body = ( + '' + '' + '' + "" + ) + hdrs = self._headers("application/xml") + params: dict[str, str] = {"name": name.lower()} + logger.info("CREATE CDS: POST %s name=%s", create_url, name) + resp = self.session.post( + create_url, + headers=hdrs, + params=params, + data=body.encode("utf-8"), + ) + logger.info("CREATE CDS RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + logger.error("CREATE CDS ERROR: %s", resp.text[:2000]) + raise CreateError( + f"创建 CDS 失败: HTTP {resp.status_code} — {resp.text[:500]}" + ) + + # 2) 写入 DDL 源码 + lock_handle, corr_nr = self.lock(obj_uri) + try: + self.set_source(src_uri, ddl_source, lock_handle, corr_nr) + finally: + self.unlock(obj_uri, lock_handle) + + return obj_uri, src_uri + + # ------------------------------------------------------------------ + # Package + # ------------------------------------------------------------------ + + def create_package( + self, + name: str, + description: str | None = None, + superpackage: str | None = None, + ) -> bool: + """创建 ABAP 包。 + + Args: + name: 包名。 + description: 描述。 + superpackage: 上级包名。 + + Returns: + 是否成功。 + """ + url = f"{self.host}/sap/bc/adt/packages" + desc = description or name + body = ( + '' + '' + else: + body += "/>" + + hdrs = self._headers("application/xml") + logger.info("CREATE PACKAGE: POST %s name=%s", url, name) + resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8")) + logger.info("CREATE PACKAGE RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + logger.error("CREATE PACKAGE ERROR: %s", resp.text[:2000]) + raise CreateError( + f"创建包失败: HTTP {resp.status_code} — {resp.text[:500]}" + ) + return True + + def get_package_info(self, name: str) -> dict[str, str]: + """获取 ABAP 包信息。 + + Args: + name: 包名。 + + Returns: + 字典含 ``name``, ``description``, ``owner``, ``superpackage`` 等。 + """ + url = f"{self.host}/sap/bc/adt/packages/{name}" + hdrs = self._headers() + hdrs["Accept"] = "application/xml" + logger.info("GET PACKAGE INFO: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("GET PACKAGE INFO RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + + root = ET.fromstring(resp.content) + core_ns = "http://www.sap.com/adt/core" + info: dict[str, str] = { + "name": name, + "description": "", + "owner": "", + "superpackage": "", + } + # 尝试从根元素属性提取 + info["description"] = root.attrib.get(f"{{{core_ns}}}description", "") + info["owner"] = root.attrib.get(f"{{{core_ns}}}owner", "") + # 上级包引用 + pkg_ref = root.find(f".//{{{core_ns}}}packageRef") + if pkg_ref is not None: + info["superpackage"] = pkg_ref.attrib.get(f"{{{core_ns}}}name", "") + return info + + # ------------------------------------------------------------------ + # ATC / Quality + # ------------------------------------------------------------------ + + def atc_check( + self, + name: str, + obj_uri: str, + variant: str | None = None, + ) -> tuple[bool, list[dict[str, str]]]: + """执行 ATC 代码检查。 + + Args: + name: 对象名称。 + obj_uri: 对象 ADT URI。 + variant: 检查变体名称。 + + Returns: + ``(success, findings)`` — success 表示无严重错误, + findings 是发现项列表,每项含 ``type``, ``line``, ``text`` 等。 + """ + url = f"{self.host}/sap/bc/adt/atos/checks" + params: dict[str, str] = {"context": obj_uri} + if variant: + params["variant"] = variant + + body = ( + '' + '' + f'' + "" + ) + + hdrs = self._headers() + logger.info("ATC CHECK: POST %s name=%s", url, name) + resp = self.session.post( + url, headers=hdrs, params=params, data=body.encode("utf-8") + ) + logger.info("ATC CHECK RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + + findings: list[dict[str, str]] = [] + has_error = False + + root = ET.fromstring(resp.content) + core_ns = "http://www.sap.com/adt/core" + for item in root.iter(): + severity = item.attrib.get("severity", item.attrib.get("type", "")) + line = item.attrib.get("line", "") + text = item.attrib.get("message", item.text or "") + if severity or text: + findings.append({"type": severity, "line": line, "text": text}) + if severity in ("E", "1"): + has_error = True + + return not has_error, findings + + # ------------------------------------------------------------------ + # Pretty printer + # ------------------------------------------------------------------ + + def pretty_print(self, source: str) -> str: + """调用 ABAP Pretty Printer 格式化源码。 + + Args: + source: 原始 ABAP 源码。 + + Returns: + 格式化后的源码。 + """ + url = f"{self.host}/sap/bc/adt/prettyprinter" + hdrs = self._headers("text/plain; charset=utf-8") + hdrs["Accept"] = "text/plain" + logger.info("PRETTY PRINT: POST %s (%d chars)", url, len(source)) + resp = self.session.post(url, headers=hdrs, data=source.encode("utf-8")) + logger.info("PRETTY PRINT RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + return resp.text + + # ------------------------------------------------------------------ + # Table structure / data query + # ------------------------------------------------------------------ + + def get_table_fields(self, table_name: str) -> list[dict[str, str]]: + """查询 DDIC 表的字段结构。 + + Args: + table_name: 表名(不区分大小写)。 + + Returns: + 字段列表,每项含 name, type, length, description, key_attribute。 + """ + url = f"{self.host}/sap/bc/adt/datapreview/ddic/{table_name.lower()}/metadata" + hdrs = self._headers() + hdrs["Accept"] = "*/*" + logger.info("GET TABLE FIELDS: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("GET TABLE FIELDS RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + + root = ET.fromstring(resp.content) + ns = {"dp": "http://www.sap.com/adt/dataPreview"} + fields = [] + for col in root.findall(".//dp:columns/dp:metadata", ns): + field = { + "name": col.attrib.get(f"{{{ns['dp']}}}name", ""), + "type": col.attrib.get(f"{{{ns['dp']}}}type", ""), + "length": col.attrib.get(f"{{{ns['dp']}}}length", ""), + "description": col.attrib.get(f"{{{ns['dp']}}}description", ""), + "key_attribute": col.attrib.get(f"{{{ns['dp']}}}keyAttribute", "false"), + } + fields.append(field) + return fields + + def query_table_data(self, sql: str, max_rows: int = 200) -> dict: + """通过 ADT freestyle SQL 查询表数据。 + + Args: + sql: SELECT SQL 语句。 + max_rows: 最大返回行数。 + + Returns: + {"columns": [...字段名...], "rows": [[值1, 值2, ...], ...], "total_rows": int, "execution_time": str} + """ + url = f"{self.host}/sap/bc/adt/datapreview/freestyle" + hdrs = self._headers("text/plain; charset=utf-8") + hdrs["Accept"] = "*/*" + params = {"rowNumber": str(max_rows)} + logger.info("QUERY TABLE DATA: POST %s sql=%s maxRows=%d", url, sql[:80], max_rows) + resp = self.session.post(url, headers=hdrs, params=params, data=sql.encode("utf-8")) + logger.info("QUERY TABLE DATA RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + + root = ET.fromstring(resp.content) + ns = {"dp": "http://www.sap.com/adt/dataPreview"} + + # 提取列名(从第一组 columns/metadata) + columns = [] + for col in root.findall(".//dp:columns/dp:metadata", ns): + name = col.attrib.get(f"{{{ns['dp']}}}name", "") + if name: + columns.append(name) + + # 提取数据 — ADT 按列存储(每个 columns 包含一列的 dataSet) + # 需要转置为按行返回 + col_data: list[list[str]] = [] + for col_group in root.findall(".//dp:columns", ns): + dataset = col_group.find("dp:dataSet", ns) + if dataset is None: + col_data.append([]) + continue + values = [v.text or "" for v in dataset.findall("dp:data", ns)] + col_data.append(values) + + # 转置:列数据 → 行数据 + max_len = max((len(c) for c in col_data), default=0) + rows = [] + for i in range(max_len): + row = [] + for c in col_data: + row.append(c[i] if i < len(c) else "") + rows.append(row) + + # 元数据 + total_el = root.find("dp:totalRows", ns) + time_el = root.find("dp:queryExecutionTime", ns) + + return { + "columns": columns, + "rows": rows, + "total_rows": int(total_el.text) if total_el is not None and total_el.text else len(rows), + "execution_time": time_el.text if time_el is not None else "", + } + + # ------------------------------------------------------------------ + # Run program + # ------------------------------------------------------------------ + + def run_program(self, program_name: str) -> str: + """远程执行 ABAP 程序并返回输出。 + + Args: + program_name: 程序名(不区分大小写)。 + + Returns: + 程序的标准输出文本(text/plain)。 + """ + url = f"{self.host}/sap/bc/adt/programs/programrun/{program_name.lower()}" + hdrs = self._headers("application/xml") + hdrs["Accept"] = "*/*" + logger.info("RUN PROGRAM: POST %s", url) + resp = self.session.post(url, headers=hdrs) + logger.info("RUN PROGRAM RESPONSE: HTTP %s (%d bytes)", resp.status_code, len(resp.content)) + resp.raise_for_status() + return resp.text diff --git a/assets/sapcli/client/_search.py b/assets/sapcli/client/_search.py new file mode 100644 index 0000000..80e86b2 --- /dev/null +++ b/assets/sapcli/client/_search.py @@ -0,0 +1,197 @@ +"""SearchMixin — repository search, where-used, code search, diff helpers.""" + +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET + +from sapcli.exceptions import SapCliError + +logger = logging.getLogger("sapcli.client") + + +class SearchMixin: + """Object search, where-used listing, and source-code search.""" + + def list_objects( + self, + obj_type: str | None = None, + package: str | None = None, + prefix: str | None = None, + ) -> list[dict[str, str]]: + """通过 ADT Information System 搜索对象。 + + Args: + obj_type: ADT 类型代码,例如 ``PROG/P``, ``CLAS/OC``。 + package: 包名过滤。 + prefix: 名称前缀过滤(如 ``Z*``)。 + + Returns: + 列表,每项含 ``name``, ``type``, ``description``, ``package``。 + """ + url = f"{self.host}/sap/bc/adt/repository/informationsystem/search" + params: dict[str, str] = {"maxrow": "200"} + if obj_type: + params["type"] = obj_type + if prefix: + params["name"] = prefix + else: + params["name"] = "*" + if package: + params["pkg"] = package + + hdrs = self._headers() + hdrs["Accept"] = "application/xml" + logger.info("LIST OBJECTS: GET %s params=%s", url, params) + resp = self.session.get(url, headers=hdrs, params=params) + logger.info("LIST OBJECTS RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + + root = ET.fromstring(resp.content) + results: list[dict[str, str]] = [] + core_ns = "http://www.sap.com/adt/core" + for ref in root.findall(f".//{{{core_ns}}}objectReference"): + name = ref.attrib.get(f"{{{core_ns}}}name", "") + otype = ref.attrib.get(f"{{{core_ns}}}type", "") + desc = ref.attrib.get(f"{{{core_ns}}}description", "") + pkg = ref.attrib.get(f"{{{core_ns}}}package", "") + results.append( + { + "name": name, + "type": otype, + "description": desc, + "package": pkg, + } + ) + return results + + def where_used( + self, + name: str, + obj_uri: str, + adt_type: str | None = None, + ) -> list[dict[str, str]]: + """Where-Used 引用查询。 + + Args: + name: 对象名称。 + obj_uri: 对象 ADT URI。 + adt_type: ADT 类型代码。 + + Returns: + 列表,每项含 ``name``, ``type``, ``package``, ``uri``。 + """ + url = f"{self.host}/sap/bc/adt/usage/whereusedlist" + body = ( + '' + '' + f' list[dict[str, str]]: + """源代码搜索。 + + Args: + query: 搜索关键词。 + obj_type: ADT 类型代码过滤。 + + Returns: + 列表,每项含 ``name``, ``type``, ``description``。 + """ + url = f"{self.host}/sap/bc/adt/repository/structuredsearch" + body = ( + '' + '' + "" + "" + f"" + f'' + ) + if obj_type: + body += ( + f'' + ) + body += ( + "" + "" + "" + "" + ) + + hdrs = self._headers() + hdrs["Accept"] = "application/xml" + logger.info("SEARCH CODE: POST %s query=%s", url, query) + resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8")) + logger.info("SEARCH CODE RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + + root = ET.fromstring(resp.content) + results: list[dict[str, str]] = [] + core_ns = "http://www.sap.com/adt/core" + for ref in root.findall(f".//{{{core_ns}}}objectReference"): + obj_name = ref.attrib.get(f"{{{core_ns}}}name", "") + obj_type_val = ref.attrib.get(f"{{{core_ns}}}type", "") + desc = ref.attrib.get(f"{{{core_ns}}}description", "") + results.append( + { + "name": obj_name, + "type": obj_type_val, + "description": desc, + } + ) + return results + + # ------------------------------------------------------------------ + # Diff helper + # ------------------------------------------------------------------ + + def read_source_for_diff(self, name: str, obj_type: str) -> str: + """读取对象源码用于 diff 对比。 + + Args: + name: 对象名称。 + obj_type: 对象类型键(如 ``report``, ``class`` 等)。 + + Returns: + 源代码字符串。 + """ + from sapcli.types import parse_object_name + + parsed = parse_object_name(name, obj_type) + if parsed.src_uri is None: + raise SapCliError(f"{obj_type} 对象没有源代码 URI") + return self.get_source(parsed.src_uri) diff --git a/assets/sapcli/client/_source.py b/assets/sapcli/client/_source.py new file mode 100644 index 0000000..b924457 --- /dev/null +++ b/assets/sapcli/client/_source.py @@ -0,0 +1,404 @@ +"""SourceMixin — source code read/write, lock/unlock, activate, syntax-check.""" + +from __future__ import annotations + +import logging +import re +import xml.etree.ElementTree as ET + +from sapcli.exceptions import ( + ActivationError, + LockError, + SyntaxCheckError, +) + +logger = logging.getLogger("sapcli.client") + + +class SourceMixin: + """Source-code read/write and lock management.""" + + # ------------------------------------------------------------------ + # Source read / write + # ------------------------------------------------------------------ + + def get_source(self, src_uri: str) -> str: + url = f"{self.host}{src_uri}" + hdrs = self._headers() + hdrs["Accept"] = "text/plain" + logger.info("GET SOURCE: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info( + "GET SOURCE RESPONSE: HTTP %s, %d bytes", + resp.status_code, + len(resp.content) if resp.content else 0, + ) + resp.raise_for_status() + return resp.text + + def set_source( + self, + src_uri: str, + source: str, + lock_handle: str, + corr_nr: str | None = None, + ) -> bool: + params: dict[str, str] = {} + if lock_handle: + params["lockHandle"] = lock_handle + if corr_nr: + params["corrNr"] = corr_nr + url = f"{self.host}{src_uri}" + hdrs = self._headers("text/plain; charset=utf-8") + logger.info("SET SOURCE: PUT %s (%d chars)", url, len(source)) + resp = self.session.put( + url, headers=hdrs, params=params, data=source.encode("utf-8") + ) + logger.info("SET SOURCE RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + logger.error("SET SOURCE ERROR BODY: %s", resp.text[:2000]) + existing_nr = self._extract_locked_corrnr(resp.text) + if existing_nr and existing_nr != corr_nr: + logger.info("使用 corrNr=%s 重试 SET SOURCE", existing_nr) + params["corrNr"] = existing_nr + resp = self.session.put( + url, headers=hdrs, params=params, data=source.encode("utf-8") + ) + logger.info("SET SOURCE RETRY RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + resp.raise_for_status() + return True + + # ------------------------------------------------------------------ + # Lock / unlock + # ------------------------------------------------------------------ + + def lock( + self, + obj_uri: str, + corr_nr: str | None = None, + accept: str | None = None, + ) -> tuple[str, str | None]: + """锁定对象。 + + Returns: + (lock_handle, effective_corr_nr) — 实际使用的传输请求号。 + 如果对象已绑定在某请求中,effective_corrnr 为该请求号; + 否则为传入的 corr_nr(可能为 None)。 + """ + self._stateful = True + url = f"{self.host}{obj_uri}" + params: dict[str, str] = {"_action": "LOCK", "accessMode": "MODIFY"} + effective_corr_nr = corr_nr + if corr_nr: + params["corrNr"] = corr_nr + hdrs = self._headers() + hdrs["Accept"] = accept or "*/*" + logger.info("LOCK: POST %s (corrNr=%s)", url, corr_nr or "none") + resp = self.session.post(url, headers=hdrs, params=params) + logger.info("LOCK RESPONSE: HTTP %s", resp.status_code) + if resp.status_code == 500: + existing_nr = self._extract_locked_corrnr(resp.text) + if existing_nr: + effective_corr_nr = existing_nr + logger.info("对象已锁定在请求 %s 中,使用该请求重试", existing_nr) + params["corrNr"] = existing_nr + resp = self.session.post(url, headers=hdrs, params=params, data="") + logger.info("LOCK RETRY RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + raise LockError(f"锁定失败: HTTP {resp.status_code}", obj_uri=obj_uri) + root = ET.fromstring(resp.content) + handle = root.findtext(".//LOCK_HANDLE") + if not handle: + for el in root.iter(): + if "HANDLE" in el.tag.upper(): + handle = el.text + break + if not handle: + raise LockError("锁定失败: 未获取到 lock handle", obj_uri=obj_uri) + + # 从成功响应中提取 corrNr(对象可能已绑定传输请求) + if not effective_corr_nr: + extracted = self._extract_corrnr_from_lock_response(root) + if extracted: + effective_corr_nr = extracted + logger.info("从 lock 响应中检测到 corrNr: %s", extracted) + + logger.info( + "Lock handle: %s (corrNr=%s)", + handle[:20] if len(handle) > 20 else handle, + effective_corr_nr or "none", + ) + return handle, effective_corr_nr + + def _extract_locked_corrnr(self, error_body: str) -> str | None: + try: + root = ET.fromstring(error_body) + for entry in root.iter(): + if entry.attrib.get("key") == "corrNr": + corrnr = entry.text + if corrnr and corrnr != "*": + return corrnr + for el in root.iter(): + if el.tag.endswith("message") and el.text: + m = re.search(r"request\s+(\w+)", el.text, re.IGNORECASE) + if m: + return m.group(1) + except Exception: + logger.debug("XML 解析提取 locked corrnr 失败", exc_info=True) + return None + + def _extract_corrnr_from_lock_response(self, root: ET.Element) -> str | None: + """从成功的 lock 响应 XML 中提取传输请求号。 + + 常见格式: + + ... + DEVK901362 + + 也可能是: + DEVK901362 + """ + # 策略 1: 直接找 CORRN 标签 + for tag_name in ("CORRN", "corrNr", "corr_nr"): + text = root.findtext(f".//{tag_name}") + if text and text.strip() and text.strip() != "*": + return text.strip() + + # 策略 2: 遍历所有元素,找标签或属性包含 corrNr 的 + for el in root.iter(): + # 检查属性 + for attr_key in el.attrib: + if "corr" in attr_key.lower() and "nr" in attr_key.lower(): + val = el.attrib[attr_key] + if val and val.strip() and val.strip() != "*": + return val.strip() + # 检查标签名包含 CORRN + if el.text and "corr" in el.tag.lower() and el.text.strip() and el.text.strip() != "*": + return el.text.strip() + + # 策略 3: 找 key="corrNr" 的 property 元素 + for el in root.iter(): + if el.attrib.get("key") == "corrNr" and el.text: + val = el.text.strip() + if val and val != "*": + return val + + return None + + def unlock(self, obj_uri: str, lock_handle: str) -> bool: + url = f"{self.host}{obj_uri}" + params = {"_action": "UNLOCK", "lockHandle": lock_handle} + hdrs = self._headers("text/plain; charset=utf-8") + logger.info("UNLOCK: POST %s", url) + resp = self.session.post(url, headers=hdrs, params=params, data="") + logger.info("UNLOCK RESPONSE: HTTP %s", resp.status_code) + self._stateful = False + return resp.status_code == 200 + + # ------------------------------------------------------------------ + # Activate / syntax check + # ------------------------------------------------------------------ + + def activate( + self, + name: str, + obj_uri: str, + corr_nr: str | None = None, + ) -> tuple[bool, list[dict[str, str]]]: + body = ( + '' + '' + f'' + "" + ) + self._stateful = False + url = f"{self.host}/sap/bc/adt/activation" + params: dict[str, str] = {"method": "activate"} + if corr_nr: + params["corrNr"] = corr_nr + hdrs = self._headers() + logger.info("ACTIVATE: POST %s name=%s corrNr=%s", url, name, corr_nr or "none") + resp = self.session.post(url, headers=hdrs, params=params, data=body) + logger.info( + "ACTIVATE RESPONSE: HTTP %s CT=%s", + resp.status_code, + resp.headers.get("content-type", "")[:80], + ) + + if resp.status_code != 200: + logger.error("Activation HTTP %s", resp.status_code) + raise ActivationError(f"激活失败: HTTP {resp.status_code}") + + ct = resp.headers.get("content-type", "") + if "inactivectsobjects" in ct: + root = ET.fromstring(resp.content) + ioc_ns = "http://www.sap.com/abapxml/inactiveCtsObjects" + inactive_objects: list[str] = [] + for entry in root.findall(f".//{{{ioc_ns}}}entry"): + obj_elem = entry.find(f"{{{ioc_ns}}}object") + if obj_elem is not None: + ref = obj_elem.find(".//{http://www.sap.com/adt/core}ref") + if ref is None: + for child in obj_elem: + if child.tag.endswith("}ref"): + ref = child + break + if ref is not None: + inactive_name = ref.attrib.get( + "{http://www.sap.com/adt/core}name", "" + ) + inactive_type = ref.attrib.get( + "{http://www.sap.com/adt/core}type", "" + ) + if inactive_name: + inactive_objects.append(f"{inactive_name} ({inactive_type})") + if inactive_objects: + logger.warning("激活返回未激活对象: %s", ", ".join(inactive_objects)) + msg_text = f"Objects still inactive: {', '.join(inactive_objects)}" + messages: list[dict[str, str]] = [ + {"type": "E", "line": "?", "text": msg_text, "href": ""} + ] + logger.info("Activation result: FAILED (inactive objects)") + return False, messages + logger.info("Activation result: SUCCESS (inactiveObjects 响应但无对象列出)") + return True, [] + + if not resp.content or not resp.text.strip(): + # NW 7.40 返回空响应但可能并未真正激活 + # 验证对象实际状态 + logger.info("Activation returned empty response, verifying object status...") + try: + verify_resp = self.session.get( + f"{self.host}{obj_uri}", + headers=self._headers(), + ) + if verify_resp.status_code == 200: + vroot = ET.fromstring(verify_resp.content) + version = vroot.attrib.get("{http://www.sap.com/adt/core}version", "") + if version == "inactive": + # NW 7.40: first activation may only "stage" the change. + # Retry activation once, then re-verify. + logger.info("Object still inactive, retrying activation (NW 7.40 double-activate workaround)...") + retry_resp = self.session.post( + url, headers=hdrs, params=params, data=body, + ) + logger.info( + "RETRY ACTIVATE: HTTP %s len=%s", + retry_resp.status_code, len(retry_resp.content), + ) + if retry_resp.status_code == 200: + # Re-verify after retry + verify_resp2 = self.session.get( + f"{self.host}{obj_uri}", + headers=self._headers(), + ) + if verify_resp2.status_code == 200: + vroot2 = ET.fromstring(verify_resp2.content) + version2 = vroot2.attrib.get( + "{http://www.sap.com/adt/core}version", "" + ) + if version2 == "active": + logger.info("Retry activation succeeded!") + return True, [] + logger.warning("Object still inactive after retry (NW 7.40)") + messages = [ + { + "type": "W", + "line": "?", + "text": "ADT 激活返回空响应,对象仍为 inactive(NW 7.40 已知限制)。请在 SAP GUI SE09 手动激活。", + "href": "", + } + ] + return False, messages + elif version == "active": + logger.info("Verified: object is active after activation") + except Exception as e: + logger.warning("Could not verify activation status: %s", e) + logger.info("Activation success (空响应)") + return True, [] + + root = ET.fromstring(resp.content) + messages = [] + for msg in root.iter(): + msg_type = msg.attrib.get("type", "") + if msg_type in ("E", "W", "I", "S"): + line = msg.attrib.get("line", "?") + href = msg.attrib.get("href", "") + txt = "" + for child in msg.iter(): + if child.text and child.tag.endswith("}txt"): + txt = child.text + break + if not txt: + for child in msg.iter(): + if child.text and len(child.text.strip()) > 3: + txt = child.text.strip() + break + messages.append({"type": msg_type, "line": line, "text": txt, "href": href}) + logger.info("Activation msg [%s] line=%s: %s", msg_type, line, txt) + + errors = [m for m in messages if m["type"] == "E"] + success = len(errors) == 0 + logger.info( + "Activation result: %s (%d errors)", + "SUCCESS" if success else "FAILED", + len(errors), + ) + return success, messages + + def syntax_check( + self, + name: str, + obj_uri: str, + ) -> tuple[bool, list[dict[str, str]]]: + body = ( + '' + '' + f'' + "" + ) + self._stateful = False + url = f"{self.host}/sap/bc/adt/activation" + params = {"method": "check"} + hdrs = self._headers() + logger.info("SYNTAX CHECK: POST %s name=%s", url, name) + resp = self.session.post(url, headers=hdrs, params=params, data=body) + logger.info("SYNTAX CHECK RESPONSE: HTTP %s", resp.status_code) + + if resp.status_code != 200: + logger.error("Syntax check HTTP %s", resp.status_code) + raise SyntaxCheckError(f"语法检查失败: HTTP {resp.status_code}") + + if not resp.content or not resp.text.strip(): + logger.info("Syntax check: OK (空响应)") + return True, [] + + root = ET.fromstring(resp.content) + messages: list[dict[str, str]] = [] + for msg in root.iter(): + msg_type = msg.attrib.get("type", "") + if msg_type in ("E", "W", "I", "S"): + line = msg.attrib.get("line", "?") + href = msg.attrib.get("href", "") + txt = "" + for child in msg.iter(): + if child.text and child.tag.endswith("}txt"): + txt = child.text + break + if not txt: + for child in msg.iter(): + if child.text and len(child.text.strip()) > 3: + txt = child.text.strip() + break + messages.append({"type": msg_type, "line": line, "text": txt, "href": href}) + logger.info("Syntax check msg [%s] line=%s: %s", msg_type, line, txt) + + errors = [m for m in messages if m["type"] == "E"] + success = len(errors) == 0 + logger.info( + "Syntax check result: %s (%d errors)", + "OK" if success else "ERRORS", + len(errors), + ) + return success, messages diff --git a/assets/sapcli/client/_transport.py b/assets/sapcli/client/_transport.py new file mode 100644 index 0000000..647c8ac --- /dev/null +++ b/assets/sapcli/client/_transport.py @@ -0,0 +1,203 @@ +"""TransportMixin — transport request management.""" + +from __future__ import annotations + +import logging +import re +import xml.etree.ElementTree as ET + +from sapcli.exceptions import CreateError, SapCliError + +logger = logging.getLogger("sapcli.client") + + +class TransportMixin: + """Transport-request CRUD and release.""" + + def get_transport_request(self) -> str | None: + url = f"{self.host}/sap/bc/adt/cts/transportrequests" + hdrs: dict[str, str] = { + **self._headers(), + "Accept": "application/vnd.sap.adt.transportorganizer.v1+xml", + } + logger.info("GET TRANSPORT: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("GET TRANSPORT RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + root = ET.fromstring(resp.content) + tm_ns = "http://www.sap.com/cts/adt/tm" + for req in root.findall(f".//{{{tm_ns}}}request"): + num = req.attrib.get(f"{{{tm_ns}}}number", "") + status = req.attrib.get(f"{{{tm_ns}}}status", "") + if status == "D" and num: + logger.info("Transport request: %s", num) + return num + return None + + def list_transport_requests(self) -> list[dict[str, str]]: + """列出所有可修改的传输请求。 + + Returns: + 列表,每项包含 number, description, owner, status 字段。 + """ + url = f"{self.host}/sap/bc/adt/cts/transportrequests" + hdrs: dict[str, str] = { + **self._headers(), + "Accept": "application/vnd.sap.adt.transportorganizer.v1+xml", + } + logger.info("LIST TRANSPORT: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("LIST TRANSPORT RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + root = ET.fromstring(resp.content) + tm_ns = "http://www.sap.com/cts/adt/tm" + results: list[dict[str, str]] = [] + for req in root.findall(f".//{{{tm_ns}}}request"): + num = req.attrib.get(f"{{{tm_ns}}}number", "") + status = req.attrib.get(f"{{{tm_ns}}}status", "") + desc = req.attrib.get(f"{{{tm_ns}}}description", "") + owner = req.attrib.get(f"{{{tm_ns}}}owner", "") + if status == "D" and num: + results.append( + { + "number": num, + "description": desc, + "owner": owner, + "status": status, + } + ) + logger.info( + "Transport request: %s — %s (owner: %s)", num, desc, owner + ) + return results + + def create_transport_request(self, description: str) -> str: + """创建新的传输请求。 + + Args: + description: 传输请求描述。 + + Returns: + 新创建的传输请求编号。 + """ + url = f"{self.host}/sap/bc/adt/cts/transportrequests" + body = ( + '' + '' + ) + hdrs = self._headers() + logger.info("CREATE TRANSPORT: POST %s desc=%s", url, description) + resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8")) + logger.info("CREATE TRANSPORT RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + logger.error("CREATE TRANSPORT ERROR: %s", resp.text[:2000]) + raise CreateError( + f"创建传输请求失败: HTTP {resp.status_code} — {resp.text[:500]}" + ) + + # 从响应中解析新请求编号 + root = ET.fromstring(resp.content) + tm_ns = "http://www.sap.com/cts/adt/tm" + for req in root.findall(f".//{{{tm_ns}}}request"): + num = req.attrib.get(f"{{{tm_ns}}}number", "") + if num: + logger.info("New transport request created: %s", num) + return num + + # 尝试从 Location header 或响应体文本中提取编号 + location = resp.headers.get("Location", "") + m = re.search(r"transportrequests/(\w+)", location) + if m: + logger.info("New transport request from Location: %s", m.group(1)) + return m.group(1) + + logger.warning("无法从响应中解析新传输请求编号") + return "" + + def transport_info(self, corr_nr: str) -> dict[str, str]: + """获取传输请求详情。 + + Args: + corr_nr: 传输请求编号。 + + Returns: + 字典含 ``number``, ``description``, ``status``, ``owner`` 等。 + """ + url = f"{self.host}/sap/bc/adt/cts/transportrequests/{corr_nr}" + hdrs: dict[str, str] = { + **self._headers(), + "Accept": "application/vnd.sap.adt.transportorganizer.v1+xml", + } + logger.info("TRANSPORT INFO: GET %s", url) + resp = self.session.get(url, headers=hdrs) + logger.info("TRANSPORT INFO RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + + root = ET.fromstring(resp.content) + tm_ns = "http://www.sap.com/cts/adt/tm" + info: dict[str, str] = { + "number": corr_nr, + "description": "", + "status": "", + "owner": "", + } + for req in root.findall(f".//{{{tm_ns}}}request"): + info["number"] = req.attrib.get(f"{{{tm_ns}}}number", corr_nr) + info["description"] = req.attrib.get(f"{{{tm_ns}}}description", "") + info["status"] = req.attrib.get(f"{{{tm_ns}}}status", "") + info["owner"] = req.attrib.get(f"{{{tm_ns}}}owner", "") + break + return info + + def transport_release(self, corr_nr: str) -> bool: + """释放传输请求。 + + Args: + corr_nr: 传输请求编号。 + + Returns: + 是否成功。 + """ + url = f"{self.host}/sap/bc/adt/cts/transportrequests/{corr_nr}/%20" + hdrs = self._headers() + params: dict[str, str] = {"_action": "RELEASE"} + logger.info("TRANSPORT RELEASE: POST %s corrNr=%s", url, corr_nr) + resp = self.session.post(url, headers=hdrs, params=params, data="") + logger.info("TRANSPORT RELEASE RESPONSE: HTTP %s", resp.status_code) + if resp.status_code >= 400: + logger.error("TRANSPORT RELEASE ERROR: %s", resp.text[:2000]) + raise SapCliError( + f"释放传输请求失败: HTTP {resp.status_code} — {resp.text[:500]}" + ) + return True + + def transport_objects(self, corr_nr: str) -> list[dict[str, str]]: + """列出传输请求中的对象。 + + Args: + corr_nr: 传输请求编号。 + + Returns: + 列表,每项含 ``name``, ``type``。 + """ + url = f"{self.host}/sap/bc/adt/cts/transportrequests/{corr_nr}" + hdrs: dict[str, str] = { + **self._headers(), + "Accept": "application/vnd.sap.adt.transportorganizer.v1+xml", + } + params: dict[str, str] = {"withObjects": "true"} + logger.info("TRANSPORT OBJECTS: GET %s", url) + resp = self.session.get(url, headers=hdrs, params=params) + logger.info("TRANSPORT OBJECTS RESPONSE: HTTP %s", resp.status_code) + resp.raise_for_status() + + root = ET.fromstring(resp.content) + results: list[dict[str, str]] = [] + core_ns = "http://www.sap.com/adt/core" + for ref in root.findall(f".//{{{core_ns}}}objectReference"): + obj_name = ref.attrib.get(f"{{{core_ns}}}name", "") + obj_type = ref.attrib.get(f"{{{core_ns}}}type", "") + results.append({"name": obj_name, "type": obj_type}) + return results diff --git a/assets/sapcli/commands/__init__.py b/assets/sapcli/commands/__init__.py new file mode 100644 index 0000000..cd75340 --- /dev/null +++ b/assets/sapcli/commands/__init__.py @@ -0,0 +1,83 @@ +"""sapcli 命令模块包。 + +将原 commands.py 按功能拆分为多个子模块,保持统一的导出接口。 +""" +from sapcli.commands.crud import ( + cmd_create, + cmd_delete, + cmd_download, + cmd_info, + cmd_sync, +) +from sapcli.commands.batch import ( + cmd_init, + cmd_refresh, + cmd_sync_all, +) +from sapcli.commands.config_cmd import ( + cmd_config, +) +from sapcli.commands.search import ( + cmd_list, + cmd_whereused, + cmd_search, +) +from sapcli.commands.transport import ( + cmd_transport, +) +from sapcli.commands.quality import ( + cmd_check, + cmd_format, +) +from sapcli.commands.diff_cmd import ( + cmd_diff, +) +from sapcli.commands.package_cmd import ( + cmd_package, +) +from sapcli.commands.cds import ( + cmd_cds, +) +from sapcli.commands.analyze import ( + cmd_analyze, +) +from sapcli.commands.scaffold import ( + cmd_scaffold, +) +from sapcli.commands.ddl_query import ( + cmd_show_table, + cmd_read_table, +) +from sapcli.commands.program_run import ( + cmd_run_program, +) +from sapcli.commands.activate import ( + cmd_activate, +) + +__all__ = [ + "cmd_create", + "cmd_delete", + "cmd_download", + "cmd_info", + "cmd_init", + "cmd_refresh", + "cmd_sync", + "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", + "cmd_show_table", + "cmd_read_table", + "cmd_run_program", + "cmd_activate", +] diff --git a/assets/sapcli/commands/activate.py b/assets/sapcli/commands/activate.py new file mode 100644 index 0000000..f1d6d09 --- /dev/null +++ b/assets/sapcli/commands/activate.py @@ -0,0 +1,122 @@ +"""activate 命令 — 单独激活 SAP 对象(无需重新 sync)。""" +from __future__ import annotations + +import argparse +import logging + +from sapcli.client import ADTClient +from sapcli.types import get_type_config, parse_object_name + +logger = logging.getLogger("sapcli.commands.activate") + + +def cmd_activate(args: argparse.Namespace, client: ADTClient) -> None: + """单独激活一个 SAP 对象。 + + 支持 --name / --type 激活单个对象, + 也支持 --names / --types 批量激活多个对象。 + 可选 --corr_nr 传入传输请求号。 + """ + corr_nr: str | None = getattr(args, "corr_nr", None) + + # 批量模式:--names ZCLS1,ZCLS2 --types class,class + names_raw: str | None = getattr(args, "names", None) + types_raw: str | None = getattr(args, "types", None) + + if names_raw and types_raw: + names = [n.strip() for n in names_raw.split(",") if n.strip()] + types = [t.strip() for t in types_raw.split(",") if t.strip()] + if len(names) != len(types): + print(" ✗ --names 和 --types 的数量不匹配") + return + _activate_batch(client, names, types, corr_nr) + return + + # 单对象模式:--name / --type + name: str = args.name + obj_type: str = args.type + + parsed = parse_object_name(name, obj_type) + type_label = get_type_config(obj_type).label + + print("=" * 60) + print(" SAP ADT 对象激活") + print("=" * 60) + print(f" 对象名称: {parsed.display_name}") + print(f" 对象类型: {type_label}") + if corr_nr: + print(f" 传输请求: {corr_nr}") + + _activate_single(client, name, parsed.obj_uri, corr_nr, type_label) + + +def _activate_single( + client: ADTClient, + name: str, + obj_uri: str, + corr_nr: str | None, + type_label: str = "", +) -> bool: + """激活单个对象,返回是否成功。""" + label = type_label or name + print(f"\n → 正在激活 {name}...") + + try: + success, messages = client.activate(name, obj_uri, corr_nr) + except Exception as e: + print(f" ✗ 激活失败: {e}") + return False + + errors = [m for m in messages if m["type"] == "E"] + warnings = [m for m in messages if m["type"] == "W"] + infos = [m for m in messages if m["type"] == "I"] + + if success: + print(f" ✓ {name} 激活成功!") + else: + print(f" ✗ {name} 激活失败! {len(errors)} 个错误, {len(warnings)} 个警告") + + for msg in messages: + icon = {"E": "✗", "W": "⚠", "I": "ℹ", "S": "✓"}.get(msg["type"], "?") + line = msg.get("line", "?") + text = msg.get("text", "") + print(f" {icon} [{msg['type']}] 行 {line}: {text}") + + return success + + +def _activate_batch( + client: ADTClient, + names: list[str], + types: list[str], + corr_nr: str | None, +) -> None: + """批量激活多个对象。""" + print("=" * 60) + print(" SAP ADT 批量激活") + print("=" * 60) + print(f" 对象数量: {len(names)}") + if corr_nr: + print(f" 传输请求: {corr_nr}") + + results: list[tuple[str, bool]] = [] + for name, obj_type in zip(names, types): + parsed = parse_object_name(name, obj_type) + ok = _activate_single(client, name, parsed.obj_uri, corr_nr) + results.append((name, ok)) + + # 汇总 + print() + print("─" * 60) + print(" 激活结果汇总:") + print("─" * 60) + ok_count = sum(1 for _, ok in results if ok) + fail_count = len(results) - ok_count + for name, ok in results: + icon = "✓" if ok else "✗" + print(f" {icon} {name}") + print() + if fail_count == 0: + print(f" ✓ 全部激活成功 ({ok_count}/{len(results)})") + else: + print(f" ⚠ 成功 {ok_count}, 失败 {fail_count} (共 {len(results)})") diff --git a/assets/sapcli/commands/analyze.py b/assets/sapcli/commands/analyze.py new file mode 100644 index 0000000..d7856df --- /dev/null +++ b/assets/sapcli/commands/analyze.py @@ -0,0 +1,126 @@ +"""依赖分析命令:analyze。""" +from __future__ import annotations + +import argparse +import logging +import os +import re + +from sapcli.client import ADTClient + +logger = logging.getLogger("sapcli.commands.analyze") + +# ABAP 依赖模式 +_DEPENDENCY_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ + ("TYPE REF TO", re.compile(r"TYPE\s+REF\s+TO\s+(\w+)", re.IGNORECASE)), + ("CALL METHOD", re.compile(r"CALL\s+METHOD\s+(\w+)", re.IGNORECASE)), + ("CALL METHOD (static)", re.compile(r"(\w+)=>(\w+)", re.IGNORECASE)), + ("CREATE OBJECT", re.compile(r"CREATE\s+OBJECT\s+\w+\s+TYPE\s+(\w+)", re.IGNORECASE)), + ("PERFORM", re.compile(r"PERFORM\s+(\w+)", re.IGNORECASE)), + ("CALL FUNCTION", re.compile(r"CALL\s+FUNCTION\s+\'(\w+)\'", re.IGNORECASE)), + ("SELECT ... FROM", re.compile(r"FROM\s+(\w+)", re.IGNORECASE)), + ("LIKE", re.compile(r"LIKE\s+(\w+)", re.IGNORECASE)), + ("TYPE", re.compile(r"TYPE\s+(\w+)", re.IGNORECASE)), +] + +# 已知的 ABAP 内置类型(不需要追踪) +_BUILTIN_TYPES = frozenset({ + "STRING", "CHAR", "NUMC", "INT1", "INT2", "INT4", "INT8", + "FLOAT", "DECFLOAT16", "DECFLOAT34", "DEC", "CURR", "QUAN", + "RAW", "LRAW", "RAWSTRING", "DATS", "TIMS", "SSTRING", + "XSTRING", "X", "C", "N", "D", "T", "I", "F", "P", + "ANY", "DATA", "REF", "OBJECT", "STRUCTURE", "TABLE", + "STANDARD", "SORTED", "HASHED", "INDEX", "SY", + "ABAP_BOOL", "ABAP_TRUE", "ABAP_FALSE", "BOOLEAN", + "XSDBOOLEAN", "FLAG", "CHAR1", "CHAR10", +}) + + +def cmd_analyze(args: argparse.Namespace, client: ADTClient) -> None: + """依赖自动分析。 + + 解析本地 .abap 文件中的 TYPE REF TO / CALL METHOD / PERFORM 等, + 自动生成 depends_on 列表。 + """ + project_path: str = getattr(args, "path", ".") + + print("=" * 60) + print(" sap-cli 依赖分析") + print("=" * 60) + print(f" 项目路径: {project_path}") + + if not os.path.isdir(project_path): + print(f"\n ✗ 目录不存在: {project_path}") + return + + # 扫描所有 .abap 文件 + abap_files: list[str] = [] + for root, dirs, files in os.walk(project_path): + for fname in files: + if fname.endswith(".abap"): + abap_files.append(os.path.join(root, fname)) + + if not abap_files: + print("\n ℹ 未找到 .abap 文件") + return + + print(f"\n → 扫描到 {len(abap_files)} 个 .abap 文件\n") + + # 分析每个文件 + all_deps: dict[str, dict[str, list[str]]] = {} + for filepath in abap_files: + rel_path = os.path.relpath(filepath, project_path) + file_base = os.path.splitext(os.path.basename(filepath))[0].upper() + + with open(filepath, "r", encoding="utf-8") as f: + source = f.read() + + deps = _analyze_dependencies(source) + all_deps[file_base] = {"file": rel_path, "deps": deps} + + if deps: + print(f" 📄 {rel_path}") + print(f" → {', '.join(deps)}") + else: + print(f" 📄 {rel_path} (无外部依赖)") + + # 汇总 + print(f"\n {'─' * 50}") + total_deps = sum(len(v["deps"]) for v in all_deps.values()) + files_with_deps = sum(1 for v in all_deps.values() if v["deps"]) + print(f" 总计: {len(abap_files)} 个文件, {files_with_deps} 个有外部依赖, {total_deps} 个依赖关系") + + # 输出 depends_on 配置建议 + if total_deps > 0: + print(f"\n 建议的 manifest.json depends_on 配置:") + print(f" {'─' * 50}") + for name, info in sorted(all_deps.items()): + if info["deps"]: + deps_str = ", ".join(f'"{d}"' for d in sorted(info["deps"])) + print(f' "{name}": [{deps_str}]') + + +def _analyze_dependencies(source: str) -> list[str]: + """分析 ABAP 源码中的依赖关系。 + + Args: + source: ABAP 源码字符串。 + + Returns: + 依赖对象名称列表(去重)。 + """ + deps: set[str] = set() + + for pattern_name, pattern in _DEPENDENCY_PATTERNS: + for match in pattern.finditer(source): + name = match.group(1).upper() + # 过滤内置类型和短名称 + if name in _BUILTIN_TYPES: + continue + if len(name) < 3: + continue + # 只追踪 Z/Y 开头的自定义对象(更精确) + if name.startswith("Z") or name.startswith("Y"): + deps.add(name) + + return sorted(deps) diff --git a/assets/sapcli/commands/batch.py b/assets/sapcli/commands/batch.py new file mode 100644 index 0000000..8950047 --- /dev/null +++ b/assets/sapcli/commands/batch.py @@ -0,0 +1,328 @@ +"""批量操作命令:init / refresh / sync-all。""" +from __future__ import annotations + +import argparse +import logging +import os + +from sapcli.client import ADTClient +from sapcli.commands.crud import _sync_single +from sapcli.exceptions import ConfigError, CyclicDependencyError +from sapcli.manifest import Manifest, ManifestEntry, init_manifest +from sapcli.scanner import scan_project +from sapcli.sorter import topological_sort +from sapcli.types import parse_object_name + +logger = logging.getLogger("sapcli.commands.batch") + + +def cmd_init(args: argparse.Namespace, client: ADTClient) -> None: + """项目初始化:扫描本地文件 → 查询 SAP → 生成 manifest.json。""" + from datetime import datetime, timezone + + from sapcli.types import parse_object_name + + project_path = os.path.abspath(args.path) + if not os.path.isdir(project_path): + raise ConfigError(f"项目目录不存在: {project_path}") + + print("=" * 60) + print(" sap-cli 项目初始化") + print("=" * 60) + print(f" 项目路径: {project_path}") + + # ── Step 1: 扫描本地文件 ── + print(f"\n → 正在扫描本地文件...") + scanned = scan_project(project_path) + + if not scanned: + print(" ℹ 未扫描到 .abap 文件") + print(" 请确认项目目录结构是否符合约定:") + print(" reports/, classes/, interfaces/, functions/, domains/, ...") + return + + print(f" ✓ 扫描到 {len(scanned)} 个本地文件\n") + + # ── Step 2: 逐个查询 SAP 状态 ── + print(f" → 正在查询 SAP 系统状态...") + manifest = init_manifest(project_path) + counts = {"active": 0, "inactive": 0, "not_exists": 0} + + for obj in scanned: + parsed = parse_object_name(obj.name, obj.type) + try: + status_info = client.get_object_status(parsed.obj_uri) + except Exception as e: + logger.warning("查询 %s 状态失败: %s", obj.name, e) + status_info = {"exists": False, "status": "not_exists", "corr_nr": None} + + system_status = status_info["status"] + corr_nr = status_info["corr_nr"] + counts[system_status] = counts.get(system_status, 0) + 1 + + # 状态图标 + if system_status == "active": + icon = "✓" + corr_display = f"corr: {corr_nr or '本地对象'}" + elif system_status == "inactive": + icon = "⚠" + corr_display = f"corr: {corr_nr or '本地对象'}" + else: + icon = "✗" + corr_display = "不存在" + + print(f" {icon} {obj.name:<20s} ({obj.type:<10s}) {system_status:<12s} {corr_display}") + + entry = ManifestEntry( + name=obj.name, + type=obj.type, + file=obj.file, + system_status=system_status, + corr_nr=corr_nr, + depends_on=[], + last_sync=None, + last_sync_result="pending", + ) + manifest.upsert(entry) + + # ── Step 3: 保存清单 ── + manifest.save() + + total = sum(counts.values()) + print(f"\n ✓ 清单已生成: {os.path.join(project_path, 'manifest.json')}") + print(f" {total} 个对象: " + f"{counts.get('active', 0)} 已激活, " + f"{counts.get('inactive', 0)} 未激活, " + f"{counts.get('not_exists', 0)} 不存在") + + +def cmd_refresh(args: argparse.Namespace, client: ADTClient) -> None: + """刷新清单:重新查询 SAP 状态,更新 system_status 和 corr_nr。""" + from datetime import datetime, timezone + + from sapcli.types import parse_object_name + + project_path = os.path.abspath(args.path) + manifest = Manifest.load(project_path) + + print("=" * 60) + print(" sap-cli 清单刷新") + print("=" * 60) + print(f" 项目路径: {project_path}") + print(f" 清单对象: {len(manifest.objects)}\n") + + print(f" → 正在查询 SAP 系统状态...") + changed = 0 + unchanged = 0 + + for name, entry in manifest.objects.items(): + parsed = parse_object_name(name, entry.type) + old_status = entry.system_status + old_corr = entry.corr_nr + + try: + status_info = client.get_object_status(parsed.obj_uri) + except Exception as e: + logger.warning("查询 %s 状态失败: %s", name, e) + print(f" ✗ {name:<20s} ({entry.type:<10s}) 查询失败: {e}") + unchanged += 1 + continue + + new_status = status_info["status"] + new_corr = status_info["corr_nr"] + + # 更新 + entry.system_status = new_status + entry.corr_nr = new_corr + + # 对比变化 + status_changed = old_status != new_status + corr_changed = old_corr != new_corr + is_changed = status_changed or corr_changed + + if is_changed: + changed += 1 + changes = [] + if status_changed: + changes.append(f"状态变更: {old_status} → {new_status}") + if corr_changed: + changes.append(f"传输请求: {old_corr or '无'} → {new_corr or '无'}") + change_str = ", ".join(changes) + print(f" ~ {name:<20s} ({entry.type:<10s}) {new_status:<12s} corr: {new_corr or 'null':<15s} ({change_str})") + else: + unchanged += 1 + print(f" ✓ {name:<20s} ({entry.type:<10s}) {new_status:<12s} corr: {new_corr or 'null':<15s} (未变化)") + + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + manifest.last_refresh = now + manifest.save() + + print(f"\n ✓ 刷新完成") + print(f" 变更: {changed} 个 | 未变化: {unchanged} 个") + + +def cmd_sync_all(args: argparse.Namespace, client: ADTClient) -> None: + """批量同步:读取清单 → 拓扑排序 → 逐个同步 → 更新清单。""" + from datetime import datetime, timezone + + from sapcli.types import parse_object_name + + project_path = os.path.abspath(args.path) + manifest = Manifest.load(project_path) + dry_run = getattr(args, "dry_run", False) + fail_fast = getattr(args, "fail_fast", False) + + all_entries = list(manifest.objects.values()) + total = len(all_entries) + + print("=" * 60) + if dry_run: + print(" sap-cli 批量同步 (dry-run)") + else: + print(" sap-cli 批量同步") + print("=" * 60) + print(f" 项目路径: {project_path}") + print(f" 清单对象: {total}") + + # ── Step 1: 扫描本地文件 vs 清单差异 ── + scanned = scan_project(project_path) + scanned_names = {s.name for s in scanned} + manifest_names = set(manifest.objects.keys()) + + new_files = scanned_names - manifest_names + missing_files = manifest_names - scanned_names + + if new_files: + print(f"\n ⚠ {len(new_files)} 个本地文件未纳入清单,建议先 create:") + for name in sorted(new_files): + print(f" - {name}") + + # ── Step 2: 过滤待处理对象 ── + pending = [] + skipped_up_to_date = [] + for entry in all_entries: + # 检查本地文件是否存在 + abs_file = manifest.file_path(entry) + if entry.name in missing_files or not os.path.isfile(abs_file): + print(f"\n ⚠ {entry.name} 本地文件缺失,将跳过") + skipped_up_to_date.append(entry) + continue + # 跳过已同步且激活的对象 + if entry.system_status == "active" and entry.last_sync_result == "success": + skipped_up_to_date.append(entry) + continue + pending.append(entry) + + # ── Step 3: 拓扑排序 ── + try: + sorted_entries = topological_sort(all_entries) + except CyclicDependencyError as e: + print(f"\n ✗ {e}") + raise + + # ── Step 4: dry-run 模式 ── + if dry_run: + print(f"\n 执行计划(按依赖排序):") + pending_set = {e.name for e in pending} + idx = 0 + for entry in sorted_entries: + idx += 1 + if entry in skipped_up_to_date and entry.name not in pending_set: + print(f" {idx}. {entry.name:<20s} ({entry.type:<10s}) → 跳过 [已是最新]") + elif entry.name in missing_files: + print(f" {idx}. {entry.name:<20s} ({entry.type:<10s}) → 跳过 [文件缺失]") + else: + status_info = f"{entry.system_status} → active" + deps = entry.depends_on + dep_str = f" [依赖: {', '.join(deps)}]" if deps else "" + print(f" {idx}. {entry.name:<20s} ({entry.type:<10s}) → sync [{status_info}]{dep_str}") + + print(f"\n 待处理: {len(pending)} 个 | 跳过: {total - len(pending)} 个") + return + + # ── Step 5: 执行同步 ── + print(f" 待处理: {len(pending)} 个对象\n") + + succeeded: list[str] = [] + failed: list[tuple[str, str]] = [] # (name, reason) + skipped_deps: list[str] = [] # 因依赖失败而跳过的 + failed_set: set[str] = set() # 失败的对象名集合(用于级联跳过) + + # 建立排序后的顺序映射 + sorted_order = {entry.name: i for i, entry in enumerate(sorted_entries)} + pending_sorted = sorted(pending, key=lambda e: sorted_order.get(e.name, 999)) + + for i, entry in enumerate(pending_sorted, 1): + # 检查依赖是否全部成功 + dep_failed = [d for d in entry.depends_on if d in failed_set] + if dep_failed: + skipped_deps.append(entry.name) + failed_set.add(entry.name) + entry.last_sync_result = "skipped" + print(f" ⊘ [{i}/{len(pending_sorted)}] {entry.name} — 跳过(依赖失败: {', '.join(dep_failed)})") + continue + + abs_file = manifest.file_path(entry) + print(f"\n ── [{i}/{len(pending_sorted)}] {entry.name} ({entry.type}) ──") + + # 如果对象不存在,先创建 + if entry.system_status == "not_exists": + parsed = parse_object_name(entry.name, entry.type) + print(f" ℹ 对象不存在,自动创建空对象...") + try: + if entry.type == "function": + group_name = entry.name.split("/", 1)[0] + if not client.function_group_exists(group_name): + print(f" → 函数组 {group_name} 不存在,自动创建...") + client.create_function_group(group_name) + print(f" ✓ 函数组 {group_name} 创建成功") + client.create_object(entry.type, entry.name, entry.name, source=None) + print(f" ✓ 空对象创建成功") + except Exception as e: + error_msg = f"创建失败: {e}" + print(f" ✗ {error_msg}") + failed.append((entry.name, error_msg)) + failed_set.add(entry.name) + entry.last_sync_result = "failed" + if fail_fast: + break + continue + + # 执行同步 + success, error_msg, actual_corr_nr = _sync_single( + entry.name, entry.type, abs_file, client, quiet=False, + ) + + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + + if success: + succeeded.append(entry.name) + entry.system_status = "active" + entry.corr_nr = actual_corr_nr + entry.last_sync = now + entry.last_sync_result = "success" + else: + failed.append((entry.name, error_msg or "未知错误")) + failed_set.add(entry.name) + entry.last_sync = now + entry.last_sync_result = "failed" + print(f" ⊘ 跳过(标记为 failed)") + if fail_fast: + break + + # ── Step 6: 保存并输出汇总 ── + manifest.save() + + print(f"\n{'=' * 60}") + print(f" 批量同步完成") + print(f"{'=' * 60}") + if succeeded: + print(f" ✓ 成功: {', '.join(succeeded)}") + if failed: + for name, reason in failed: + print(f" ✗ 失败: {name} ({reason})") + if skipped_deps: + print(f" ⊘ 跳过: {', '.join(skipped_deps)}") + if not failed and not skipped_deps: + print(f" ⊘ 跳过: (无)") + print(f" 清单已更新: {os.path.join(project_path, 'manifest.json')}") diff --git a/assets/sapcli/commands/cds.py b/assets/sapcli/commands/cds.py new file mode 100644 index 0000000..e0c7cb2 --- /dev/null +++ b/assets/sapcli/commands/cds.py @@ -0,0 +1,186 @@ +"""CDS View 命令:cds (download / sync / create)。""" +from __future__ import annotations + +import argparse +import logging +import os + +from sapcli.client import ADTClient + +logger = logging.getLogger("sapcli.commands.cds") + + +def cmd_cds(args: argparse.Namespace, client: ADTClient) -> None: + """CDS View 操作。""" + action = getattr(args, "cds_action", None) + + if action == "download": + _cds_download(args, client) + elif action == "sync": + _cds_sync(args, client) + elif action == "create": + _cds_create(args, client) + else: + print(" 用法: sap-cli cds [download|sync|create]") + print(" download — 下载 CDS View DDL 源码") + print(" sync — 同步本地 DDL 到 SAP") + print(" create — 创建新的 CDS View") + + +def _cds_download(args: argparse.Namespace, client: ADTClient) -> None: + """下载 CDS View DDL 源码。""" + name: str = args.name + save_path: str = getattr(args, "path", ".") + + print("=" * 60) + print(" SAP CDS View DDL 源码下载") + print("=" * 60) + print(f" CDS 名称: {name}") + print(f" 保存路径: {save_path}") + + print(f"\n → 正在下载 DDL 源码...") + try: + source = client.get_cds_source(name) + except Exception as e: + print(f" ✗ 下载失败: {e}") + return + + source = source.replace("\r\n", "\n").replace("\r", "\n") + line_count = len(source.splitlines()) + print(f" ✓ DDL 源码下载成功! {len(source)} 字符, {line_count} 行") + + if not os.path.isdir(save_path): + os.makedirs(save_path, exist_ok=True) + + filename = f"{name.lower()}.ddl" + filepath = os.path.join(save_path, filename) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(source) + print(f"\n ✓ 文件已保存: {filepath}") + + +def _cds_sync(args: argparse.Namespace, client: ADTClient) -> None: + """同步本地 DDL 到 SAP。""" + name: str = args.name + ddl_path: str = args.path + + print("=" * 60) + print(" SAP CDS View DDL 同步") + print("=" * 60) + print(f" CDS 名称: {name}") + print(f" DDL 文件: {ddl_path}") + + if not os.path.isfile(ddl_path): + print(f"\n ✗ DDL 文件不存在: {ddl_path}") + return + + with open(ddl_path, "r", encoding="utf-8") as f: + ddl_source = f.read() + + obj_uri = f"/sap/bc/adt/ddic/ddlsources/{name.lower()}" + src_uri = f"/sap/bc/adt/ddic/ddlsources/{name.lower()}/source/main" + + # 检查对象是否存在 + print(f"\n → 检查 CDS View 是否存在...") + exists = client.object_exists(obj_uri) + + if not exists: + print(f" ℹ CDS View 不存在,需要先创建") + try: + client.create_cds(name, name, ddl_source) + print(f" ✓ CDS View 创建并同步成功!") + except Exception as e: + print(f" ✗ 创建失败: {e}") + return + + print(f" ✓ CDS View 存在") + + # 锁定 → 写入 → 解锁 → 激活 + print(f"\n → 正在同步 DDL 源码...") + try: + lock_handle, corr_nr = client.lock(obj_uri) + try: + client.set_source(src_uri, ddl_source, lock_handle, corr_nr) + print(f" ✓ DDL 源码写入成功") + finally: + client.unlock(obj_uri, lock_handle) + except Exception as e: + print(f" ✗ 写入失败: {e}") + return + + # 激活 + print(f"\n → 正在激活...") + try: + success, messages = client.activate(name.upper(), obj_uri) + if success: + print(f" ✓ 激活成功") + else: + errors = [m for m in messages if m["type"] == "E"] + print(f" ✗ 激活失败: {len(errors)} 个错误") + for e in errors: + print(f" [错误] 行 {e['line']}: {e['text']}") + except Exception as e: + print(f" ⚠ 激活失败: {e}") + + print(f"\n ✓ CDS View 同步完成!") + + +def _cds_create(args: argparse.Namespace, client: ADTClient) -> None: + """创建新的 CDS View。""" + name: str = args.name + description: str = getattr(args, "description", None) or name + ddl_path: str | None = getattr(args, "ddl_path", None) + + print("=" * 60) + print(" SAP 创建 CDS View") + print("=" * 60) + print(f" CDS 名称: {name}") + print(f" 描述: {description}") + + # 读取 DDL 源码 + ddl_source = "" + if ddl_path and os.path.isfile(ddl_path): + with open(ddl_path, "r", encoding="utf-8") as f: + ddl_source = f.read() + print(f" DDL 文件: {ddl_path}") + else: + # 生成默认模板 + ddl_source = _default_cds_template(name, description) + print(f" DDL: 使用默认模板") + + print(f"\n → 正在创建 CDS View...") + try: + obj_uri, src_uri = client.create_cds(name, description, ddl_source) + except Exception as e: + print(f" ✗ 创建失败: {e}") + return + + print(f" ✓ CDS View 创建成功!") + print(f" ✓ URI: {obj_uri}") + + # 保存本地文件 + save_dir = getattr(args, "path", ".") + if save_dir: + if not os.path.isdir(save_dir): + os.makedirs(save_dir, exist_ok=True) + filepath = os.path.join(save_dir, f"{name.lower()}.ddl") + with open(filepath, "w", encoding="utf-8") as f: + f.write(ddl_source) + print(f" ✓ DDL 已保存: {filepath}") + + +def _default_cds_template(name: str, description: str) -> str: + """生成默认 CDS View DDL 模板。""" + view_name = name[:16].upper() + return ( + f"@AbapCatalog.sqlViewName: \'{view_name}\'\n" + f"@EndUserText.label: \'{description}\'\n" + f"define view {name.lower()}\n" + f" as select from sflight\n" + f" {{\n" + f" carrid,\n" + f" connid,\n" + f" fldate\n" + f" }}\n" + ) diff --git a/assets/sapcli/commands/config_cmd.py b/assets/sapcli/commands/config_cmd.py new file mode 100644 index 0000000..48bda27 --- /dev/null +++ b/assets/sapcli/commands/config_cmd.py @@ -0,0 +1,106 @@ +"""config 子命令:显示配置、列出 profile、设置配置项。""" +from __future__ import annotations + +import argparse +import configparser +import os + + +def cmd_config(args: argparse.Namespace, client=None) -> None: + """配置管理命令。""" + action = getattr(args, "config_action", None) + if action == "show": + _config_show(args) + elif action == "list-profiles": + _config_list_profiles(args) + elif action == "set": + _config_set(args) + else: + print(" 用法: sap-cli config [show|list-profiles|set]") + + +def _config_show(args: argparse.Namespace) -> None: + """显示当前配置。""" + from sapcli.config import load_config + + config_path = getattr(args, "config", None) + + try: + cfg, loaded_from = load_config(config_path) + except Exception as e: + print(f" ✗ 加载配置失败: {e}") + return + + print("=" * 60) + print(" sap-cli 当前配置") + print("=" * 60) + if loaded_from: + print(f" 配置文件: {loaded_from}") + print(f" 主机: {cfg.host}") + print(f" Client: {cfg.client}") + print(f" 用户: {cfg.user}") + print(f" 密码: {'***' if cfg.password else '(未设置)'}") + + +def _config_list_profiles(args: argparse.Namespace) -> None: + """列出所有可用的 profile。""" + config_path = getattr(args, "config", None) + if config_path is None: + config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "config.ini") + + print("=" * 60) + print(" sap-cli 可用 profile") + print("=" * 60) + + if not os.path.isfile(config_path): + print(" (无配置文件)") + return + + parser = configparser.ConfigParser() + parser.read(config_path, encoding="utf-8") + sections = parser.sections() + if not sections: + print(" (无 profile)") + for name in sections: + active = " (默认)" if name == "SAP" else "" + print(f" - {name}{active}") + + print(f"\n 配置文件: {config_path}") + + +def _config_set(args: argparse.Namespace) -> None: + """设置配置项到 config.ini。""" + key = getattr(args, "key", None) + value = getattr(args, "value", None) + profile = getattr(args, "profile", None) or "SAP" + + if not key or not value: + print(" ✗ 用法: sap-cli config set ") + print(" 可设置的 key: host, client, user, password") + return + + valid_keys = {"host", "client", "user", "password"} + if key not in valid_keys: + print(f" ✗ 不支持的配置项: {key}") + print(f" 可设置的 key: {', '.join(sorted(valid_keys))}") + return + + # 找到或创建 config.ini + config_path = getattr(args, "config", None) + if not config_path: + config_path = os.path.join(os.getcwd(), "config.ini") + + parser = configparser.ConfigParser() + if os.path.isfile(config_path): + parser.read(config_path, encoding="utf-8") + + if not parser.has_section(profile): + parser.add_section(profile) + + parser.set(profile, key, value) + + with open(config_path, "w", encoding="utf-8") as f: + parser.write(f) + + print(f" ✓ 已设置 [{profile}] {key} = {'***' if key == 'password' else value}") + print(f" 配置文件: {config_path}") diff --git a/assets/sapcli/commands/crud.py b/assets/sapcli/commands/crud.py new file mode 100644 index 0000000..30bddd8 --- /dev/null +++ b/assets/sapcli/commands/crud.py @@ -0,0 +1,890 @@ +"""CRUD 命令:download / sync / info / delete / create。""" +from __future__ import annotations + +import argparse +import logging +import os +import xml.etree.ElementTree as ET + +from sapcli.client import ADTClient +from sapcli.exceptions import ( + ConfigError, + CreateError, + DeleteError, + InvalidNameError, + LockError, + ObjectAlreadyExistsError, + ObjectNotFoundError, + SapCliError, +) +from sapcli.manifest import Manifest, ManifestEntry +from sapcli.types import get_type_config, parse_object_name + +DDIC_TYPES = {"domain", "dataelement", "table", "structure", "tabletype"} + +logger = logging.getLogger("sapcli.commands.crud") + +_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +LOG_DIR = os.path.join(_PROJECT_ROOT, "log") +LOG_FILE = os.path.join(LOG_DIR, "adt_tools.log") + +DEFAULT_TEMPLATES: dict[str, str] = { + "report": 'REPORT {name}.\nWRITE: / \'Hello from {name}\'.\n', + "class": ( + 'CLASS {name} DEFINITION\n' + ' PUBLIC\n' + ' FINAL\n' + ' CREATE PUBLIC.\n' + ' PUBLIC SECTION.\n' + ' METHODS: hello.\n' + 'ENDCLASS.\n' + 'CLASS {name} IMPLEMENTATION.\n' + ' METHOD hello.\n' + ' ENDMETHOD.\n' + 'ENDCLASS.\n' + ), + "function": ( + 'FUNCTION {name}\n' + ' EXPORTING\n' + ' VALUE(EV_RESULT) TYPE STRING.\n' + ' ev_result = \'hello\'.\n' + 'ENDFUNCTION.\n' + ), + "interface": ( + 'INTERFACE {name}\n' + ' PUBLIC.\n' + ' METHODS: hello.\n' + 'ENDINTERFACE.\n' + ), +} + + +def print_source_preview(source: str, max_lines: int = 20) -> None: + """打印源代码预览。""" + lines = source.splitlines() + print(f" ┌─── 源代码 (前 {max_lines} 行) ──────────────────────") + for i, line in enumerate(lines[:max_lines], 1): + print(f" │ {i:4d} | {line}") + if len(lines) > max_lines: + print(f" │ ... 省略剩余 {len(lines) - max_lines} 行 ...") + print(f" └────────────────────────────────────────────") + + +def cmd_download(args: argparse.Namespace, client: ADTClient) -> None: + """下载 SAP 对象源代码到本地文件。""" + obj_type: str = args.type + name: str = args.name + save_dir: str = args.path + + parsed = parse_object_name(name, obj_type) + type_label = get_type_config(obj_type).label + + if parsed.src_uri is None: + print(f"\n ✗ {type_label}没有源代码,不支持 download 操作") + print(f" functiongroup 是函数模块的容器,不包含可编辑的源代码文件") + raise InvalidNameError(f"{type_label}没有源代码,不支持 download 操作") + + print("=" * 60) + print(" SAP ADT 源代码下载") + print("=" * 60) + print(f" 对象类型: {type_label}") + print(f" 对象名称: {parsed.display_name}") + print(f" 保存路径: {save_dir}") + + print(f"\n → 检查对象是否存在...") + if not client.object_exists(parsed.obj_uri): + print(f" ✗ 对象不存在: {parsed.display_name}") + print(f" 请确认名称和类型是否正确") + raise ObjectNotFoundError(parsed.display_name, obj_type) + print(" ✓ 对象存在") + + print(f"\n → 正在下载源代码...") + source = client.get_source(parsed.src_uri) + source = source.replace("\r\n", "\n").replace("\r", "\n") + line_count = len(source.splitlines()) + print(f" ✓ 源代码下载成功! {len(source)} 字符, {line_count} 行") + + if not os.path.isdir(save_dir): + os.makedirs(save_dir, exist_ok=True) + + filename = f"{parsed.file_base}.abap" + filepath = os.path.join(save_dir, filename) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(source) + print(f"\n ✓ 文件已保存: {filepath}") + + print() + print_source_preview(source) + + print(f"\n ✓ 下载完成!") + print(f" 文件: {filepath}") + print(f" 大小: {len(source)} 字符, {line_count} 行") + + +def cmd_sync(args: argparse.Namespace, client: ADTClient) -> None: + """同步单个对象源代码到 SAP 并激活。""" + obj_type: str = args.type + name: str = args.name + file_path: str = args.path + + # 向后兼容:检测项目根目录(从文件路径向上查找 manifest.json) + if not hasattr(args, "project_path") or not args.project_path: + parent = os.path.dirname(os.path.abspath(file_path)) + if os.path.isfile(os.path.join(parent, "manifest.json")): + args.project_path = parent + else: + args.project_path = None + + parsed = parse_object_name(name, obj_type) + type_label = get_type_config(obj_type).label + + if parsed.src_uri is None: + print(f"\n ✗ {type_label}没有源代码,不支持 sync 操作") + print(f" functiongroup 是函数模块的容器,不包含可编辑的源代码文件") + raise InvalidNameError(f"{type_label}没有源代码,不支持 sync 操作") + + if not os.path.isfile(file_path): + print(f"\n ✗ 文件不存在: {file_path}") + raise ConfigError(f"文件不存在: {file_path}") + + with open(file_path, "r", encoding="utf-8") as f: + source = f.read() + + print("=" * 60) + print(" SAP ADT 源代码同步激活") + print("=" * 60) + print(f" 对象类型: {type_label}") + print(f" 对象名称: {parsed.display_name}") + print(f" 本地文件: {file_path}") + print(f" 源代码: {len(source)} 字符, {len(source.splitlines())} 行") + print(f" 流程: 检查/创建 → 写入 → 语法检查 → 激活") + + corr_nr_arg = getattr(args, "corr_nr", None) + success, error_msg, actual_corr_nr = _sync_single( + name, obj_type, file_path, client, corr_nr=corr_nr_arg, + ) + + if success: + # 更新清单(如果适用) + _update_manifest_after_sync(args, name, obj_type, file_path, actual_corr_nr) + return + + # 失败时也更新清单 + _update_manifest_after_sync(args, name, obj_type, file_path, actual_corr_nr, failed=True) + raise SapCliError(error_msg or "同步失败") + + +def _sync_single( + name: str, + obj_type: str, + file_path: str, + client: ADTClient, + corr_nr: str | None = None, + quiet: bool = False, +) -> tuple[bool, str | None, str | None]: + """单个对象的同步核心逻辑。 + + Args: + name: 对象名称(function 类型含 /) + obj_type: 对象类型 + file_path: 本地源代码文件路径 + client: ADT 客户端 + corr_nr: 传输请求号(可选) + quiet: 是否静默模式(批量同步时减少输出) + + Returns: + (success, error_msg, actual_corr_nr) + """ + parsed = parse_object_name(name, obj_type) + type_label = get_type_config(obj_type).label + + if parsed.src_uri is None: + return False, f"{type_label}没有源代码,不支持 sync 操作", None + + if not os.path.isfile(file_path): + return False, f"文件不存在: {file_path}", None + + with open(file_path, "r", encoding="utf-8") as f: + source = f.read() + + if not quiet: + print(f"\n → 检查对象是否存在...") + if not client.object_exists(parsed.obj_uri): + if not quiet: + print(f" ℹ 对象不存在,自动创建空对象...") + try: + client.create_object(obj_type, name, name, source=None) + if not quiet: + print(f" ✓ 空对象创建成功,进入同步流程") + except Exception as e: + return False, f"创建失败: {e}", None + else: + if not quiet: + print(" ✓ 对象存在") + + # ── Step 1: 锁定 → 写入 → 解锁 ── + if not quiet: + print("\n ── 锁定 → 写入 → 解锁 ──") + try: + if corr_nr: + lock_handle, _ = client.lock(parsed.obj_uri, corr_nr) + if not quiet: + print(f" ✓ 锁定成功(传输请求: {corr_nr})") + else: + try: + lock_handle, detected_corr_nr = client.lock(parsed.obj_uri) + if detected_corr_nr: + corr_nr = detected_corr_nr + if not quiet: + print(f" ✓ 锁定成功(对象已绑定传输请求: {corr_nr})") + else: + if not quiet: + print(f" ✓ 锁定成功(本地对象,无需传输请求)") + except LockError: + if not quiet: + print(" ℹ 对象需要传输请求号") + corr_nr = _select_transport_request(client) + if corr_nr: + lock_handle, _ = client.lock(parsed.obj_uri, corr_nr) + if not quiet: + print(f" ✓ 锁定成功(传输请求: {corr_nr})") + else: + return False, "锁定失败: 无法获取传输请求号", None + except LockError as e: + return False, str(e), None + + try: + client.set_source(parsed.src_uri, source, lock_handle, corr_nr) + if not quiet: + print(f" ✓ 源代码写入成功") + finally: + client.unlock(parsed.obj_uri, lock_handle) + if not quiet: + print(f" ✓ 解锁成功") + + # ── Step 2: 语法检查 ── + if not quiet: + print("\n ── 语法检查 ──") + try: + check_ok, check_msgs = client.syntax_check(name, parsed.obj_uri) + except Exception as e: + if not quiet: + print(f" ℹ 语法检查异常(跳过,直接激活): {e}") + check_ok = True + check_msgs = [] + + if not check_ok: + errors = [m for m in check_msgs if m["type"] == "E"] + warnings = [m for m in check_msgs if m["type"] == "W"] + if not quiet: + print(f" ✗ 语法检查未通过! {len(errors)} 个错误, {len(warnings)} 个警告") + for e in errors: + print(f" [错误] 行 {e['line']}: {e['text']}") + for w in warnings: + print(f" [警告] 行 {w['line']}: {w['text']}") + error_summary = "; ".join(f"行{e['line']}: {e['text']}" for e in errors) + return False, f"语法检查未通过: {error_summary}", corr_nr + + if not quiet: + print(" ✓ 语法检查通过") + + # ── Step 3: 激活 ── + if not quiet: + print("\n ── 激活对象 ──") + success, messages = client.activate(name, parsed.obj_uri, corr_nr) + + if success: + if not quiet: + print(f" ✓ 激活成功") + print(f"\n {'=' * 60}") + print(f" ✓ {parsed.display_name} 已成功同步并激活") + print(f" 文件: {file_path}") + print(f" 源代码: {len(source)} 字符, {len(source.splitlines())} 行") + print(f" 日志: {LOG_FILE}") + print(f" {'=' * 60}") + return True, None, corr_nr + + errors = [m for m in messages if m["type"] == "E"] + if not quiet: + warnings = [m for m in messages if m["type"] == "W"] + print(f" ✗ 激活失败! {len(errors)} 个错误, {len(warnings)} 个警告") + for e in errors: + print(f" [错误] 行 {e['line']}: {e['text']}") + for w in warnings: + print(f" [警告] 行 {w['line']}: {w['text']}") + error_summary = "; ".join(f"行{e['line']}: {e['text']}" for e in errors) + return False, f"激活失败: {error_summary}", corr_nr + + +def _update_manifest_after_sync( + args: argparse.Namespace, + name: str, + obj_type: str, + file_path: str, + corr_nr: str | None, + failed: bool = False, +) -> None: + """sync 成功/失败后尝试更新清单(如果适用)。""" + from datetime import datetime, timezone + + project_path = getattr(args, "project_path", None) + if not project_path: + return + + manifest_path = os.path.join(project_path, "manifest.json") + if not os.path.isfile(manifest_path): + return + + try: + manifest = Manifest.load(project_path) + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + + # 确定相对文件路径 + try: + rel_file = os.path.relpath(file_path, project_path).replace("\\", "/") + except ValueError: + rel_file = file_path + + entry = manifest.get(name) + if entry: + entry.corr_nr = corr_nr + entry.system_status = "active" if not failed else entry.system_status + entry.last_sync = now + entry.last_sync_result = "failed" if failed else "success" + else: + manifest.upsert(ManifestEntry( + name=name, + type=obj_type, + file=rel_file, + system_status="active" if not failed else "inactive", + corr_nr=corr_nr, + depends_on=[], + last_sync=now, + last_sync_result="failed" if failed else "success", + )) + manifest.save() + except Exception as e: + logger.warning("更新清单失败: %s", e) + + +def cmd_info(args: argparse.Namespace, client: ADTClient) -> None: + """查询对象元数据信息。""" + obj_type: str = args.type + name: str = args.name + + parsed = parse_object_name(name, obj_type) + type_label = get_type_config(obj_type).label + + INFO_ACCEPT: dict[str, str] = { + "report": "application/vnd.sap.adt.programs.programs.v2+xml", + "class": "application/vnd.sap.adt.oo.classes.v2+xml", + "interface": "application/vnd.sap.adt.oo.interfaces.v2+xml", + "function": "application/vnd.sap.adt.functions.fmodules.v2+xml", + "functiongroup": "application/vnd.sap.adt.functions.groups.v2+xml", + "domain": "*/*", + "dataelement": "*/*", + "table": "*/*", + "tabletype": "*/*", + } + + print("=" * 60) + print(" SAP ADT 对象信息查询") + print("=" * 60) + print(f" 对象类型: {type_label}") + print(f" 对象名称: {parsed.display_name}") + + print(f"\n → 正在查询对象信息...") + accept = INFO_ACCEPT.get(obj_type, "application/xml") + url = client.host + parsed.obj_uri + hdrs = client._headers("application/xml") + hdrs["Accept"] = accept + logger.info("INFO: GET %s", url) + resp = client.session.get(url, headers=hdrs) + logger.info("INFO RESPONSE: HTTP %s", resp.status_code) + + if resp.status_code == 404: + print(f" ✗ 对象不存在: {parsed.display_name}") + raise ObjectNotFoundError(parsed.display_name, obj_type) + if resp.status_code == 406: + hdrs["Accept"] = "application/xml" + resp = client.session.get(url, headers=hdrs) + if resp.status_code != 200: + print(f" ✗ 查询失败: HTTP {resp.status_code}") + print(f" {resp.text[:200]}") + raise SapCliError(f"查询失败: HTTP {resp.status_code}") + + root = ET.fromstring(resp.content) + ns = {"adtcore": "http://www.sap.com/adt/core"} + + info_name = root.attrib.get(f"{{{ns['adtcore']}}}name", "") + info_type = root.attrib.get(f"{{{ns['adtcore']}}}type", "") + info_desc = root.attrib.get(f"{{{ns['adtcore']}}}description", "") + info_version = root.attrib.get(f"{{{ns['adtcore']}}}version", "") + info_changed_at = root.attrib.get(f"{{{ns['adtcore']}}}changedAt", "") + info_changed_by = root.attrib.get(f"{{{ns['adtcore']}}}changedBy", "") + info_created_by = root.attrib.get(f"{{{ns['adtcore']}}}createdBy", "") + info_responsible = root.attrib.get(f"{{{ns['adtcore']}}}responsible", "") + info_language = root.attrib.get(f"{{{ns['adtcore']}}}masterLanguage", "") + + if info_version == "active": + status_icon = "✓ 已激活" + elif info_version == "inactive": + status_icon = "⚠️ 未激活" + else: + status_icon = f"❓ {info_version}" + + print(f" ✓ 查询成功\n") + print(f" {'─' * 50}") + print(f" 名称: {info_name}") + print(f" 类型: {info_type}") + print(f" 描述: {info_desc}") + print(f" 状态: {status_icon}") + print(f" 负责人: {info_responsible}") + print(f" 语言: {info_language}") + print(f" 创建者: {info_created_by}") + print(f" 修改者: {info_changed_by}") + print(f" 修改时间: {info_changed_at}") + print(f" URI: {parsed.obj_uri}") + print(f" {'─' * 50}") + + expected_name = parsed.display_name.upper().split("/")[-1] + returned_name = info_name.upper() + if returned_name != expected_name: + print(f"\n ✗ 名称不匹配! 请求: {expected_name}, 返回: {returned_name}") + print(f" 可能查询到了错误的对象") + raise SapCliError(f"名称不匹配! 请求: {expected_name}, 返回: {returned_name}") + + +def cmd_delete(args: argparse.Namespace, client: ADTClient) -> None: + """从 SAP 系统删除对象。""" + obj_type: str = args.type + name: str = args.name + + parsed = parse_object_name(name, obj_type) + type_label = get_type_config(obj_type).label + + print("=" * 60) + print(" SAP ADT 源代码删除") + print("=" * 60) + print(f" 对象类型: {type_label}") + print(f" 对象名称: {parsed.display_name}") + + print(f"\n → 检查对象是否存在...") + if not client.object_exists(parsed.obj_uri): + print(f" ✗ 对象不存在: {parsed.display_name}") + print(f" 请确认名称和类型是否正确") + raise ObjectNotFoundError(parsed.display_name, obj_type) + print(" ✓ 对象存在") + + print(f"\n ⚠️ 即将从 SAP 系统中删除: {parsed.display_name}") + print(f" 类型: {type_label}") + print(f" URI: {parsed.obj_uri}") + confirm = input("\n 确认删除? (输入 yes 确认): ").strip() + if confirm.lower() != "yes": + print(" 已取消删除操作") + return + + corr_nr = client.get_transport_request() + if corr_nr: + print(f" ✓ 传输请求: {corr_nr}") + else: + print(" ℹ 未找到可修改的传输请求,将尝试无传输号删除") + + print(f"\n → 正在删除对象...") + success, error = client.delete_object(parsed.obj_uri, corr_nr) + + if success: + print(f" ✓ 删除成功!") + print(f"\n ✓ {parsed.display_name} 已从 SAP 系统中删除") + + # 更新清单(如果适用) + _update_manifest_after_delete(args, name) + else: + print(f" ✗ 删除失败: {error}") + print(f"\n 可能原因:") + print(f" - 对象被其他用户锁定") + print(f" - 缺少删除权限") + print(f" - 需要传输请求号") + raise DeleteError(f"删除失败: {error}") + + +def _update_manifest_after_delete( + args: argparse.Namespace, + name: str, +) -> None: + """delete 成功后尝试从清单移除(如果适用)。""" + project_path = getattr(args, "project_path", None) + if not project_path: + return + + manifest_path = os.path.join(project_path, "manifest.json") + if not os.path.isfile(manifest_path): + return + + try: + manifest = Manifest.load(project_path) + if manifest.remove(name): + manifest.save() + logger.info("清单已更新: 移除 %s", name) + except Exception as e: + logger.warning("更新清单失败: %s", e) + + +def _select_transport_request(client: ADTClient) -> str | None: + """交互式传输请求选择:列出已有请求或新建。 + + Returns: + 选中的传输请求编号,或 None 表示无传输号创建。 + """ + print(f"\n → 查询可用的传输请求...") + try: + requests_list = client.list_transport_requests() + except Exception as e: + logger.warning("查询传输请求失败: %s", e) + print(f" ℹ 查询传输请求失败,将尝试无传输号创建") + return None + + if requests_list: + print(f" 找到 {len(requests_list)} 个可修改的传输请求:\n") + for i, req in enumerate(requests_list, 1): + desc = req.get("description", "") + owner = req.get("owner", "") + print(f" {i}. {req['number']} {desc} (所有者: {owner})") + print(f" {len(requests_list) + 1}. 新建传输请求") + print(f" 0. 不使用传输请求(本地对象)") + print() + + while True: + choice = input(" 请选择 [0-{}]: ".format(len(requests_list) + 1)).strip() + if not choice: + continue + try: + idx = int(choice) + except ValueError: + print(" ✗ 请输入数字") + continue + + if idx == 0: + print(" ℹ 将尝试无传输号创建(本地对象 $TMP)") + return None + elif 1 <= idx <= len(requests_list): + selected = requests_list[idx - 1]["number"] + print(f" ✓ 已选择传输请求: {selected}") + return selected + elif idx == len(requests_list) + 1: + # 新建传输请求 + tr_desc = input(" 请输入新传输请求描述: ").strip() + if not tr_desc: + print(" ✗ 描述不能为空,请重新选择") + continue + try: + new_nr = client.create_transport_request(tr_desc) + if new_nr: + print(f" ✓ 传输请求已创建: {new_nr}") + return new_nr + else: + print(" ✗ 创建传输请求失败(未返回编号),将尝试无传输号创建") + return None + except Exception as e: + print(f" ✗ 创建传输请求失败: {e}") + return None + else: + print(f" ✗ 请输入 0-{len(requests_list) + 1} 之间的数字") + else: + print(" ℹ 未找到可修改的传输请求") + print() + choice = input(" 是否新建传输请求? (y/n): ").strip().lower() + if choice in ("y", "yes"): + tr_desc = input(" 请输入新传输请求描述: ").strip() + if tr_desc: + try: + new_nr = client.create_transport_request(tr_desc) + if new_nr: + print(f" ✓ 传输请求已创建: {new_nr}") + return new_nr + except Exception as e: + print(f" ✗ 创建传输请求失败: {e}") + else: + print(" ✗ 描述不能为空") + print(" ℹ 将尝试无传输号创建") + return None + + +def cmd_create(args: argparse.Namespace, client: ADTClient) -> None: + """在 SAP 系统创建开发对象。""" + obj_type: str = args.type + name: str = args.name + description: str = getattr(args, "description", None) or name + source_file: str | None = getattr(args, "source", None) + definition_file: str | None = getattr(args, "definition", None) + + type_label = get_type_config(obj_type).label + + if obj_type == "function": + if "/" not in name: + print(f" ✗ function 类型需要'函数组名/函数模块名' 格式,例如: ZGROUP/Z_MY_FUNC") + raise InvalidNameError( + "function 类型需要'函数组名/函数模块名' 格式,例如: ZGROUP/Z_MY_FUNC" + ) + display_name = name + + print("=" * 60) + print(" SAP ADT 创建开发对象") + print("=" * 60) + print(f" 对象类型: {type_label}") + print(f" 对象名称: {display_name}") + print(f" 描述: {description}") + + print(f"\n → 检查对象是否存在...") + if obj_type != "functiongroup": + parsed = parse_object_name(name, obj_type) + if client.object_exists(parsed.obj_uri): + print(f" ✗ 对象已存在: {display_name}") + raise ObjectAlreadyExistsError(display_name, type_label) + else: + if client.function_group_exists(name): + print(f" ✗ 函数组已存在: {name}") + raise ObjectAlreadyExistsError(name, type_label) + print(" ✓ 对象不存在,可以创建") + + # ── 传输请求选择 ── + corr_nr = getattr(args, "corr_nr", None) + if corr_nr: + # 非交互模式:用户通过 --corr_nr 指定 + print(f" ✓ 传输请求(指定): {corr_nr}") + else: + corr_nr = _select_transport_request(client) + + if obj_type == "function": + group_name = name.split("/", 1)[0] + if not client.function_group_exists(group_name): + print(f"\n → 函数组 {group_name} 不存在,自动创建...") + try: + client.create_function_group(group_name, corr_nr=corr_nr) + print(f" ✓ 函数组 {group_name} 创建成功") + except Exception as e: + print(f" ✗ 函数组创建失败: {e}") + raise CreateError(f"函数组创建失败: {e}") from e + else: + print(f" ✓ 函数组 {group_name} 已存在") + + if obj_type == "functiongroup": + print(f"\n → 正在创建函数组...") + try: + created_uri = client.create_function_group(name, description, corr_nr) + except Exception as e: + print(f" ✗ 创建失败: {e}") + raise CreateError(str(e)) from e + print(f" ✓ 函数组创建成功") + print(f" ✓ URI: {created_uri}") + print(f"\n ✓ {name} 创建完成!") + print(f" 类型: 函数组(FUNCTION GROUP)") + print(f" 描述: {description}") + return + + if obj_type in DDIC_TYPES: + _create_ddic(args, client, obj_type, name, description, corr_nr, definition_file) + return + + if source_file: + with open(source_file, "r", encoding="utf-8") as f: + source = f.read() + else: + template_name = name.split("/", 1)[-1].upper() if obj_type == "function" else name.upper() + source = DEFAULT_TEMPLATES[obj_type].format(name=template_name) + + print(f"\n → 正在创建对象...") + try: + created_uri, created_src_uri = client.create_object( + obj_type, name, description, corr_nr, source + ) + except Exception as e: + print(f" ✗ 创建失败: {e}") + raise CreateError(str(e)) from e + + print(f" ✓ 对象创建成功!") + print(f" ✓ URI: {created_uri}") + + if source: + print(f" ✓ 源代码已写入并激活 ({len(source)} 字符)") + + print(f"\n ✓ {display_name} 创建完成!") + print(f" 类型: {type_label}") + print(f" 描述: {description}") + + # 更新清单(如果适用) + _update_manifest_after_create(args, name, obj_type, corr_nr) + + +def _update_manifest_after_create( + args: argparse.Namespace, + name: str, + obj_type: str, + corr_nr: str | None, +) -> None: + """create 成功后尝试写入清单(如果适用)。""" + from datetime import datetime, timezone + + project_path = getattr(args, "project_path", None) + if not project_path: + return + + manifest_path = os.path.join(project_path, "manifest.json") + if not os.path.isfile(manifest_path): + return + + try: + manifest = Manifest.load(project_path) + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + + # 根据类型推断文件路径 + from sapcli.scanner import DIRECTORY_TYPE_MAP + type_to_dir = {v: k for k, v in DIRECTORY_TYPE_MAP.items()} + dir_name = type_to_dir.get(obj_type, "") + + if obj_type == "function" and "/" in name: + group, func = name.split("/", 1) + rel_file = f"functions/{group.lower()}/{func.lower()}.abap" + elif dir_name: + file_base = name.split("/", 1)[-1].lower() + rel_file = f"{dir_name}/{file_base}.abap" + else: + rel_file = "" + + manifest.upsert(ManifestEntry( + name=name, + type=obj_type, + file=rel_file, + system_status="active", + corr_nr=corr_nr, + depends_on=[], + last_sync=now, + last_sync_result="success", + )) + manifest.save() + logger.info("清单已更新: 新增 %s", name) + except Exception as e: + logger.warning("更新清单失败: %s", e) + + +def _create_ddic( + args: argparse.Namespace, + client: ADTClient, + obj_type: str, + name: str, + description: str, + corr_nr: str | None, + definition_file: str | None, +) -> None: + """创建 DDIC 对象(domain/dataelement/table/structure/tabletype)。""" + from sapcli.ddic import ( + DomainDefinition, + DataElementDefinition, + TableDefinition, + StructureDefinition, + TableTypeDefinition, + TableField, + ) + + type_label = get_type_config(obj_type).label + definition_body: str | None = None + + if definition_file: + import json + with open(definition_file, "r", encoding="utf-8") as f: + data = json.load(f) + + if obj_type == "domain": + defn = DomainDefinition( + datatype=data.get("datatype", "CHAR"), + length=data.get("length", 10), + decimals=data.get("decimals", 0), + lowercase=data.get("lowercase", False), + fix_values=data.get("fix_values", []), + ) + definition_body = defn.to_xml(name.upper(), description) + elif obj_type == "dataelement": + defn = DataElementDefinition( + datatype=data.get("datatype", "CHAR"), + length=data.get("length", 10), + decimals=data.get("decimals", 0), + domain_name=data.get("domain_name", ""), + ) + definition_body = defn.to_xml(name.upper(), description) + elif obj_type == "table": + fields = [ + TableField( + name=f.get("name", ""), + type_name=f.get("type", ""), + is_key=f.get("key", False), + not_null=f.get("not_null", False), + ) + for f in data.get("fields", []) + ] + defn = TableDefinition( + fields=fields, + enhancement_category=data.get("enhancement_category", "#NOT_CLASSIFIED"), + delivery_class=data.get("delivery_class", "#A"), + data_maintenance=data.get("data_maintenance", "#LIMITED"), + table_category=data.get("table_category", "#TRANSPARENT"), + ) + definition_body = defn.to_ddl(name.lower(), description) + elif obj_type == "structure": + fields = [ + TableField( + name=f.get("name", ""), + type_name=f.get("type", ""), + is_key=f.get("key", False), + not_null=f.get("not_null", False), + ) + for f in data.get("fields", []) + ] + defn = StructureDefinition( + fields=fields, + enhancement_category=data.get("enhancement_category", "#NOT_CLASSIFIED"), + ) + definition_body = defn.to_ddl(name.lower(), description) + elif obj_type == "tabletype": + defn = TableTypeDefinition( + line_type=data.get("line_type", ""), + key_type=data.get("key_type", "#USER_DEFINED"), + access_mode=data.get("access_mode", "#STANDARD"), + ) + definition_body = defn.to_xml(name.upper(), description) + + if definition_body is None: + if obj_type == "domain": + defn = DomainDefinition() + definition_body = defn.to_xml(name.upper(), description) + elif obj_type == "dataelement": + defn = DataElementDefinition() + definition_body = defn.to_xml(name.upper(), description) + elif obj_type == "table": + defn = TableDefinition(fields=[ + TableField(name="key_field", type_name="char10", not_null=True), + ]) + definition_body = defn.to_ddl(name.lower(), description) + elif obj_type == "structure": + defn = StructureDefinition(fields=[ + TableField(name="field1", type_name="char10"), + ]) + definition_body = defn.to_ddl(name.lower(), description) + elif obj_type == "tabletype": + print(f" ✗ tabletype 需要通过 --definition 指定 line_type") + raise CreateError("tabletype 需要通过 --definition 指定 line_type") + + print(f"\n → 正在创建 DDIC 对象...") + try: + created_uri, created_src_uri = client.create_ddic_object( + obj_type, name, definition_body, corr_nr + ) + except Exception as e: + print(f" ✗ 创建失败: {e}") + raise CreateError(str(e)) from e + + print(f" ✓ DDIC 对象创建并激活成功!") + print(f" ✓ URI: {created_uri}") + print(f"\n ✓ {name} 创建完成!") + print(f" 类型: {type_label}") + print(f" 描述: {description}") diff --git a/assets/sapcli/commands/ddl_query.py b/assets/sapcli/commands/ddl_query.py new file mode 100644 index 0000000..0216dde --- /dev/null +++ b/assets/sapcli/commands/ddl_query.py @@ -0,0 +1,100 @@ +"""DDL query commands — show table fields, read table data.""" + +from __future__ import annotations + +from sapcli.cli.output import print_error, print_info + + +def cmd_show_table(args, client) -> None: + """查看 DDIC 表字段结构。""" + table_name = args.name.upper() + print_info(f"查询表 {table_name} 的字段结构...") + + fields = client.get_table_fields(table_name) + + if not fields: + print_error(f"表 {table_name} 无字段信息") + return + + print() + print("=" * 72) + print(f" 表 {table_name} — 字段结构") + print("=" * 72) + print(f" {'字段名':<20} {'类型':<6} {'长度':<6} {'Key':<5} {'描述'}") + print("-" * 72) + for f in fields: + print(f" {f['name']:<20} {f['type']:<6} {f['length']:<6} {f['key_attribute']:<5} {f['description']}") + print("-" * 72) + print(f" 共 {len(fields)} 个字段") + print() + + +def cmd_read_table(args, client) -> None: + """查询表数据(ADT freestyle SQL)。""" + table_name = args.name.upper() + max_rows = getattr(args, "max_rows", 200) + where = getattr(args, "where", None) + fields = getattr(args, "fields", None) + + select = fields if fields else "*" + sql = f"SELECT {select} FROM {table_name}" + if where: + sql += f" WHERE {where}" + sql += f" UP TO {max_rows} ROWS" + + print_info(f"执行 SQL: {sql}") + + result = client.query_table_data(sql, max_rows=max_rows) + + columns = result["columns"] + rows = result["rows"] + total = result["total_rows"] + + if not rows: + print() + print_error(f"表 {table_name} 无数据({total} 行)") + print() + return + + # 计算列宽 + col_widths = [] + for i, col in enumerate(columns): + max_w = len(col) + for row in rows: + if i < len(row): + max_w = max(max_w, min(len(str(row[i])), 40)) + col_widths.append(max_w + 2) + + # 限制总宽度 + total_width = sum(col_widths) + len(columns) + 1 + if total_width > 200: + # 截断过宽的列 + scale = 200 / total_width + col_widths = [max(int(w * scale), 6) for w in col_widths] + + sep = "+" + "+".join("-" * w for w in col_widths) + "+" + + print() + print(sep) + # 表头 + header = "|" + for i, col in enumerate(columns): + w = col_widths[i] if i < len(col_widths) else 10 + header += f" {col:<{w-1}}|" + print(header) + print(sep) + + # 数据行 + for row in rows: + line = "|" + for i, val in enumerate(row): + w = col_widths[i] if i < len(col_widths) else 10 + s = str(val)[:w-1] + line += f" {s:<{w-1}}|" + print(line) + + print(sep) + exec_time = result.get("execution_time", "") + time_info = f" ({exec_time}ms)" if exec_time else "" + print(f" {len(rows)} 行{time_info}") + print() diff --git a/assets/sapcli/commands/diff_cmd.py b/assets/sapcli/commands/diff_cmd.py new file mode 100644 index 0000000..afab586 --- /dev/null +++ b/assets/sapcli/commands/diff_cmd.py @@ -0,0 +1,89 @@ +"""代码差异对比命令:diff。""" +from __future__ import annotations + +import argparse +import difflib +import logging +import os + +from sapcli.client import ADTClient +from sapcli.types import get_type_config, parse_object_name + +logger = logging.getLogger("sapcli.commands.diff_cmd") + + +def cmd_diff(args: argparse.Namespace, client: ADTClient) -> None: + """本地 vs SAP 代码差异对比。""" + name: str = args.name + obj_type: str = args.type + local_path: str | None = getattr(args, "path", None) + + parsed = parse_object_name(name, obj_type) + type_label = get_type_config(obj_type).label + + if parsed.src_uri is None: + print(f"\n ✗ {type_label}没有源代码,不支持 diff 操作") + return + + print("=" * 60) + print(" SAP ADT 代码差异对比") + print("=" * 60) + print(f" 对象名称: {parsed.display_name}") + print(f" 对象类型: {type_label}") + + # 读取本地文件 + if local_path is None: + # 尝试自动推断文件路径 + local_path = f"{parsed.file_base}.abap" + if not os.path.isfile(local_path): + print(f"\n ✗ 本地文件不存在: {local_path}") + return + + with open(local_path, "r", encoding="utf-8") as f: + local_source = f.read() + + local_lines = local_source.splitlines(keepends=True) + print(f" 本地文件: {local_path} ({len(local_lines)} 行)") + + # 读取 SAP 端源码 + print(f"\n → 正在读取 SAP 端源码...") + try: + sap_source = client.read_source_for_diff(name, obj_type) + except Exception as e: + print(f" ✗ 读取 SAP 源码失败: {e}") + return + + sap_source = sap_source.replace("\r\n", "\n").replace("\r", "\n") + sap_lines = sap_source.splitlines(keepends=True) + print(f" ✓ SAP 源码读取成功 ({len(sap_lines)} 行)") + + # 生成 unified diff + diff_lines = list(difflib.unified_diff( + sap_lines, + local_lines, + fromfile=f"SAP:{parsed.display_name}", + tofile=f"本地:{os.path.basename(local_path)}", + lineterm="", + )) + + if not diff_lines: + print(f"\n ✓ 本地文件与 SAP 端完全一致,无差异") + return + + print(f"\n {'─' * 60}") + print(f" 差异摘要:") + added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++")) + removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---")) + print(f" 新增行: {added} | 删除行: {removed}") + print(f" {'─' * 60}\n") + + # 输出 diff + for line in diff_lines: + if line.startswith("+") and not line.startswith("+++"): + print(f" \033[32m{line}\033[0m") + elif line.startswith("-") and not line.startswith("---"): + print(f" \033[31m{line}\033[0m") + elif line.startswith("@@"): + print(f" \033[36m{line}\033[0m") + else: + print(f" {line}") diff --git a/assets/sapcli/commands/package_cmd.py b/assets/sapcli/commands/package_cmd.py new file mode 100644 index 0000000..38dd709 --- /dev/null +++ b/assets/sapcli/commands/package_cmd.py @@ -0,0 +1,107 @@ +"""包管理命令:package (create / info / list)。""" +from __future__ import annotations + +import argparse +import logging + +from sapcli.client import ADTClient + +logger = logging.getLogger("sapcli.commands.package_cmd") + + +def cmd_package(args: argparse.Namespace, client: ADTClient) -> None: + """ABAP 包操作。""" + action = getattr(args, "package_action", None) + + if action == "create": + _package_create(args, client) + elif action == "info": + _package_info(args, client) + elif action == "list": + _package_list(args, client) + else: + print(" 用法: sap-cli package [create|info|list]") + print(" create — 创建 ABAP 包") + print(" info — 查看包详情") + print(" list — 列出对象(通过 list 命令按包过滤)") + + +def _package_create(args: argparse.Namespace, client: ADTClient) -> None: + """创建 ABAP 包。""" + name: str = args.name + description: str = getattr(args, "description", None) or name + superpackage: str | None = getattr(args, "superpackage", None) + + print("=" * 60) + print(" SAP 创建 ABAP 包") + print("=" * 60) + print(f" 包名: {name}") + print(f" 描述: {description}") + if superpackage: + print(f" 上级包: {superpackage}") + + print(f"\n → 正在创建包...") + try: + success = client.create_package(name, description, superpackage) + except Exception as e: + print(f" ✗ 创建失败: {e}") + return + + if success: + print(f" ✓ 包 {name} 创建成功!") + else: + print(f" ✗ 包创建失败") + + +def _package_info(args: argparse.Namespace, client: ADTClient) -> None: + """查看包详情。""" + name: str = args.name + + print("=" * 60) + print(" SAP 包信息") + print("=" * 60) + print(f" 包名: {name}") + + print(f"\n → 正在查询...") + try: + info = client.get_package_info(name) + except Exception as e: + print(f" ✗ 查询失败: {e}") + return + + print(f"\n {'─' * 50}") + print(f" 名称: {info.get('name', '')}") + print(f" 描述: {info.get('description', '')}") + print(f" 所有者: {info.get('owner', '')}") + print(f" 上级包: {info.get('superpackage', '(无)')}") + print(f" {'─' * 50}") + + +def _package_list(args: argparse.Namespace, client: ADTClient) -> None: + """列出包中的对象。""" + name: str = args.name + + print("=" * 60) + print(" SAP 包对象列表") + print("=" * 60) + print(f" 包名: {name}") + + print(f"\n → 正在查询...") + try: + results = client.list_objects(package=name) + except Exception as e: + print(f" ✗ 查询失败: {e}") + return + + if not results: + print(" ℹ 包中没有找到对象") + return + + print(f" ✓ 找到 {len(results)} 个对象\n") + print(f" {'名称':<30s} {'类型':<12s} {'描述':<30s}") + print(f" {'─' * 30} {'─' * 12} {'─' * 30}") + for obj in results: + obj_name = obj.get("name", "")[:30] + obj_type = obj.get("type", "")[:12] + desc = obj.get("description", "")[:30] + print(f" {obj_name:<30s} {obj_type:<12s} {desc:<30s}") diff --git a/assets/sapcli/commands/program_run.py b/assets/sapcli/commands/program_run.py new file mode 100644 index 0000000..372baff --- /dev/null +++ b/assets/sapcli/commands/program_run.py @@ -0,0 +1,20 @@ +"""Program execution command — remotely run ABAP programs via ADT.""" + +from __future__ import annotations + +from sapcli.cli.output import print_info, print_success, print_error + + +def cmd_run_program(args, client) -> None: + """远程执行 ABAP 程序。""" + program_name = args.name.upper() + print_info(f"远程执行程序 {program_name}...") + + output = client.run_program(program_name) + + if output.strip(): + print() + print(output.rstrip()) + else: + print() + print_info("程序执行完成,无输出。") diff --git a/assets/sapcli/commands/quality.py b/assets/sapcli/commands/quality.py new file mode 100644 index 0000000..998f66d --- /dev/null +++ b/assets/sapcli/commands/quality.py @@ -0,0 +1,131 @@ +"""代码质量命令:check (ATC) / format (Pretty Printer)。""" +from __future__ import annotations + +import argparse +import logging + +from sapcli.client import ADTClient +from sapcli.types import get_type_config, parse_object_name + +logger = logging.getLogger("sapcli.commands.quality") + + +def cmd_check(args: argparse.Namespace, client: ADTClient) -> None: + """ATC 代码检查。""" + name: str = args.name + obj_type: str = args.type + variant: str | None = getattr(args, "variant", None) + + parsed = parse_object_name(name, obj_type) + type_label = get_type_config(obj_type).label + + print("=" * 60) + print(" SAP ATC 代码检查") + print("=" * 60) + print(f" 对象名称: {parsed.display_name}") + print(f" 对象类型: {type_label}") + if variant: + print(f" 检查变体: {variant}") + + print(f"\n → 正在执行 ATC 检查...") + try: + success, findings = client.atc_check(name, parsed.obj_uri, variant) + except Exception as e: + print(f" ✗ ATC 检查失败: {e}") + return + + if success and not findings: + print(" ✓ ATC 检查通过,无发现项") + return + + errors = [f for f in findings if f["type"] in ("E", "1")] + warnings = [f for f in findings if f["type"] in ("W", "2")] + infos = [f for f in findings if f["type"] in ("I", "3")] + + print(f"\n {'─' * 50}") + if success: + print(f" ✓ ATC 检查完成(无严重错误)") + else: + print(f" ✗ ATC 检查发现 {len(errors)} 个错误") + + print(f" 错误: {len(errors)} | 警告: {len(warnings)} | 信息: {len(infos)}") + print(f" {'─' * 50}") + + for finding in findings: + severity_icon = {"E": "✗", "1": "✗", "W": "⚠", "2": "⚠", "I": "ℹ", "3": "ℹ"}.get( + finding["type"], "?" + ) + line = finding.get("line", "?") + text = finding.get("text", "") + print(f" {severity_icon} [{finding['type']}] 行 {line}: {text}") + + +def cmd_format(args: argparse.Namespace, client: ADTClient) -> None: + """代码格式化(ABAP Pretty Printer)。""" + name: str = args.name + obj_type: str = args.type + + parsed = parse_object_name(name, obj_type) + type_label = get_type_config(obj_type).label + + if parsed.src_uri is None: + print(f"\n ✗ {type_label}没有源代码,不支持格式化操作") + return + + print("=" * 60) + print(" SAP ABAP 代码格式化") + print("=" * 60) + print(f" 对象名称: {parsed.display_name}") + print(f" 对象类型: {type_label}") + + # 读取当前源码 + print(f"\n → 正在读取源代码...") + try: + source = client.get_source(parsed.src_uri) + except Exception as e: + print(f" ✗ 读取源代码失败: {e}") + return + + print(f" ✓ 源代码读取成功 ({len(source)} 字符)") + + # 调用 Pretty Printer + print(f"\n → 正在调用 Pretty Printer...") + try: + formatted = client.pretty_print(source) + except Exception as e: + print(f" ✗ 格式化失败: {e}") + return + + if formatted == source: + print(" ℹ 代码已经是格式化的,无需修改") + return + + print(f" ✓ 格式化完成") + + # 写回 SAP + print(f"\n → 正在写回 SAP...") + try: + lock_handle, corr_nr = client.lock(parsed.obj_uri) + try: + client.set_source(parsed.src_uri, formatted, lock_handle, corr_nr) + print(f" ✓ 源代码已写回") + finally: + client.unlock(parsed.obj_uri, lock_handle) + except Exception as e: + print(f" ✗ 写回失败: {e}") + return + + # 激活 + try: + success, messages = client.activate(name, parsed.obj_uri) + if success: + print(f" ✓ 激活成功") + else: + errors = [m for m in messages if m["type"] == "E"] + print(f" ⚠ 激活返回 {len(errors)} 个错误") + for e in errors: + print(f" [错误] 行 {e['line']}: {e['text']}") + except Exception as e: + print(f" ⚠ 激活失败: {e}") + + print(f"\n ✓ 格式化操作完成!") diff --git a/assets/sapcli/commands/scaffold.py b/assets/sapcli/commands/scaffold.py new file mode 100644 index 0000000..54724e2 --- /dev/null +++ b/assets/sapcli/commands/scaffold.py @@ -0,0 +1,251 @@ +"""项目模板命令:scaffold。""" +from __future__ import annotations + +import argparse +import logging +import os + +from sapcli.client import ADTClient + +logger = logging.getLogger("sapcli.commands.scaffold") + + +def _alv_report_template(name: str, package: str) -> str: + """生成 ALV 报表模板。""" + return ( + f"REPORT {name.upper()}.\n" + f"\n" + f"TYPES: BEGIN OF ty_data,\n" + f" field1 TYPE char10,\n" + f" field2 TYPE char20,\n" + f" END OF ty_data.\n" + f"\n" + f"DATA: gt_data TYPE STANDARD TABLE OF ty_data,\n" + f" gs_data TYPE ty_data,\n" + f" go_alv TYPE REF TO cl_salv_table,\n" + f" go_msg TYPE REF TO cx_salv_msg.\n" + f"\n" + f"START-OF-SELECTION.\n" + f" PERFORM get_data.\n" + f" PERFORM display_alv.\n" + f"\n" + f"FORM get_data.\n" + f" \" TODO: 填充数据\n" + f" gs_data-field1 = '示例'.\n" + f" gs_data-field2 = '数据'.\n" + f" APPEND gs_data TO gt_data.\n" + f"ENDFORM.\n" + f"\n" + f"FORM display_alv.\n" + f" TRY.\n" + f" cl_salv_table=>factory(\n" + f" IMPORTING\n" + f" r_salv_table = go_alv\n" + f" CHANGING\n" + f" t_table = gt_data\n" + f" ).\n" + f" go_alv->display( ).\n" + f" CATCH cx_salv_msg INTO go_msg.\n" + f" MESSAGE go_msg->get_text( ) TYPE 'I'.\n" + f" ENDTRY.\n" + f"ENDFORM.\n" + ) + + +def _bapi_wrapper_template(name: str, package: str) -> str: + """生成 BAPI 包装类模板。""" + class_name = name.upper() + return ( + f"CLASS {class_name} DEFINITION\n" + f" PUBLIC\n" + f" FINAL\n" + f" CREATE PUBLIC.\n" + f"\n" + f" PUBLIC SECTION.\n" + f" TYPES: BEGIN OF ty_result,\n" + f" success TYPE abap_bool,\n" + f" message TYPE string,\n" + f" END OF ty_result.\n" + f"\n" + f" CLASS-METHODS:\n" + f" call_bapi\n" + f" IMPORTING\n" + f" iv_param1 TYPE string OPTIONAL\n" + f" RETURNING\n" + f" VALUE(rs_result) TYPE ty_result.\n" + f"\n" + f" PRIVATE SECTION.\n" + f" CLASS-METHODS:\n" + f" _call_remote_bapi\n" + f" IMPORTING\n" + f" iv_param1 TYPE string\n" + f" EXPORTING\n" + f" ev_success TYPE abap_bool\n" + f" ev_message TYPE string.\n" + f"ENDCLASS.\n" + f"\n" + f"\n" + f"CLASS {class_name} IMPLEMENTATION.\n" + f"\n" + f" METHOD call_bapi.\n" + f" _call_remote_bapi(\n" + f" EXPORTING\n" + f" iv_param1 = iv_param1\n" + f" IMPORTING\n" + f" ev_success = rs_result-success\n" + f" ev_message = rs_result-message\n" + f" ).\n" + f" ENDMETHOD.\n" + f"\n" + f" METHOD _call_remote_bapi.\n" + f" \" TODO: 调用 BAPI 函数\n" + f" ev_success = abap_true.\n" + f" ev_message = '未实现'.\n" + f" ENDMETHOD.\n" + f"\n" + f"ENDCLASS.\n" + ) + + +def _interface_class_template(name: str, package: str) -> str: + """生成接口 + 实现类模板。""" + name_upper = name.upper() + if name_upper.startswith("Z"): + if_name = f"ZIF_{name_upper[2:]}" + else: + if_name = f"ZIF_{name_upper}" + cls_name = name_upper + return ( + f"INTERFACE {if_name}\n" + f" PUBLIC.\n" + f" METHODS:\n" + f" execute\n" + f" RETURNING VALUE(rv_result) TYPE string.\n" + f"ENDINTERFACE.\n" + f"\n" + f"\n" + f"CLASS {cls_name} DEFINITION\n" + f" PUBLIC\n" + f" FINAL\n" + f" CREATE PUBLIC.\n" + f"\n" + f" PUBLIC SECTION.\n" + f" INTERFACES: {if_name}.\n" + f"\n" + f" PRIVATE SECTION.\n" + f" DATA: mv_state TYPE string.\n" + f"ENDCLASS.\n" + f"\n" + f"\n" + f"CLASS {cls_name} IMPLEMENTATION.\n" + f"\n" + f" METHOD {if_name}~execute.\n" + f" rv_result = 'Hello from {cls_name}'.\n" + f" ENDMETHOD.\n" + f"\n" + f"ENDCLASS.\n" + ) + + +def _data_model_template(name: str, package: str) -> str: + """生成数据模型模板(DDIC DDL)。""" + name_lower = name.lower() + return ( + f"@EndUserText.label: '{name.upper()} 数据模型'\n" + f"@AbapCatalog.enhancementCategory: #NOT_CLASSIFIED\n" + f"@AbapCatalog.tableCategory: #TRANSPARENT\n" + f"@AbapCatalog.deliveryClass: #A\n" + f"@AbapCatalog.dataMaintenance: #LIMITED\n" + f"define table {name_lower} {{\n" + f" key_client : abap.clnt not null;\n" + f" key_id : abap.char10 not null;\n" + f" name : abap.char40;\n" + f" created_at : timestampl;\n" + f" changed_at : timestampl;\n" + f"}}\n" + ) + + +# 模板注册表 +_TEMPLATES: dict[str, dict] = { + "alv-report": { + "desc": "ALV 报表模板", + "file_name": "{name_lower}.abap", + "dir": "reports", + "generator": _alv_report_template, + }, + "bapi-wrapper": { + "desc": "BAPI 包装类模板", + "file_name": "{name_lower}.abap", + "dir": "classes", + "generator": _bapi_wrapper_template, + }, + "interface-class": { + "desc": "接口 + 实现类模板", + "file_name": "{name_lower}.abap", + "dir": "classes", + "generator": _interface_class_template, + }, + "data-model": { + "desc": "数据模型模板 (DDIC DDL)", + "file_name": "{name_lower}.ddl", + "dir": "tables", + "generator": _data_model_template, + }, +} + + +def cmd_scaffold(args: argparse.Namespace, client: ADTClient | None = None) -> None: + """项目模板创建。""" + # 不指定模板时,列出可用模板 + template = getattr(args, "template", None) + if not template: + print("=" * 60) + print(" sap-cli 可用模板") + print("=" * 60) + for key, info in _TEMPLATES.items(): + desc = info.get("description", "") + print(f" {key:20s} {desc}") + print() + print(" 用法: sap-cli scaffold --name ZXXX --template <模板名>") + return + + name: str = args.name + package: str = getattr(args, "package", "$TMP") or "$TMP" + output_dir: str = getattr(args, "path", ".") or "." + + template_info = _TEMPLATES.get(template) + if not template_info: + print(f" ✗ 不支持的模板类型: {template}") + print(f" 可用模板: {', '.join(_TEMPLATES.keys())}") + return + + print("=" * 60) + print(" sap-cli 项目模板创建") + print("=" * 60) + print(f" 对象名称: {name}") + print(f" 模板: {template} ({template_info['desc']})") + print(f" 包: {package}") + print(f" 输出目录: {output_dir}") + + # 生成源码 + name_lower = name.lower() + source = template_info["generator"](name, package) + + # 确定输出路径 + target_dir = os.path.join(output_dir, template_info["dir"]) + if not os.path.isdir(target_dir): + os.makedirs(target_dir, exist_ok=True) + + filename = template_info["file_name"].format(name_lower=name_lower) + filepath = os.path.join(target_dir, filename) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(source) + + print(f"\n ✓ 模板已生成!") + print(f" 文件: {filepath}") + print(f" 大小: {len(source)} 字符") + print(f"\n 下一步:") + print(f" 1. 编辑 {filepath} 完善代码") + print(f" 2. 运行 sap-cli sync --name {name} --type report --path {filepath}") diff --git a/assets/sapcli/commands/search.py b/assets/sapcli/commands/search.py new file mode 100644 index 0000000..a5a2bce --- /dev/null +++ b/assets/sapcli/commands/search.py @@ -0,0 +1,147 @@ +"""搜索与浏览命令:list / whereused / search。""" +from __future__ import annotations + +import argparse +import logging + +from sapcli.client import ADTClient +from sapcli.types import get_type_config, parse_object_name + +logger = logging.getLogger("sapcli.commands.search") + +# 对象类型关键字 → ADT 类型代码映射 +_TYPE_FILTER_MAP: dict[str, str] = { + "report": "PROG/P", + "class": "CLAS/OC", + "interface": "INTF/OI", + "function": "FUNC/F", + "functiongroup": "FUGR/F", + "domain": "DOMA/DD", + "dataelement": "DTEL/DE", + "table": "TABL/TT", + "structure": "TABL/ST", + "tabletype": "TTYP", + "include": "PROG/I", + "cdsview": "DDLS/DF", + "messageclass": "MSAG", + "view": "VIEW", +} + + +def cmd_list(args: argparse.Namespace, client: ADTClient) -> None: + """列出 SAP 对象。""" + obj_type = getattr(args, "type", None) + package = getattr(args, "package", None) + prefix = getattr(args, "prefix", None) + + # 将友好类型名转换为 ADT 类型代码 + adt_type = None + if obj_type: + adt_type = _TYPE_FILTER_MAP.get(obj_type, obj_type) + + print("=" * 60) + print(" SAP ADT 对象列表") + print("=" * 60) + if obj_type: + print(f" 类型过滤: {obj_type}") + if package: + print(f" 包过滤: {package}") + if prefix: + print(f" 名称前缀: {prefix}") + + print(f"\n → 正在搜索对象...") + try: + results = client.list_objects(obj_type=adt_type, package=package, prefix=prefix) + except Exception as e: + print(f" ✗ 搜索失败: {e}") + return + + if not results: + print(" ℹ 未找到匹配的对象") + return + + print(f" ✓ 找到 {len(results)} 个对象\n") + print(f" {'名称':<30s} {'类型':<12s} {'包':<15s} {'描述':<30s}") + print(f" {'─' * 30} {'─' * 12} {'─' * 15} {'─' * 30}") + for obj in results: + name = obj.get("name", "")[:30] + otype = obj.get("type", "")[:12] + pkg = obj.get("package", "")[:15] + desc = obj.get("description", "")[:30] + print(f" {name:<30s} {otype:<12s} {pkg:<15s} {desc:<30s}") + + +def cmd_whereused(args: argparse.Namespace, client: ADTClient) -> None: + """Where-Used 引用查询。""" + name: str = args.name + obj_type: str | None = getattr(args, "type", None) + + parsed = parse_object_name(name, obj_type or "report") + type_label = get_type_config(obj_type or "report").label + + print("=" * 60) + print(" SAP ADT Where-Used 查询") + print("=" * 60) + print(f" 对象名称: {parsed.display_name}") + if obj_type: + print(f" 对象类型: {type_label}") + + print(f"\n → 正在查询引用关系...") + adt_type = _TYPE_FILTER_MAP.get(obj_type, obj_type) if obj_type else None + try: + results = client.where_used(name, parsed.obj_uri, adt_type) + except Exception as e: + print(f" ✗ 查询失败: {e}") + return + + if not results: + print(" ℹ 未找到引用关系") + return + + print(f" ✓ 找到 {len(results)} 个引用\n") + print(f" {'名称':<30s} {'类型':<12s} {'包':<15s} {'URI':<40s}") + print(f" {'─' * 30} {'─' * 12} {'─' * 15} {'─' * 40}") + for ref in results: + ref_name = ref.get("name", "")[:30] + ref_type = ref.get("type", "")[:12] + ref_pkg = ref.get("package", "")[:15] + ref_uri = ref.get("uri", "")[:40] + print(f" {ref_name:<30s} {ref_type:<12s} {ref_pkg:<15s} {ref_uri:<40s}") + + +def cmd_search(args: argparse.Namespace, client: ADTClient) -> None: + """源代码搜索。""" + query: str = args.query + obj_type: str | None = getattr(args, "type", None) + + # 将友好类型名转换为 ADT 类型代码 + adt_type = None + if obj_type: + adt_type = _TYPE_FILTER_MAP.get(obj_type, obj_type) + + print("=" * 60) + print(" SAP ADT 源代码搜索") + print("=" * 60) + print(f" 搜索关键词: {query}") + if obj_type: + print(f" 类型过滤: {obj_type}") + + print(f"\n → 正在搜索...") + try: + results = client.search_code(query, obj_type=adt_type) + except Exception as e: + print(f" ✗ 搜索失败: {e}") + return + + if not results: + print(" ℹ 未找到匹配的代码") + return + + print(f" ✓ 找到 {len(results)} 个结果\n") + print(f" {'名称':<30s} {'类型':<12s} {'描述':<40s}") + print(f" {'─' * 30} {'─' * 12} {'─' * 40}") + for obj in results: + obj_name = obj.get("name", "")[:30] + obj_type_val = obj.get("type", "")[:12] + desc = obj.get("description", "")[:40] + print(f" {obj_name:<30s} {obj_type_val:<12s} {desc:<40s}") diff --git a/assets/sapcli/commands/transport.py b/assets/sapcli/commands/transport.py new file mode 100644 index 0000000..82297ee --- /dev/null +++ b/assets/sapcli/commands/transport.py @@ -0,0 +1,136 @@ +"""传输管理命令:transport (list / info / release / objects)。""" +from __future__ import annotations + +import argparse +import logging + +from sapcli.client import ADTClient + +logger = logging.getLogger("sapcli.commands.transport") + + +def cmd_transport(args: argparse.Namespace, client: ADTClient) -> None: + """传输请求管理。""" + action = getattr(args, "transport_action", None) + + if action == "list": + _transport_list(args, client) + elif action == "info": + _transport_info(args, client) + elif action == "release": + _transport_release(args, client) + elif action == "objects": + _transport_objects(args, client) + else: + print(" 用法: sap-cli transport [list|info|release|objects]") + print(" list — 列出可修改的传输请求") + print(" info — 查看传输请求详情") + print(" release — 释放传输请求") + print(" objects — 列出传输请求中的对象") + + +def _transport_list(args: argparse.Namespace, client: ADTClient) -> None: + """列出可修改的传输请求。""" + print("=" * 60) + print(" SAP 传输请求列表") + print("=" * 60) + + print(f"\n → 正在查询传输请求...") + try: + requests_list = client.list_transport_requests() + except Exception as e: + print(f" ✗ 查询失败: {e}") + return + + if not requests_list: + print(" ℹ 未找到可修改的传输请求") + return + + print(f" ✓ 找到 {len(requests_list)} 个传输请求\n") + print(f" {'编号':<15s} {'描述':<40s} {'所有者':<15s}") + print(f" {'─' * 15} {'─' * 40} {'─' * 15}") + for req in requests_list: + num = req.get("number", "")[:15] + desc = req.get("description", "")[:40] + owner = req.get("owner", "")[:15] + print(f" {num:<15s} {desc:<40s} {owner:<15s}") + + +def _transport_info(args: argparse.Namespace, client: ADTClient) -> None: + """查看传输请求详情。""" + corr_nr: str = args.corr_nr + + print("=" * 60) + print(" SAP 传输请求详情") + print("=" * 60) + print(f" 传输请求: {corr_nr}") + + print(f"\n → 正在查询...") + try: + info = client.transport_info(corr_nr) + except Exception as e: + print(f" ✗ 查询失败: {e}") + return + + print(f"\n {'─' * 50}") + print(f" 描述: {info.get('description', '')}") + print(f" 状态: {info.get('status', '')}") + print(f" 所有者: {info.get('owner', '')}") + print(f" {'─' * 50}") + + +def _transport_release(args: argparse.Namespace, client: ADTClient) -> None: + """释放传输请求。""" + corr_nr: str = args.corr_nr + + print("=" * 60) + print(" SAP 释放传输请求") + print("=" * 60) + print(f" 传输请求: {corr_nr}") + + print(f"\n ⚠️ 即将释放传输请求: {corr_nr}") + confirm = input(" 确认释放? (输入 yes 确认): ").strip() + if confirm.lower() != "yes": + print(" 已取消释放操作") + return + + print(f"\n → 正在释放...") + try: + success = client.transport_release(corr_nr) + except Exception as e: + print(f" ✗ 释放失败: {e}") + return + + if success: + print(f" ✓ 传输请求 {corr_nr} 已成功释放!") + else: + print(f" ✗ 释放失败,请检查传输请求状态") + + +def _transport_objects(args: argparse.Namespace, client: ADTClient) -> None: + """列出传输请求中的对象。""" + corr_nr: str = args.corr_nr + + print("=" * 60) + print(" SAP 传输请求对象列表") + print("=" * 60) + print(f" 传输请求: {corr_nr}") + + print(f"\n → 正在查询...") + try: + objects = client.transport_objects(corr_nr) + except Exception as e: + print(f" ✗ 查询失败: {e}") + return + + if not objects: + print(" ℹ 传输请求中无对象") + return + + print(f" ✓ 找到 {len(objects)} 个对象\n") + print(f" {'名称':<30s} {'类型':<15s}") + print(f" {'─' * 30} {'─' * 15}") + for obj in objects: + obj_name = obj.get("name", "")[:30] + obj_type = obj.get("type", "")[:15] + print(f" {obj_name:<30s} {obj_type:<15s}") diff --git a/assets/sapcli/config.py b/assets/sapcli/config.py new file mode 100644 index 0000000..d5b3a23 --- /dev/null +++ b/assets/sapcli/config.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import configparser +import os +from dataclasses import dataclass + +from sapcli.exceptions import ConfigError + + +@dataclass(frozen=True) +class SAPConfig: + host: str + client: str + user: str + password: str + + def __post_init__(self): + missing = [] + if not self.host: + missing.append("host") + if not self.user: + missing.append("user") + if not self.password: + missing.append("password") + if missing: + raise ConfigError( + f"配置缺失: {', '.join(missing)}. " + f"请设置环境变量或配置文件 (config.ini)" + ) + + +def list_profiles(config_path: str | None = None) -> list[str]: + """列出配置文件中所有可用的 profile(section 名称)。""" + cfg = configparser.ConfigParser() + path = config_path or _find_config_file() + if path: + cfg.read(path, encoding="utf-8") + return cfg.sections() + + +def _find_config_file() -> str | None: + """按搜索路径查找 config.ini。""" + search_paths: list[str] = [] + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(script_dir) + search_paths.append(os.path.join(os.getcwd(), "config.ini")) + search_paths.append(os.path.join(project_root, "config.ini")) + for p in search_paths: + if os.path.isfile(p): + return p + return None + + +def load_config( + config_path: str | None = None, + profile: str | None = None, +) -> tuple[SAPConfig, str | None]: + cfg = configparser.ConfigParser() + search_paths: list[str] = [] + + if config_path: + search_paths.append(config_path) + + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(script_dir) + search_paths.append(os.path.join(os.getcwd(), "config.ini")) + search_paths.append(os.path.join(project_root, "config.ini")) + + loaded_from = None + for p in search_paths: + if os.path.isfile(p): + cfg.read(p, encoding="utf-8") + loaded_from = p + break + + # profile 参数决定读哪个 section,默认 SAP + section = profile or "SAP" + sap = cfg[section] if cfg.has_section(section) else {} + + host = os.environ.get("SAP_HOST", sap.get("host", "")) + client = os.environ.get("SAP_CLIENT", sap.get("client", "100")) + user = os.environ.get("SAP_USER", sap.get("user", "")) + config_password = sap.get("password", "") + env_password = os.environ.get("SAP_PASSWORD", "") + + # 密码优先级: 环境变量 > keyring > config.ini + from sapcli.password import resolve_password + password = resolve_password(host, client, user, config_password, env_password) + + return SAPConfig( + host=host, + client=client, + user=user, + password=password, + ), loaded_from diff --git a/assets/sapcli/ddic.py b/assets/sapcli/ddic.py new file mode 100644 index 0000000..5791e88 --- /dev/null +++ b/assets/sapcli/ddic.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from sapcli.utils.xml_utils import ddl_escape as _ddl_escape +from sapcli.utils.xml_utils import xml_escape as _xml_escape + + +@dataclass +class DomainDefinition: + datatype: str = "CHAR" + length: int = 10 + decimals: int = 0 + lowercase: bool = False + fix_values: list[dict[str, str]] = field(default_factory=list) + + def to_xml(self, name: str, description: str, package: str = "$TMP") -> str: + ns = ( + 'xmlns:doma="http://www.sap.com/dictionary/domain"' + ' xmlns:adtcore="http://www.sap.com/adt/core"' + ) + header = ( + f'' + f'' + ) + type_info = ( + "" + "" + f"{self.datatype}" + f"{self.length:06d}" + f"{self.decimals:06d}" + "" + "" + f"{self.length:06d}" + "00" + "" + f"false" + f"{'true' if self.lowercase else 'false'}" + "false" + "" + ) + fix_xml = "" + if self.fix_values: + fix_xml = "false" + for i, fv in enumerate(self.fix_values, 1): + low = fv.get("low", "") + high = fv.get("high", "") + text = fv.get("text", "") + fix_xml += ( + f"" + f"{i:04d}" + f"{low}" + f"{high}" + f"{text}" + f"" + ) + fix_xml += "" + + return f"{header}{type_info}{fix_xml}" + + +@dataclass +class DataElementDefinition: + datatype: str = "CHAR" + length: int = 10 + decimals: int = 0 + domain_name: str = "" + + def to_xml(self, name: str, description: str, package: str = "$TMP") -> str: + ns = ( + 'xmlns:blue="http://www.sap.com/wbobj/dictionary/dtel"' + ' xmlns:adtcore="http://www.sap.com/adt/core"' + ) + header = ( + f'' + f'' + ) + type_block = ( + '' + ) + if self.domain_name: + type_block += ( + f"domain" + f"{self.domain_name}" + "" + "" + "" + ) + else: + type_block += ( + "predefinedAbapType" + "" + f"{self.datatype}" + f"{self.length:06d}" + f"{self.decimals:06d}" + ) + type_block += ( + "" + "00" + "10" + "" + "00" + "20" + "" + "00" + "40" + "" + "00" + "55" + "" + "" + "" + "" + "false" + "false" + "false" + "false" + "" + ) + return f"{header}{type_block}" + + +@dataclass +class TableField: + name: str + type_name: str + is_key: bool = False + not_null: bool = False + + def to_ddl(self) -> str: + parts = [f" {self.name:40s}: {self.type_name}"] + if self.not_null: + parts.append("not null") + return " ".join(parts) + ";" + + +@dataclass +class TableDefinition: + fields: list[TableField] = field(default_factory=list) + label: str = "" + enhancement_category: str = "#NOT_CLASSIFIED" + delivery_class: str = "#A" + data_maintenance: str = "#LIMITED" + table_category: str = "#TRANSPARENT" + + def to_ddl(self, name: str, description: str) -> str: + lines = [] + lines.append(f"@EndUserText.label : '{_ddl_escape(description)}'") + lines.append(f"@AbapCatalog.enhancementCategory : {self.enhancement_category}") + lines.append(f"@AbapCatalog.tableCategory : {self.table_category}") + lines.append(f"@AbapCatalog.deliveryClass : {self.delivery_class}") + lines.append(f"@AbapCatalog.dataMaintenance : {self.data_maintenance}") + lines.append(f"define table {name.lower()} {{") + for f in self.fields: + lines.append(f.to_ddl()) + lines.append("}") + return "\n".join(lines) + + +@dataclass +class StructureDefinition: + fields: list[TableField] = field(default_factory=list) + enhancement_category: str = "#NOT_CLASSIFIED" + + def to_ddl(self, name: str, description: str) -> str: + lines = [] + lines.append(f"@EndUserText.label : '{_ddl_escape(description)}'") + lines.append(f"@AbapCatalog.enhancementCategory : {self.enhancement_category}") + lines.append(f"define structure {name.lower()} {{") + for f in self.fields: + lines.append(f.to_ddl()) + lines.append("}") + return "\n".join(lines) + + +@dataclass +class TableTypeDefinition: + line_type: str = "" + key_type: str = "#USER_DEFINED" + access_mode: str = "#STANDARD" + key_fields: list[str] = field(default_factory=list) + + def to_xml(self, name: str, description: str, package: str = "$TMP") -> str: + ns = ( + 'xmlns:ttyp="http://www.sap.com/adt/dictionary/tabletypes"' + ' xmlns:adtcore="http://www.sap.com/adt/core"' + ) + header = ( + f'' + f'' + ) + body = ( + "" + "" + f"{self.access_mode}" + "" + "" + f"{self.key_type}" + "" + "" + f"structured" + f"{self.line_type}" + "" + "" + ) + return f"{header}{body}" + + +def load_definition_from_json(path: str, obj_type: str) -> str | None: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return build_definition(data, obj_type) + + +def build_definition(data: dict, obj_type: str) -> str | None: + if obj_type == "domain": + return _build_domain(data) + elif obj_type == "dataelement": + return _build_dataelement(data) + elif obj_type in ("table", "structure"): + return None + elif obj_type == "tabletype": + return _build_tabletype(data) + return None + + +def _build_domain(data: dict) -> str: + defn = DomainDefinition( + datatype=data.get("datatype", "CHAR"), + length=data.get("length", 10), + decimals=data.get("decimals", 0), + lowercase=data.get("lowercase", False), + fix_values=data.get("fix_values", []), + ) + name = data.get("name", "ZDOMAIN") + description = data.get("description", name) + package = data.get("package", "$TMP") + return defn.to_xml(name, description, package) + + +def _build_dataelement(data: dict) -> str: + defn = DataElementDefinition( + datatype=data.get("datatype", "CHAR"), + length=data.get("length", 10), + decimals=data.get("decimals", 0), + domain_name=data.get("domain_name", ""), + ) + name = data.get("name", "ZDATAELEMENT") + description = data.get("description", name) + package = data.get("package", "$TMP") + return defn.to_xml(name, description, package) + + +def _build_tabletype(data: dict) -> str: + defn = TableTypeDefinition( + line_type=data.get("line_type", ""), + key_type=data.get("key_type", "#USER_DEFINED"), + access_mode=data.get("access_mode", "#STANDARD"), + key_fields=data.get("key_fields", []), + ) + name = data.get("name", "ZTABLETYPE") + description = data.get("description", name) + package = data.get("package", "$TMP") + return defn.to_xml(name, description, package) diff --git a/assets/sapcli/exceptions.py b/assets/sapcli/exceptions.py new file mode 100644 index 0000000..fc46896 --- /dev/null +++ b/assets/sapcli/exceptions.py @@ -0,0 +1,77 @@ +from __future__ import annotations + + +class SapCliError(Exception): + """基础异常""" + + +class ConfigError(SapCliError): + """配置错误(缺失参数、文件不存在等)""" + + +class LoginError(SapCliError): + """登录失败""" + + +class ObjectNotFoundError(SapCliError): + """对象不存在""" + + def __init__(self, name: str, obj_type: str): + self.name = name + self.obj_type = obj_type + super().__init__(f"{obj_type} 对象不存在: {name}") + + +class ObjectAlreadyExistsError(SapCliError): + """对象已存在""" + + def __init__(self, name: str, obj_type: str): + self.name = name + self.obj_type = obj_type + super().__init__(f"{obj_type} 对象已存在: {name}") + + +class LockError(SapCliError): + """锁定失败""" + + def __init__(self, message: str, obj_uri: str = ""): + self.obj_uri = obj_uri + super().__init__(message) + + +class ActivationError(SapCliError): + """激活失败""" + + def __init__(self, message: str, errors: list[dict] | None = None): + self.errors = errors or [] + super().__init__(message) + + +class SyntaxCheckError(SapCliError): + """语法检查未通过""" + + def __init__(self, message: str, errors: list[dict] | None = None, warnings: list[dict] | None = None): + self.errors = errors or [] + self.warnings = warnings or [] + super().__init__(message) + + +class DeleteError(SapCliError): + """删除失败""" + + +class CreateError(SapCliError): + """创建失败""" + + +class InvalidNameError(SapCliError): + """对象名称格式错误""" + + +class CyclicDependencyError(SapCliError): + """循环依赖(拓扑排序检测)""" + + def __init__(self, cycle_members: list[str]): + self.cycle_members = cycle_members + members = " → ".join(cycle_members) + super().__init__(f"检测到循环依赖: {members}") diff --git a/assets/sapcli/manifest.py b/assets/sapcli/manifest.py new file mode 100644 index 0000000..a20dc02 --- /dev/null +++ b/assets/sapcli/manifest.py @@ -0,0 +1,174 @@ +"""清单文件 (manifest.json) 管理模块。 + +负责清单的读取、写入、查询与更新。 +""" +from __future__ import annotations + +import json +import logging +import os +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +logger = logging.getLogger("sapcli.manifest") + +MANIFEST_FILENAME = "manifest.json" +CURRENT_VERSION = 1 + + +@dataclass +class ManifestEntry: + """单条清单记录。""" + + name: str + type: str + file: str + system_status: str = "not_exists" # "active" / "inactive" / "not_exists" + corr_nr: str | None = None + depends_on: list[str] = field(default_factory=list) + last_sync: str | None = None # ISO timestamp + last_sync_result: str = "pending" # "success" / "failed" / "pending" / "skipped" + + def to_dict(self) -> dict[str, Any]: + return { + "type": self.type, + "file": self.file, + "system_status": self.system_status, + "corr_nr": self.corr_nr, + "depends_on": self.depends_on, + "last_sync": self.last_sync, + "last_sync_result": self.last_sync_result, + } + + @classmethod + def from_dict(cls, name: str, data: dict[str, Any]) -> ManifestEntry: + return cls( + name=name, + type=data.get("type", ""), + file=data.get("file", ""), + system_status=data.get("system_status", "not_exists"), + corr_nr=data.get("corr_nr"), + depends_on=data.get("depends_on", []), + last_sync=data.get("last_sync"), + last_sync_result=data.get("last_sync_result", "pending"), + ) + + +@dataclass +class Manifest: + """清单容器。""" + + version: int = CURRENT_VERSION + last_init: str | None = None + last_refresh: str | None = None + objects: dict[str, ManifestEntry] = field(default_factory=dict) + _project_path: str = field(default="", repr=False) + + # ── 持久化 ── + + @classmethod + def load(cls, project_path: str) -> Manifest: + """从项目目录下的 manifest.json 加载清单。""" + filepath = os.path.join(project_path, MANIFEST_FILENAME) + if not os.path.isfile(filepath): + raise FileNotFoundError( + f"清单文件不存在: {filepath}\n 请先执行 init 命令初始化项目" + ) + + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + + version = data.get("version", 1) + if version != CURRENT_VERSION: + logger.warning( + "清单版本不匹配: 期望 %d, 实际 %d,建议重新 init", + CURRENT_VERSION, + version, + ) + + objects: dict[str, ManifestEntry] = {} + for name, obj_data in data.get("objects", {}).items(): + objects[name] = ManifestEntry.from_dict(name, obj_data) + + manifest = cls( + version=version, + last_init=data.get("last_init"), + last_refresh=data.get("last_refresh"), + objects=objects, + _project_path=project_path, + ) + logger.info("清单已加载: %d 个对象 (%s)", len(objects), filepath) + return manifest + + def save(self) -> None: + """将清单写回 manifest.json。""" + filepath = os.path.join(self._project_path, MANIFEST_FILENAME) + data = { + "version": self.version, + "last_init": self.last_init, + "last_refresh": self.last_refresh, + "objects": { + name: entry.to_dict() + for name, entry in self.objects.items() + }, + } + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + logger.info("清单已保存: %s", filepath) + + # ── CRUD ── + + def upsert(self, entry: ManifestEntry) -> None: + """新增或更新条目。""" + self.objects[entry.name] = entry + + def remove(self, name: str) -> bool: + """移除条目。返回是否实际移除了。""" + if name in self.objects: + del self.objects[name] + return True + return False + + def get(self, name: str) -> ManifestEntry | None: + """按名称获取条目。""" + return self.objects.get(name) + + # ── 查询 ── + + def pending_objects(self) -> list[ManifestEntry]: + """返回需要处理的对象列表(非 active+success 的对象)。""" + results: list[ManifestEntry] = [] + for entry in self.objects.values(): + if entry.system_status == "active" and entry.last_sync_result == "success": + continue + results.append(entry) + return results + + def active_up_to_date_objects(self) -> list[ManifestEntry]: + """返回已同步且最新激活的对象列表。""" + results: list[ManifestEntry] = [] + for entry in self.objects.values(): + if entry.system_status == "active" and entry.last_sync_result == "success": + results.append(entry) + return results + + # ── 工具 ── + + def file_path(self, entry: ManifestEntry) -> str: + """返回条目对应文件的绝对路径。""" + # entry.file 使用正斜杠,需要适配 os.path.join + rel = entry.file.replace("/", os.sep) + return os.path.join(self._project_path, rel) + + +def init_manifest(project_path: str) -> Manifest: + """创建一个新的空清单(不保存)。""" + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + return Manifest( + version=CURRENT_VERSION, + last_init=now, + last_refresh=None, + objects={}, + _project_path=project_path, + ) diff --git a/assets/sapcli/password.py b/assets/sapcli/password.py new file mode 100644 index 0000000..be2036e --- /dev/null +++ b/assets/sapcli/password.py @@ -0,0 +1,76 @@ +"""密码解析模块(独立于 auth / config,避免循环 import)。 + +按优先级解析密码: 环境变量 > keyring > config.ini。 +""" +from __future__ import annotations + +import logging + +logger = logging.getLogger("sapcli.password") + +# keyring 可用性标志 +_KEYRING_AVAILABLE = False +_keyring = None +_keyring_mod = None + +try: + import keyring as _keyring_mod + _keyring = _keyring_mod + _KEYRING_AVAILABLE = True +except ImportError: + logger.debug("keyring 库不可用,密码将仅从 config.ini 读取") + + +def _service_name(host: str, client: str) -> str: + """构建 keyring 服务名。""" + return f"sap-cli:{host}:{client}" + + +def get_password(host: str, client: str, user: str) -> str | None: + """从 keyring 获取密码。 + + Args: + host: SAP 主机地址 + client: SAP client 号 + user: 用户名 + + Returns: + 密码字符串,如果 keyring 不可用则返回 None + """ + if not _KEYRING_AVAILABLE: + return None + try: + return _keyring.get_password(_service_name(host, client), user) + except Exception as e: + logger.warning("从 keyring 读取密码失败: %s", e) + return None + + +def resolve_password( + host: str, + client: str, + user: str, + config_password: str = "", + env_password: str = "", +) -> str: + """按优先级解析密码: 环境变量 > keyring > config.ini。 + + Args: + host: SAP 主机地址 + client: SAP client 号 + user: 用户名 + config_password: 配置文件中的密码 + env_password: 环境变量中的密码 + + Returns: + 最终使用的密码 + """ + # 环境变量优先 + if env_password: + return env_password + # keyring 其次 + kr_password = get_password(host, client, user) + if kr_password: + return kr_password + # config.ini 兜底 + return config_password diff --git a/assets/sapcli/scanner.py b/assets/sapcli/scanner.py new file mode 100644 index 0000000..75a7683 --- /dev/null +++ b/assets/sapcli/scanner.py @@ -0,0 +1,121 @@ +"""目录扫描模块。 + +扫描项目根目录下按约定的子目录结构,识别 ABAP 开发对象。 +""" +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass + +logger = logging.getLogger("sapcli.scanner") + +DIRECTORY_TYPE_MAP: dict[str, str] = { + "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_DIR = "functions" + + +@dataclass +class ScannedObject: + """扫描到的对象。""" + + name: str # 大写对象名,function 类型含 / (GROUP/FUNC) + type: str # 对象类型 + file: str # 相对于项目根目录的路径 + + +def scan_project(project_path: str) -> list[ScannedObject]: + """扫描项目目录,返回识别到的所有对象列表。 + + 遍历已知目录名,每个目录下找 .abap 文件。 + functions/ 特殊处理:子目录 = 函数组名。 + """ + results: list[ScannedObject] = [] + + for dir_name, obj_type in DIRECTORY_TYPE_MAP.items(): + dir_path = os.path.join(project_path, dir_name) + if not os.path.isdir(dir_path): + logger.debug("目录不存在,跳过: %s", dir_path) + continue + + if dir_name == FUNCTIONS_DIR: + _scan_functions(dir_path, results) + else: + _scan_flat_directory(dir_path, dir_name, obj_type, results) + + logger.info("扫描完成: %d 个对象", len(results)) + return results + + +def _scan_flat_directory( + dir_path: str, + dir_name: str, + obj_type: str, + results: list[ScannedObject], +) -> None: + """扫描普通目录(一对一:文件名 = 对象名)。""" + for filename in sorted(os.listdir(dir_path)): + if not filename.endswith(".abap"): + continue + # 隐藏文件跳过 + if filename.startswith("."): + continue + + obj_name = filename[:-5].upper() # 去掉 .abap,转大写 + rel_path = f"{dir_name}/{filename}" + + results.append(ScannedObject( + name=obj_name, + type=obj_type, + file=rel_path, + )) + logger.debug("扫描到: %s (%s) → %s", obj_name, obj_type, rel_path) + + +def _scan_functions(dir_path: str, results: list[ScannedObject]) -> None: + """扫描 functions/ 目录(特殊处理:子目录 = 函数组名)。 + + 目录结构: functions/{组名小写}/{模块名小写}.abap + 对象名: {组名大写}/{模块名大写} + """ + for group_dir_name in sorted(os.listdir(dir_path)): + group_dir = os.path.join(dir_path, group_dir_name) + if not os.path.isdir(group_dir): + continue + if group_dir_name.startswith("."): + continue + + group_name = group_dir_name.upper() + + for filename in sorted(os.listdir(group_dir)): + if not filename.endswith(".abap"): + continue + if filename.startswith("."): + continue + + func_name = filename[:-5].upper() + obj_name = f"{group_name}/{func_name}" + rel_path = f"{FUNCTIONS_DIR}/{group_dir_name}/{filename}" + + results.append(ScannedObject( + name=obj_name, + type="function", + file=rel_path, + )) + logger.debug("扫描到: %s (function) → %s", obj_name, rel_path) diff --git a/assets/sapcli/sorter.py b/assets/sapcli/sorter.py new file mode 100644 index 0000000..93957a5 --- /dev/null +++ b/assets/sapcli/sorter.py @@ -0,0 +1,152 @@ +"""依赖排序模块。 + +使用 Kahn 算法对清单对象进行拓扑排序, +按 depends_on 显式依赖和 TYPE_PRIORITY 类型优先级决定执行顺序。 +""" +from __future__ import annotations + +import logging +from collections import deque + +from sapcli.exceptions import CyclicDependencyError +from sapcli.manifest import ManifestEntry + +logger = logging.getLogger("sapcli.sorter") + +# 类型默认优先级(数值越小越先执行) +TYPE_PRIORITY: dict[str, int] = { + "domain": 10, + "dataelement": 20, + "structure": 25, + "table": 30, + "tabletype": 40, + "view": 42, + "lockobject": 44, + "searchhelp": 46, + "messageclass": 48, + "interface": 50, + "class": 60, + "functiongroup": 70, + "function": 75, + "include": 78, + "cdsview": 79, + "report": 80, +} + +# 未注册类型的默认优先级 +_DEFAULT_PRIORITY = 90 + + +def topological_sort(objects: list[ManifestEntry]) -> list[ManifestEntry]: + """对清单对象进行拓扑排序。 + + 1. 按 depends_on 建有向边(被依赖 → 当前对象) + 2. 无入边的节点按 TYPE_PRIORITY 排序作为 tie-breaker + 3. 检测循环依赖 → 抛出 CyclicDependencyError + + Returns: + 排序后的对象列表(依赖在前,被依赖在后...不,应该是依赖在前,被依赖的对象先执行)。 + """ + if not objects: + return [] + + name_map: dict[str, ManifestEntry] = {obj.name: obj for obj in objects} + names = set(name_map.keys()) + + # ── 建图:计算入度 ── + in_degree: dict[str, int] = {name: 0 for name in names} + # adj[A] = [B, C, ...] 表示 A 完成后可以释放 B、C 的入度 + adj: dict[str, list[str]] = {name: [] for name in names} + + for obj in objects: + for dep in obj.depends_on: + if dep not in names: + # 依赖不在当前列表中,忽略(外部依赖) + logger.debug("忽略外部依赖: %s → %s", obj.name, dep) + continue + # dep → obj(dep 先执行,obj 后执行) + adj[dep].append(obj.name) + in_degree[obj.name] += 1 + + # ── Kahn 算法 ── + # 初始化:入度为 0 的节点 + ready: list[str] = [ + name for name, deg in in_degree.items() if deg == 0 + ] + # 按 TYPE_PRIORITY 排序 + ready.sort(key=lambda n: _priority(n_map=name_map, n=n)) + + queue: deque[str] = deque(ready) + sorted_names: list[str] = [] + + while queue: + # 取出优先级最高(数值最小)的节点 + # 由于 deque 不支持按优先级取,先排序再取 + # 实际实现:每次从 ready 列表中取第一个 + current = queue.popleft() + sorted_names.append(current) + + # 释放后续节点 + newly_ready: list[str] = [] + for neighbor in adj[current]: + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + newly_ready.append(neighbor) + + # 新就绪的节点按优先级排序后加入队列 + newly_ready.sort(key=lambda n: _priority(n_map=name_map, n=n)) + queue.extend(newly_ready) + + # ── 循环检测 ── + if len(sorted_names) != len(names): + remaining = names - set(sorted_names) + # 尝试找出循环链 + cycle = _detect_cycle(remaining, adj, in_degree) + raise CyclicDependencyError(cycle) + + result = [name_map[n] for n in sorted_names] + logger.info("拓扑排序完成: %d 个对象", len(result)) + return result + + +def _priority(*, n_map: dict[str, ManifestEntry], n: str) -> int: + """获取节点优先级数值。""" + obj_type = n_map[n].type if n in n_map else "" + return TYPE_PRIORITY.get(obj_type, _DEFAULT_PRIORITY) + + +def _detect_cycle( + remaining: set[str], + adj: dict[str, list[str]], + in_degree: dict[str, int], +) -> list[str]: + """尝试在剩余节点中检测循环,返回循环中的节点名列表。""" + # 沿着入度 > 0 的边走,迟早会回到已访问的节点 + if not remaining: + return [] + + visited: set[str] = set() + path: list[str] = [] + start = next(iter(remaining)) + + current = start + for _ in range(len(remaining) + 1): + if current in visited: + # 找到循环起点 + idx = path.index(current) + return path[idx:] + visited.add(current) + path.append(current) + + # 找下一个仍在 remaining 中的后继 + found_next = False + for neighbor in adj.get(current, []): + if neighbor in remaining: + current = neighbor + found_next = True + break + if not found_next: + break + + # 无法确定精确循环链,返回所有剩余节点 + return list(remaining) diff --git a/assets/sapcli/types.py b/assets/sapcli/types.py new file mode 100644 index 0000000..dc3d8e3 --- /dev/null +++ b/assets/sapcli/types.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sapcli.exceptions import InvalidNameError + + +@dataclass(frozen=True) +class ObjectTypeConfig: + key: str + label: str + obj_uri_template: str + src_uri_template: str | None + collection_uri: str + create_content_type: str + has_source: bool = True + + def format_obj_uri(self, name: str, **kwargs: str) -> str: + return self.obj_uri_template.format(name=name, **kwargs) + + def format_src_uri(self, name: str, **kwargs: str) -> str | None: + if self.src_uri_template is None: + return None + return self.src_uri_template.format(name=name, **kwargs) + + def format_collection_uri(self, **kwargs: str) -> str: + return self.collection_uri.format(**kwargs) + + +_OBJECT_TYPE_REGISTRY: dict[str, ObjectTypeConfig] = {} + + +def _register(cfg: ObjectTypeConfig) -> ObjectTypeConfig: + _OBJECT_TYPE_REGISTRY[cfg.key] = cfg + return cfg + + +REPORT = _register(ObjectTypeConfig( + key="report", + label="程序(REPORT)", + 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.programs.v2+xml", +)) + +CLASS = _register(ObjectTypeConfig( + key="class", + label="类(CLASS)", + obj_uri_template="/sap/bc/adt/oo/classes/{name}", + src_uri_template="/sap/bc/adt/oo/classes/{name}/source/main", + collection_uri="/sap/bc/adt/oo/classes", + create_content_type="application/vnd.sap.adt.oo.classes.v1+xml", +)) + +FUNCTION = _register(ObjectTypeConfig( + key="function", + label="函数模块(FUNCTION)", + obj_uri_template="/sap/bc/adt/functions/groups/{group}/fmodules/{name}", + src_uri_template="/sap/bc/adt/functions/groups/{group}/fmodules/{name}/source/main", + collection_uri="/sap/bc/adt/functions/groups/{group}/fmodules", + create_content_type="application/vnd.sap.adt.functions.fmodules.v1+xml", +)) + +FUNCTIONGROUP = _register(ObjectTypeConfig( + key="functiongroup", + label="函数组(FUNCTION GROUP)", + obj_uri_template="/sap/bc/adt/functions/groups/{name}", + src_uri_template=None, + collection_uri="/sap/bc/adt/functions/groups", + create_content_type="application/vnd.sap.adt.functions.groups.v2+xml", + has_source=False, +)) + +INTERFACE = _register(ObjectTypeConfig( + key="interface", + label="接口(INTERFACE)", + obj_uri_template="/sap/bc/adt/oo/interfaces/{name}", + src_uri_template="/sap/bc/adt/oo/interfaces/{name}/source/main", + collection_uri="/sap/bc/adt/oo/interfaces", + create_content_type="application/vnd.sap.adt.oo.interfaces.v1+xml", +)) + +DOMAIN = _register(ObjectTypeConfig( + key="domain", + label="域(DOMAIN)", + obj_uri_template="/sap/bc/adt/ddic/domains/{name}", + src_uri_template="/sap/bc/adt/ddic/domains/{name}/source/main", + collection_uri="/sap/bc/adt/ddic/domains", + create_content_type="application/vnd.sap.adt.domains.v2+xml", +)) + +DATAELEMENT = _register(ObjectTypeConfig( + key="dataelement", + label="数据元素(DATA ELEMENT)", + obj_uri_template="/sap/bc/adt/ddic/dataelements/{name}", + src_uri_template="/sap/bc/adt/ddic/dataelements/{name}/source/main", + collection_uri="/sap/bc/adt/ddic/dataelements", + create_content_type="application/vnd.sap.adt.dataelements.v2+xml", +)) + +TABLE = _register(ObjectTypeConfig( + key="table", + label="透明表(TABLE)", + obj_uri_template="/sap/bc/adt/ddic/tables/{name}", + src_uri_template="/sap/bc/adt/ddic/tables/{name}/source/main", + collection_uri="/sap/bc/adt/ddic/tables", + create_content_type="application/vnd.sap.adt.ddic.tables.v1+xml", +)) + +STRUCTURE = _register(ObjectTypeConfig( + key="structure", + label="结构(STRUCTURE)", + obj_uri_template="/sap/bc/adt/ddic/structures/{name}", + src_uri_template="/sap/bc/adt/ddic/structures/{name}/source/main", + collection_uri="/sap/bc/adt/ddic/structures", + create_content_type="application/vnd.sap.adt.ddic.structures.v1+xml", +)) + +TABLETYPE = _register(ObjectTypeConfig( + key="tabletype", + label="表类型(TABLE TYPE)", + obj_uri_template="/sap/bc/adt/vit/wb/object_type/ttypda/object_name/{name}", + src_uri_template=None, + collection_uri="/sap/bc/adt/ddic/tabletypes", + create_content_type="application/vnd.sap.adt.ddic.tabletypes.v1+xml", + has_source=False, +)) + +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", +)) + +CDSVIEW = _register(ObjectTypeConfig( + key="cdsview", + label="CDS视图(CDS VIEW)", + obj_uri_template="/sap/bc/adt/dds/ddl/sources/{name}", + src_uri_template="/sap/bc/adt/dds/ddl/sources/{name}/content", + collection_uri="/sap/bc/adt/dds/ddl/sources", + create_content_type="text/plain", +)) + +MESSAGECLASS = _register(ObjectTypeConfig( + key="messageclass", + label="消息类(MESSAGE CLASS)", + obj_uri_template="/sap/bc/adt/oo/t100/messages/classes/{name}", + src_uri_template=None, + collection_uri="/sap/bc/adt/oo/t100/messages/classes", + create_content_type="application/vnd.sap.adt.t100.message.classes.v1+xml", + has_source=False, +)) + +VIEW = _register(ObjectTypeConfig( + key="view", + label="数据库视图(VIEW)", + obj_uri_template="/sap/bc/adt/ddic/views/{name}", + src_uri_template="/sap/bc/adt/ddic/views/{name}/source/main", + collection_uri="/sap/bc/adt/ddic/views", + create_content_type="application/vnd.sap.adt.ddic.views.v1+xml", +)) + +SEARCHHELP = _register(ObjectTypeConfig( + key="searchhelp", + label="搜索帮助(SEARCH HELP)", + obj_uri_template="/sap/bc/adt/ddic/searchhelps/{name}", + src_uri_template=None, + collection_uri="/sap/bc/adt/ddic/searchhelps", + create_content_type="application/vnd.sap.adt.ddic.searchhelps.v1+xml", + has_source=False, +)) + +LOCKOBJECT = _register(ObjectTypeConfig( + key="lockobject", + label="锁对象(LOCK OBJECT)", + obj_uri_template="/sap/bc/adt/ddic/lockobjects/{name}", + src_uri_template=None, + collection_uri="/sap/bc/adt/ddic/lockobjects", + create_content_type="application/vnd.sap.adt.ddic.lockobjects.v1+xml", + has_source=False, +)) + + +def get_type_config(key: str) -> ObjectTypeConfig: + if key not in _OBJECT_TYPE_REGISTRY: + raise ValueError(f"不支持的对象类型: {key}") + return _OBJECT_TYPE_REGISTRY[key] + + +def all_type_keys() -> list[str]: + return list(_OBJECT_TYPE_REGISTRY.keys()) + + +@dataclass(frozen=True) +class ParsedName: + obj_uri: str + src_uri: str | None + display_name: str + file_base: str + + +def parse_object_name(name: str, obj_type: str) -> ParsedName: + cfg = get_type_config(obj_type) + + if obj_type == "function": + if "/" not in name: + raise InvalidNameError( + "function 类型需要'函数组名/函数模块名' 格式,例如: ZGROUP/Z_MY_FUNC" + ) + group, fm = name.split("/", 1) + group = group.strip() + fm = fm.strip() + if not group or not fm: + raise InvalidNameError("函数组名和函数模块名不能为空") + return ParsedName( + obj_uri=cfg.format_obj_uri(fm.lower(), group=group.lower()), + src_uri=cfg.format_src_uri(fm.lower(), group=group.lower()), + display_name=name, + file_base=fm.lower(), + ) + + # VIT 端点需要大写名称以返回完整元数据;去掉前导 / + if obj_type == "tabletype": + clean = name.lstrip("/") + name_upper = clean.upper() + return ParsedName( + obj_uri=cfg.format_obj_uri(name_upper), + src_uri=cfg.format_src_uri(name_upper), + display_name=clean, + file_base=clean.lower(), + ) + + name_lower = name.lower() + return ParsedName( + obj_uri=cfg.format_obj_uri(name_lower), + src_uri=cfg.format_src_uri(name_lower), + display_name=name, + file_base=name_lower, + ) diff --git a/assets/sapcli/utils/__init__.py b/assets/sapcli/utils/__init__.py new file mode 100644 index 0000000..447e13b --- /dev/null +++ b/assets/sapcli/utils/__init__.py @@ -0,0 +1,5 @@ +"""sapcli 工具包""" + +from .xml_utils import xml_escape, ddl_escape + +__all__ = ["xml_escape", "ddl_escape"] diff --git a/assets/sapcli/utils/xml_utils.py b/assets/sapcli/utils/xml_utils.py new file mode 100644 index 0000000..171d842 --- /dev/null +++ b/assets/sapcli/utils/xml_utils.py @@ -0,0 +1,13 @@ +"""XML 安全转义工具""" + +import xml.sax.saxutils as _saxutils + + +def xml_escape(s: str) -> str: + """转义 XML 特殊字符(包括双引号),用于属性值安全。""" + return _saxutils.escape(s, {'"': '"'}) + + +def ddl_escape(s: str) -> str: + """转义 DDL 字符串中的单引号(ABAP DDL 用两个单引号转义)。""" + return s.replace("'", "''") diff --git a/references/error-handling.md b/references/error-handling.md new file mode 100644 index 0000000..f2a71c0 --- /dev/null +++ b/references/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/references/sap-tool-constraints.md b/references/sap-tool-constraints.md new file mode 100644 index 0000000..23996c3 --- /dev/null +++ b/references/sap-tool-constraints.md @@ -0,0 +1,103 @@ +# 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:/Codespace/sap-cli && python main.py [options] +``` + +### 例外(仅在以下情况下允许) + +- `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 +``` + +## 自检清单 + +每次执行 SAP 操作前,确认: + +1. ✅ 我在用 `python main.py <命令>` 吗? +2. ✅ 我没有直接调 ADT/SOAP/RFC 端点吗? +3. ✅ 我操作的是 Z* 开头的自定义对象吗? +4. ✅ 我没有 INSERT/UPDATE/DELETE 系统表吗? +5. ✅ sync 命令带了 `--corr_nr` 吗? diff --git a/scripts/setup.py b/scripts/setup.py new file mode 100644 index 0000000..1966503 --- /dev/null +++ b/scripts/setup.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""sap-cli 安装与配置脚本。 + +用法: + python setup.py install # 安装 sap-cli 及依赖 + python setup.py config # 交互式配置 SAP 连接 + python setup.py verify # 验证安装和连接 + python setup.py all # 一键完成:安装 + 配置 + 验证 +""" +from __future__ import annotations + +import os +import sys +import subprocess +from pathlib import Path + +# ── 路径常量 ────────────────────────────────────────────── +SKILL_DIR = Path(__file__).resolve().parent.parent +ASSETS_DIR = SKILL_DIR / "assets" +CONFIG_EXAMPLE = ASSETS_DIR / "config.ini.example" +CONFIG_FILE = ASSETS_DIR / "config.ini" + + +def _run(cmd: list[str], **kw) -> int: + """运行命令并实时输出。""" + print(f" $ {' '.join(cmd)}") + return subprocess.call(cmd, **kw) + + +def _pip_available() -> bool: + try: + subprocess.run([sys.executable, "-m", "pip", "--version"], + capture_output=True, check=True) + return True + except Exception: + return False + + +# ── 安装 ───────────────────────────────────────────────── +def do_install(): + """安装 sap-cli 及其依赖。""" + print("\n📦 安装 sap-cli ...") + + # 1. 检查 Python 版本 + py_ver = sys.version_info + if py_ver < (3, 10): + print(f" ❌ Python 3.10+ required, got {py_ver.major}.{py_ver.minor}") + sys.exit(1) + print(f" ✅ Python {py_ver.major}.{py_ver.minor}.{py_ver.micro}") + + # 2. 安装依赖 + if _pip_available(): + print("\n Installing dependencies ...") + ret = _run([sys.executable, "-m", "pip", "install", "requests>=2.28.0"]) + if ret != 0: + print(" ⚠️ pip install failed, trying manual install ...") + else: + print(" ⚠️ pip not available, skipping dependency install.") + print(" 请手动安装: pip install requests") + + # 3. 验证 sapcli 包可导入 + print("\n Verifying sapcli package ...") + ret = _run([sys.executable, "-c", "from sapcli import __version__; print(f' ✅ sap-cli v{__version__}')"], + cwd=str(ASSETS_DIR)) + if ret != 0: + print(" ❌ sapcli package import failed.") + sys.exit(1) + + # 4. 验证 CLI 入口 + ret = _run([sys.executable, "main.py", "--help"], cwd=str(ASSETS_DIR)) + if ret != 0: + print(" ❌ CLI entry point failed.") + sys.exit(1) + + print("\n ✅ sap-cli 安装成功!") + print(f" 安装位置: {ASSETS_DIR}") + print(f" 运行方式: cd {ASSETS_DIR} && python main.py ") + + +# ── 配置 ───────────────────────────────────────────────── +def do_config(): + """交互式配置 SAP 连接。""" + print("\n⚙️ 配置 SAP 连接 ...") + + if CONFIG_FILE.exists(): + print(f" 配置文件已存在: {CONFIG_FILE}") + answer = input(" 是否覆盖?(y/N): ").strip().lower() + if answer != "y": + print(" 保留现有配置。") + return + + # 从模板创建 + if CONFIG_EXAMPLE.exists(): + import shutil + shutil.copy2(CONFIG_EXAMPLE, CONFIG_FILE) + print(f" ✅ 已从模板创建: {CONFIG_FILE}") + else: + # 手动创建 + CONFIG_FILE.write_text( + "[SAP]\nhost = \nclient = \nuser = \npassword = \n", + encoding="utf-8", + ) + print(f" ✅ 已创建空白配置: {CONFIG_FILE}") + + # 交互式填写 + print("\n 请输入 SAP 连接信息(留空跳过):") + host = input(" SAP 服务器地址 (如 http://sap.example.com:8000): ").strip() + client = input(" SAP Client (如 100): ").strip() + user = input(" 用户名: ").strip() + password = input(" 密码: ").strip() + + if host or client or user or password: + # 简单替换 + content = CONFIG_FILE.read_text(encoding="utf-8") + if host: + content = _replace_config_value(content, "host", host) + if client: + content = _replace_config_value(content, "client", client) + if user: + content = _replace_config_value(content, "user", user) + if password: + content = _replace_config_value(content, "password", password) + CONFIG_FILE.write_text(content, encoding="utf-8") + print(" ✅ 配置已更新。") + else: + print(" ⚠️ 未填写任何信息,请手动编辑: " + str(CONFIG_FILE)) + + print("\n 💡 也可以通过环境变量配置(优先级更高):") + print(" SAP_HOST / SAP_CLIENT / SAP_USER / SAP_PASSWORD") + + +def _replace_config_value(content: str, key: str, value: str) -> str: + """替换 config.ini 中的值。""" + import re + pattern = rf"^({key}\s*=\s*).*?$" + return re.sub(pattern, rf"\g<1>{value}", content, flags=re.MULTILINE) + + +# ── 验证 ───────────────────────────────────────────────── +def do_verify(): + """验证安装和连接。""" + print("\n🔍 验证安装 ...") + + # 1. 包导入 + ret = _run([sys.executable, "-c", "from sapcli import __version__; print(' ✅ 包导入正常')"], + cwd=str(ASSETS_DIR)) + if ret != 0: + print(" ❌ 包导入失败") + return False + + # 2. CLI 帮助 + ret = _run([sys.executable, "main.py", "--help"], cwd=str(ASSETS_DIR)) + if ret != 0: + print(" ❌ CLI 入口失败") + return False + + # 3. 配置文件 + if CONFIG_FILE.exists(): + print(f" ✅ 配置文件: {CONFIG_FILE}") + else: + print(f" ⚠️ 未找到配置文件,请运行: python setup.py config") + return False + + # 4. SAP 连接测试 + print("\n 测试 SAP 连接 ...") + ret = _run([sys.executable, "main.py", "config", "show"], cwd=str(ASSETS_DIR)) + if ret == 0: + print(" ✅ SAP 连接正常!") + return True + else: + print(" ⚠️ 连接测试失败,请检查配置。") + return False + + +# ── 一键全部 ───────────────────────────────────────────── +def do_all(): + """安装 + 配置 + 验证。""" + do_install() + do_config() + do_verify() + print("\n" + "=" * 50) + print(" 🎉 sap-cli 已就绪!") + print(" 使用示例:") + print(f" cd {ASSETS_DIR}") + print(" python main.py info --name ZMY_CLASS --type class") + print("=" * 50) + + +# ── 入口 ───────────────────────────────────────────────── +def main(): + commands = { + "install": do_install, + "config": do_config, + "verify": do_verify, + "all": do_all, + } + + if len(sys.argv) < 2 or sys.argv[1] not in commands: + print("Usage: python setup.py ") + print() + print("Commands:") + print(" install - Install sap-cli and dependencies") + print(" config - Configure SAP connection (interactive)") + print(" verify - Verify installation and connection") + print(" all - Install + config + verify (recommended)") + sys.exit(1) + + commands[sys.argv[1]]() + + +if __name__ == "__main__": + main()