From e786742bcbb16dc3b6f64e76522a3c2951e61d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E8=AE=A9=E5=AE=87?= Date: Fri, 11 Sep 2026 00:22:05 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E8=87=B3=E6=BA=90?= =?UTF-8?q?=E7=A0=81=202.5.1=20=E2=80=94=20SKILL.md=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E4=BA=A7=E7=89=A9=20+=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL.md(唯一来源 = sap-cli/skill-src/SKILL.md.tmpl): - 本仓 SKILL.md 自此为构建产物,勿手工编辑;手改后再构建会被漂移护栏拦下 - 并入模板独有内容:调用方式节 + 补齐 parser 真实命令(clone/enhancement/ history/unit-test/unlock/transport create,原表只 9 个,实际 31 个) - 版本号改为 {{VERSION}} 注入(原字面量停在 v2.2.1/v2.3.0,与工具实际版本脱节) - 修 1 处违反铁律 5 的示例(--path ./src → ./src/TMP/class/) assets/(工具代码,此前严重过时): - 9 个文件与源码不一致,scanner.py 尤甚:3818B → 6664B - 修复前从本仓装出的工具**不支持三层 src 结构**,铁律 5 实际跑不通 - transport.py 反向漂移(分发版多 transport create,源码无)已随源码回迁对齐 VERSION 2.5.1;references/ 三份规则与源码一致(无差异) --- SKILL.md | 29 +++- assets/sapcli/__init__.py | 2 +- assets/sapcli/cli/app.py | 2 + assets/sapcli/cli/parser.py | 6 + assets/sapcli/client/_ddic.py | 6 + assets/sapcli/commands/__init__.py | 2 + assets/sapcli/commands/batch.py | 5 +- assets/sapcli/commands/crud.py | 24 ++++ assets/sapcli/commands/transport.py | 6 +- assets/sapcli/scanner.py | 204 ++++++++++++++++++++++------ 10 files changed, 231 insertions(+), 55 deletions(-) diff --git a/SKILL.md b/SKILL.md index a69b232..fb3e9de 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: sap-cli -description: "SAP ABAP ADT CLI for AI agents. Trigger when: operating ABAP objects, syncing code to/from SAP, managing transports, querying DDIC, running ABAP programs, reading SAP tables, or any SAP development task via terminal." +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.5.1" author: WuRangyu license: MIT @@ -17,6 +17,21 @@ triggers: Python CLI wrapping the SAP ADT REST API. Manages the full lifecycle of ABAP development objects from the terminal. Core loop: **download → local edit → sync back to SAP**. +## 调用方式 + +```bash +cd /assets && python main.py [options] +``` + +全局参数(置于命令之前): + +| 参数 | 说明 | +|------|------| +| `--profile ` | 使用指定配置 profile | +| `--verify-ssl` | 启用 SSL 验证(默认关闭) | + +安装与配置见同目录 `INSTALL.md`。 + ## Quick Start ```bash @@ -37,7 +52,7 @@ python main.py read-table --name ZMY_TABLE --fields "F1,F2" --where "F1 = 'X'" - ## Command Reference -Global params (before command): `--profile `, `--verify-ssl`. Optional params in `[ ]`. +Optional params in `[ ]`. 各命令参数以 `python main.py --help` 为准。 | Category | Command | Key Parameters | |----------|---------|----------------| @@ -49,20 +64,24 @@ Global params (before command): `--profile `, `--verify-ssl`. Optional par | CRUD | `activate` | `--name --type --corr_nr` (built-in NW 7.40 double-activate) | | CRUD | `upload` | `--name --type --path --corr_nr` (lock→write→unlock only; no check/activate) | | CRUD | `syntax-check` | `--name --type` (remote object; no upload/activate) | +| CRUD | `unlock` | `--name --type --lock-handle` (释放 delete 后残留的 enqueue 锁) | | Batch | `init` | `--path` | | Batch | `refresh` | `--path` | | Batch | `sync --all` | `--path --corr_nr [--dry-run]` | | Batch | `analyze` | `--path` (dependency analysis) | +| Batch | `clone` | `--name --type --from --to [--corr_nr]` (跨系统克隆对象) | | Query | `show-table` | `--name` (SE11 structure) | | Query | `read-table` | `--name [--fields --where --max-rows]` (SE16N data) | | Query | `list` | `--type [--package --prefix]` | | Query | `whereused` | `--name --type` | | Query | `search` | `--query` | | Query | `diff` | `--name --type --path` (local vs SAP) | +| Query | `history` | `--name --type` (对象版本历史) | | Transport | `transport list` | — | | Transport | `transport info` | `--corr_nr` | | Transport | `transport release` | `--corr_nr` | | Transport | `transport objects` | `--corr_nr` | +| Transport | `transport create` | `--description` (新建传输请求) | | Config | `config show` | — | | Config | `config list-profiles` | — | | Config | `config set` | ` ` | @@ -70,6 +89,8 @@ Global params (before command): `--profile `, `--verify-ssl`. Optional par | Other | `run-program` | `--name` (SA38) | | Other | `check` | `--name --type` (ATC) | | Other | `format` | `--name --type` (Pretty Printer) | +| Other | `enhancement` | `--name --type` (增强) | +| Other | `unit-test` | `--name --type` (ABAP Unit 测试) | | Other | `package create` | `--name` | | Other | `cds download` | `--name --path` | | Other | `scaffold` | `--name --template ` — alv-report, bapi-wrapper, interface-class, data-model | @@ -80,7 +101,7 @@ Batch sync dependency order: `domain(10) → dataelement(20) → table(30) → t Step-wise sync for lock-heavy automation — split the atomic `sync` when lock conflicts are frequent; upload now, verify, activate later: ```bash -python main.py upload --name ZMY_CLASS --type class --path ./src/zmy_class.abap --corr_nr DEVK901XXX # lock→write→unlock only +python main.py upload --name ZMY_CLASS --type class --path ./src/TMP/class/zmy_class.abap --corr_nr DEVK901XXX # lock→write→unlock only python main.py syntax-check --name ZDEPENDENT_REPORT --type report # verify dependents (no upload) python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX # activate once clean ``` @@ -297,7 +318,7 @@ Automation fallbacks — exhaust in order before requesting any SAP GUI action: - **Give the complete change checklist at once** — do the full comparison first (`show-table` vs code field refs); never surface issues incrementally. - **System-table writes** — 4-step authorization: explain *** → list exact statements → state risks & alternatives → await explicit approval. -## NW 7.40 Compatibility (E2E verified — v2.3.0) +## NW 7.40 Compatibility (E2E verified — 2.5.1) Full matrix: `docs/NW740-COMPATIBILITY.md`. diff --git a/assets/sapcli/__init__.py b/assets/sapcli/__init__.py index fe7e096..ada298b 100644 --- a/assets/sapcli/__init__.py +++ b/assets/sapcli/__init__.py @@ -2,4 +2,4 @@ sap-cli — 基于 SAP ADT REST API 的 ABAP 开发对象管理工具 """ -__version__ = "2.3.0" +__version__ = "2.5.1" diff --git a/assets/sapcli/cli/app.py b/assets/sapcli/cli/app.py index 147c3e0..3635cbf 100644 --- a/assets/sapcli/cli/app.py +++ b/assets/sapcli/cli/app.py @@ -13,6 +13,7 @@ from sapcli.client import ADTClient from sapcli.commands import ( cmd_create, cmd_delete, + cmd_unlock, cmd_download, cmd_info, cmd_init, @@ -139,6 +140,7 @@ def main() -> None: "sync": cmd_sync_all if getattr(args, "all", False) else cmd_sync, "info": cmd_info, "delete": cmd_delete, + "unlock": cmd_unlock, "create": cmd_create, "init": cmd_init, "refresh": cmd_refresh, diff --git a/assets/sapcli/cli/parser.py b/assets/sapcli/cli/parser.py index 8e9b62c..dbff59b 100644 --- a/assets/sapcli/cli/parser.py +++ b/assets/sapcli/cli/parser.py @@ -99,6 +99,12 @@ CDS View: del_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") del_parser.add_argument("--path", default=None, help="项目根目录 (可选,用于更新清单)") + # ── unlock ── + unlock_parser = subparsers.add_parser("unlock", help="释放对象残留 enqueue 锁(delete 后锁未释放时用)") + unlock_parser.add_argument("--name", required=True, help="对象名称 (function 类型用'组名/模块名')") + unlock_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") + unlock_parser.add_argument("--lock-handle", required=True, help="LOCK 响应返回的 lockHandle(见 log/adt_tools.log)") + # ── info ── info_parser = subparsers.add_parser("info", help="查询对象元数据信息") info_parser.add_argument("--name", required=True, help="对象名称 (function 类型用'组名/模块名')") diff --git a/assets/sapcli/client/_ddic.py b/assets/sapcli/client/_ddic.py index bef55be..63da604 100644 --- a/assets/sapcli/client/_ddic.py +++ b/assets/sapcli/client/_ddic.py @@ -71,6 +71,12 @@ class DdicMixin: logger.info("DELETE RESPONSE: HTTP %s", resp.status_code) if resp.status_code in (200, 204): logger.info("删除成功") + # 修复:delete 成功后必须释放 enqueue 锁,否则残留锁导致后续 create 403 + try: + self.unlock(obj_uri, lock_handle) + logger.info("删除后锁已释放") + except Exception as e: + logger.warning("删除后解锁失败(可能锁已自动释放): %s", e) return True, "" error_text = resp.text[:500] if resp.text else f"HTTP {resp.status_code}" logger.error("删除失败: %s", error_text) diff --git a/assets/sapcli/commands/__init__.py b/assets/sapcli/commands/__init__.py index 7737542..e7a4818 100644 --- a/assets/sapcli/commands/__init__.py +++ b/assets/sapcli/commands/__init__.py @@ -8,6 +8,7 @@ from sapcli.commands.crud import ( cmd_download, cmd_info, cmd_sync, + cmd_unlock, ) from sapcli.commands.batch import ( cmd_init, @@ -76,6 +77,7 @@ from sapcli.commands.enhancement import ( __all__ = [ "cmd_create", "cmd_delete", + "cmd_unlock", "cmd_download", "cmd_info", "cmd_init", diff --git a/assets/sapcli/commands/batch.py b/assets/sapcli/commands/batch.py index 8950047..479cd8c 100644 --- a/assets/sapcli/commands/batch.py +++ b/assets/sapcli/commands/batch.py @@ -37,8 +37,9 @@ def cmd_init(args: argparse.Namespace, client: ADTClient) -> None: if not scanned: print(" ℹ 未扫描到 .abap 文件") - print(" 请确认项目目录结构是否符合约定:") - print(" reports/, classes/, interfaces/, functions/, domains/, ...") + print(" 请确认项目目录结构是否符合约定(两种布局均可):") + print(" 三层: src/<开发包>/<对象类型>/<文件名>.abap (本地开发包固定 TMP)") + print(" 扁平: <对象类型>/<文件名>.abap (对象类型目录单复数均可)") return print(f" ✓ 扫描到 {len(scanned)} 个本地文件\n") diff --git a/assets/sapcli/commands/crud.py b/assets/sapcli/commands/crud.py index cef8558..4860d20 100644 --- a/assets/sapcli/commands/crud.py +++ b/assets/sapcli/commands/crud.py @@ -589,6 +589,30 @@ def cmd_delete(args: argparse.Namespace, client: ADTClient) -> None: raise DeleteError(f"删除失败: {error}") +def cmd_unlock(args: argparse.Namespace, client: ADTClient) -> None: + """释放指定对象的 enqueue 锁(清理 delete 后残留锁 / SM12 场景)。""" + obj_type: str = args.type + name: str = args.name + lock_handle: str = args.lock_handle + + 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 → 正在释放锁 (lockHandle={lock_handle[:12]}...)...") + ok = client.unlock(parsed.obj_uri, lock_handle) + if ok: + print(" ✓ 锁释放成功") + else: + print(" ✗ 锁释放失败(HTTP 非 200,可能锁已不存在或 handle 无效)") + raise SapCliError(f"解锁失败: {parsed.display_name}") + + def _update_manifest_after_delete( args: argparse.Namespace, name: str, diff --git a/assets/sapcli/commands/transport.py b/assets/sapcli/commands/transport.py index 222f49c..eda1d65 100644 --- a/assets/sapcli/commands/transport.py +++ b/assets/sapcli/commands/transport.py @@ -1,4 +1,4 @@ -"""传输管理命令:transport (list / info / release / objects)。""" +"""传输管理命令:transport (list / info / release / objects / create)。""" from __future__ import annotations import argparse @@ -119,7 +119,7 @@ def _transport_create(args: argparse.Namespace, client: ADTClient) -> None: print("=" * 60) print(f" 描述: {description}") - print(f"\n → 正在创建...") + print("\n → 正在创建...") try: new_nr = client.create_transport_request(description) except Exception as e: @@ -129,7 +129,7 @@ def _transport_create(args: argparse.Namespace, client: ADTClient) -> None: if new_nr: print(f"\n ✓ 传输请求已创建: {new_nr}") else: - print(f"\n ✗ 创建失败(未返回编号),请检查系统或描述合法性") + print("\n ✗ 创建失败(未返回编号),请检查系统或描述合法性") def _transport_objects(args: argparse.Namespace, client: ADTClient) -> None: diff --git a/assets/sapcli/scanner.py b/assets/sapcli/scanner.py index 1dee01a..6ec6c93 100644 --- a/assets/sapcli/scanner.py +++ b/assets/sapcli/scanner.py @@ -1,6 +1,22 @@ """目录扫描模块。 -扫描项目根目录下按约定的子目录结构,识别 ABAP 开发对象。 +扫描项目目录,识别 ABAP 开发对象。支持两种布局: + +1. **三层布局(推荐,sap-cli-skill 铁律 5)**: + `src/<开发包>/<对象类型>/<文件名>.abap` + 例:`src/TMP/class/zcl_demo.abap` + 本地开发包固定写 `TMP`(对应 SAP 端 `$TMP` 本地包,目录名去掉 `$`)。 + +2. **扁平布局(旧项目,向后兼容)**: + `<对象类型>/<文件名>.abap`(项目根目录下,对象类型目录用复数) + 例:`classes/zcl_demo.abap` + +对象类型目录名**单复数均接受、大小写不敏感**(`class/` 与 `classes/` 等价)。 + +函数模块(function 类型)两种命名均支持: +- 子目录式:`function/<函数组>/<函数模块>.abap` +- ADT 式:`function/<函数组>.fugr.<函数模块>.abap`(abapGit 惯例) +对象名统一为 `函数组/函数模块`(大写)。 """ from __future__ import annotations @@ -10,6 +26,7 @@ from dataclasses import dataclass logger = logging.getLogger("sapcli.scanner") +# 对象类型目录名(复数,历史命名)→ 对象类型 DIRECTORY_TYPE_MAP: dict[str, str] = { "reports": "report", "classes": "class", @@ -30,8 +47,38 @@ DIRECTORY_TYPE_MAP: dict[str, str] = { "ddlx": "ddlX", } +# 单数形式目录名(sap-cli-skill 结构规范使用单数)→ 对象类型 +_SINGULAR_TYPE_MAP: dict[str, str] = { + "report": "report", + "class": "class", + "interface": "interface", + "function": "function", + "domain": "domain", + "dataelement": "dataelement", + "table": "table", + "structure": "structure", + "tabletype": "tabletype", + "include": "include", + "cdsview": "cdsview", + "messageclass": "messageclass", + "view": "view", + "searchhelp": "searchhelp", + "lockobject": "lockobject", + "dcl": "dcl", + "ddlx": "ddlX", +} + +# 查找用总表:小写目录名 → 对象类型 +TYPE_DIR_LOOKUP: dict[str, str] = {**DIRECTORY_TYPE_MAP, **_SINGULAR_TYPE_MAP} + FUNCTIONS_DIR = "functions" +# 源码根目录(三层布局的第 1 层父目录) +SRC_DIR = "src" + +# ADT 式函数模块文件名分隔符:.fugr..abap +FUNC_ADT_SEP = ".fugr." + @dataclass class ScannedObject: @@ -45,79 +92,146 @@ class ScannedObject: def scan_project(project_path: str) -> list[ScannedObject]: """扫描项目目录,返回识别到的所有对象列表。 - 遍历已知目录名,每个目录下找 .abap 文件。 - functions/ 特殊处理:子目录 = 函数组名。 + 先扫项目根目录下的对象类型目录(旧布局), + 再扫 src/<开发包>/ 下的对象类型目录(三层布局)。 + 同名同类型对象只保留第一次扫到的。 """ results: list[ScannedObject] = [] + seen: set[tuple[str, str]] = set() - 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) + # 扫描范围:(目录绝对路径, 该目录相对项目根的路径前缀) + scopes: list[tuple[str, str]] = [(project_path, "")] + + src_path = os.path.join(project_path, SRC_DIR) + if os.path.isdir(src_path): + for pkg_name in sorted(os.listdir(src_path)): + if pkg_name.startswith("."): + continue + pkg_path = os.path.join(src_path, pkg_name) + if os.path.isdir(pkg_path): + scopes.append((pkg_path, f"{SRC_DIR}/{pkg_name}")) + + for root_path, rel_prefix in scopes: + try: + entries = sorted(os.listdir(root_path)) + except OSError as exc: # 权限/竞态 + logger.debug("目录不可读,跳过: %s (%s)", root_path, exc) continue - if dir_name == FUNCTIONS_DIR: - _scan_functions(dir_path, results) - else: - _scan_flat_directory(dir_path, dir_name, obj_type, results) + for dir_name in entries: + if dir_name.startswith("."): + continue + obj_type = TYPE_DIR_LOOKUP.get(dir_name.lower()) + if obj_type is None: + continue + dir_path = os.path.join(root_path, dir_name) + if not os.path.isdir(dir_path): + continue + + if obj_type == "function": + _scan_functions(dir_path, dir_name, rel_prefix, results, seen) + else: + _scan_flat_directory(dir_path, dir_name, obj_type, rel_prefix, results, seen) logger.info("扫描完成: %d 个对象", len(results)) return results +def _rel(rel_prefix: str, *parts: str) -> str: + """拼相对项目根的路径(统一用 / 分隔,去掉空段)。""" + segs = [rel_prefix, *parts] + return "/".join(s for s in segs if s) + + +def _add( + results: list[ScannedObject], + seen: set[tuple[str, str]], + name: str, + obj_type: str, + rel_path: str, +) -> None: + key = (name, obj_type) + if key in seen: + logger.debug("重复对象,跳过: %s (%s) ← %s", name, obj_type, rel_path) + return + seen.add(key) + results.append(ScannedObject(name=name, type=obj_type, file=rel_path)) + logger.debug("扫描到: %s (%s) → %s", name, obj_type, rel_path) + + def _scan_flat_directory( dir_path: str, dir_name: str, obj_type: str, + rel_prefix: str, results: list[ScannedObject], + seen: set[tuple[str, str]], ) -> None: """扫描普通目录(一对一:文件名 = 对象名)。""" for filename in sorted(os.listdir(dir_path)): if not filename.endswith(".abap"): continue - # 隐藏文件跳过 if filename.startswith("."): continue + if not os.path.isfile(os.path.join(dir_path, filename)): + 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) + _add(results, seen, obj_name, obj_type, _rel(rel_prefix, dir_name, filename)) -def _scan_functions(dir_path: str, results: list[ScannedObject]) -> None: - """扫描 functions/ 目录(特殊处理:子目录 = 函数组名)。 +def _scan_functions( + dir_path: str, + dir_name: str, + rel_prefix: str, + results: list[ScannedObject], + seen: set[tuple[str, str]], +) -> None: + """扫描函数模块目录。 - 目录结构: functions/{组名小写}/{模块名小写}.abap - 对象名: {组名大写}/{模块名大写} + 支持两种命名: + - 子目录式: <函数组>/<函数模块>.abap + - ADT 式: <函数组>.fugr.<函数模块>.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): + for entry in sorted(os.listdir(dir_path)): + if entry.startswith("."): continue - if group_dir_name.startswith("."): + entry_path = os.path.join(dir_path, entry) + + if os.path.isdir(entry_path): + # 子目录式:子目录名 = 函数组名 + group_name = entry.upper() + for filename in sorted(os.listdir(entry_path)): + if not filename.endswith(".abap") or filename.startswith("."): + continue + if not os.path.isfile(os.path.join(entry_path, filename)): + continue + func_name = filename[:-5].upper() + _add( + results, seen, + f"{group_name}/{func_name}", "function", + _rel(rel_prefix, dir_name, entry, filename), + ) continue - group_name = group_dir_name.upper() + if not entry.endswith(".abap"): + continue - 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) + # ADT 式:.fugr..abap + base = entry[:-5] + low = base.lower() + if FUNC_ADT_SEP not in low: + logger.debug("函数模块文件名不符合约定,跳过: %s", entry) + continue + idx = low.index(FUNC_ADT_SEP) + group_name = base[:idx].upper() + func_name = base[idx + len(FUNC_ADT_SEP):].upper() + if not group_name or not func_name: + logger.debug("函数模块文件名解析失败,跳过: %s", entry) + continue + _add( + results, seen, + f"{group_name}/{func_name}", "function", + _rel(rel_prefix, dir_name, entry), + )