chore: sync skill to v2.2.1

- NW 7.40 E2E-verified compatibility matrix in SKILL.md
- DDIC fixes: include namespace, tabletype exists check, DDIC delete lock Accept
- New commands: clone, enhancement, history, unit-test
- 665 tests, 96% coverage
This commit is contained in:
2026-07-10 19:13:46 +08:00
parent 3a7fba0860
commit 38e99638c5
16 changed files with 951 additions and 16 deletions
+16
View File
@@ -60,6 +60,18 @@ from sapcli.commands.upload import (
from sapcli.commands.syntax_check import (
cmd_syntax_check,
)
from sapcli.commands.unit_test import (
cmd_unit_test,
)
from sapcli.commands.history import (
cmd_history,
)
from sapcli.commands.clone import (
cmd_clone,
)
from sapcli.commands.enhancement import (
cmd_enhancement,
)
__all__ = [
"cmd_create",
@@ -88,4 +100,8 @@ __all__ = [
"cmd_activate",
"cmd_upload",
"cmd_syntax_check",
"cmd_unit_test",
"cmd_history",
"cmd_clone",
"cmd_enhancement",
]
+128
View File
@@ -0,0 +1,128 @@
"""clone 命令 — 跨系统克隆对象(下载源系统 → 上传目标系统)。"""
from __future__ import annotations
import argparse
import logging
import os
import tempfile
from sapcli.client import ADTClient
from sapcli.commands.crud import _sync_single
from sapcli.config import load_config
from sapcli.exceptions import ConfigError, InvalidNameError, ObjectNotFoundError, SapCliError
from sapcli.types import get_type_config, parse_object_name
logger = logging.getLogger("sapcli.commands.clone")
def _make_profile_client(config_path: str | None, profile: str, verify_ssl: bool = False) -> ADTClient:
"""按 profile 加载配置并建立已登录的 ADT 客户端。"""
sap_cfg, _ = load_config(config_path, profile=profile)
client = ADTClient(
sap_cfg.host, sap_cfg.client, sap_cfg.user, sap_cfg.password,
verify_ssl=verify_ssl,
)
client.login()
return client
def cmd_clone(args: argparse.Namespace, client: ADTClient) -> None:
"""从一个系统下载对象,上传到另一个系统。
注意:``client`` 参数在本命令中被忽略;源/目标客户端分别按
``--from`` / ``--to`` profile 构建。
"""
name: str = args.name
obj_type: str = args.type
from_profile: str = args.from_profile
to_profile: str = args.to_profile
corr_nr: str | None = getattr(args, "corr_nr", None)
config_path: str | None = getattr(args, "config", None)
verify_ssl: bool = getattr(args, "verify_ssl", False)
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}没有源代码,不支持 clone 操作")
raise InvalidNameError(f"{type_label}没有源代码,不支持 clone 操作")
print("=" * 60)
print(" 跨系统克隆对象")
print("=" * 60)
print(f" 对象类型: {type_label}")
print(f" 对象名称: {parsed.display_name}")
print(f" 源系统: {from_profile}")
print(f" 目标系统: {to_profile}")
if corr_nr:
print(f" 目标传输请求: {corr_nr}")
# ── Step 1: 从源系统下载源码 ──
print(f"\n → 连接源系统 ({from_profile})...")
try:
src_client = _make_profile_client(config_path, from_profile, verify_ssl)
except Exception as e:
print(f" ✗ 连接源系统失败: {e}")
raise ConfigError(f"连接源系统失败: {e}") from e
print(f" ✓ 已登录源系统")
try:
print(f"\n → 检查源对象是否存在...")
if not src_client.object_exists(parsed.obj_uri):
print(f" ✗ 源对象不存在: {parsed.display_name}")
raise ObjectNotFoundError(parsed.display_name, obj_type)
print(" ✓ 源对象存在")
print(f" → 正在从源系统下载源代码...")
source = src_client.get_source(parsed.src_uri)
source = source.replace("\r\n", "\n").replace("\r", "\n")
print(f" ✓ 下载成功 ({len(source)} 字符)")
finally:
src_client.session.close()
# 写入临时文件供 _sync_single 复用
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".abap", prefix=f"clone_{parsed.file_base}_")
try:
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
f.write(source)
# ── Step 2: 连接目标系统 ──
print(f"\n → 连接目标系统 ({to_profile})...")
try:
tgt_client = _make_profile_client(config_path, to_profile, verify_ssl)
except Exception as e:
print(f" ✗ 连接目标系统失败: {e}")
raise ConfigError(f"连接目标系统失败: {e}") from e
print(f" ✓ 已登录目标系统")
try:
print(f"\n → 检查目标对象是否存在...")
exists = tgt_client.object_exists(parsed.obj_uri)
if exists:
print(" ✓ 目标对象已存在(将覆盖)")
else:
print(" 目标对象不存在(将创建并同步)")
# ── Step 3: 同步到目标(不存在则自动创建 + 上传 + 激活)──
print(f"\n → 同步到目标系统...")
success, error_msg, actual_corr = _sync_single(
name, obj_type, tmp_path, tgt_client, corr_nr=corr_nr,
)
finally:
tgt_client.session.close()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
if success:
print(f"\n {'=' * 60}")
print(f" ✓ 克隆完成: {parsed.display_name}")
print(f" {from_profile}{to_profile}")
if actual_corr:
print(f" 目标传输请求: {actual_corr}")
print(f" {'=' * 60}")
return
raise SapCliError(error_msg or f"克隆失败: {parsed.display_name}")
+46 -4
View File
@@ -56,6 +56,39 @@ DEFAULT_TEMPLATES: dict[str, str] = {
' METHODS: hello.\n'
'ENDINTERFACE.\n'
),
# 以下类型不提供完整业务骨架,仅给出占位注释。
# 建议通过 --source 指定本地源码文件,或用 --definition 创建 DDIC 定义。
"include": (
'*&---------------------------------------------------------------------*\n'
'*& Include {name}\n'
'*&---------------------------------------------------------------------*\n'
'* Include 程序骨架:在此编写可被其它程序 INCLUDE 的 ABAP 片段。\n'
'* 提示:用 --source 指定本地源码文件可覆盖本模板。\n'
),
"messageclass": (
'* Message Class {name}\n'
'* 消息类(T100)不包含 ABAP 源码,消息条目通过 SE91 维护。\n'
'* 提示:建议用 --definition 创建,或通过 SAP GUI (SE91) 维护消息。\n'
),
"view": (
'* Database View {name}\n'
'* 数据库视图 DDL 骨架(基础 SELECT 形式,按需替换):\n'
'* SELECT <字段列表>\n'
'* FROM <主表>\n'
'* [JOIN <关联表> ON <条件>]\n'
'* [WHERE <过滤条件>]\n'
'* 提示:用 --source 指定完整视图定义可覆盖本模板。\n'
),
"searchhelp": (
'* Search Help {name}\n'
'* 搜索帮助不包含 ABAP 源码,定义含数据源与显示字段。\n'
'* 提示:建议用 --definition 创建,或通过 SAP GUI (SE11) 维护。\n'
),
"lockobject": (
'* Lock Object {name}\n'
'* 锁对象不包含 ABAP 源码,定义含锁定模式与主表。\n'
'* 提示:建议用 --definition 创建,或通过 SAP GUI (SE11) 维护。\n'
),
}
@@ -92,7 +125,7 @@ def cmd_download(args: argparse.Namespace, client: ADTClient) -> None:
print(f" 保存路径: {save_dir}")
print(f"\n → 检查对象是否存在...")
if not client.object_exists(parsed.obj_uri):
if not client.object_exists(parsed.exists_uri or parsed.obj_uri):
print(f" ✗ 对象不存在: {parsed.display_name}")
print(f" 请确认名称和类型是否正确")
raise ObjectNotFoundError(parsed.display_name, obj_type)
@@ -210,7 +243,7 @@ def _sync_single(
if not quiet:
print(f"\n → 检查对象是否存在...")
if not client.object_exists(parsed.obj_uri):
if not client.object_exists(parsed.exists_uri or parsed.obj_uri):
if not quiet:
print(f" ℹ 对象不存在,自动创建空对象...")
try:
@@ -386,6 +419,15 @@ def cmd_info(args: argparse.Namespace, client: ADTClient) -> None:
"dataelement": "*/*",
"table": "*/*",
"tabletype": "*/*",
"structure": "application/vnd.sap.adt.ddic.structures.v1+xml",
"include": "application/vnd.sap.adt.programs.includes.v2+xml",
"cdsview": "application/vnd.sap.adt.ddic.ddls.v3+xml",
"messageclass": "application/vnd.sap.adt.t100.message.classes.v1+xml",
"view": "application/vnd.sap.adt.ddic.views.v1+xml",
"searchhelp": "application/vnd.sap.adt.ddic.searchhelps.v1+xml",
"lockobject": "application/vnd.sap.adt.ddic.lockobjects.v1+xml",
"dcl": "text/plain",
"ddlX": "text/plain",
}
print("=" * 60)
@@ -471,7 +513,7 @@ def cmd_delete(args: argparse.Namespace, client: ADTClient) -> None:
print(f" 对象名称: {parsed.display_name}")
print(f"\n → 检查对象是否存在...")
if not client.object_exists(parsed.obj_uri):
if not client.object_exists(parsed.exists_uri or parsed.obj_uri):
print(f" ✗ 对象不存在: {parsed.display_name}")
print(f" 请确认名称和类型是否正确")
raise ObjectNotFoundError(parsed.display_name, obj_type)
@@ -639,7 +681,7 @@ def cmd_create(args: argparse.Namespace, client: ADTClient) -> None:
print(f"\n → 检查对象是否存在...")
if obj_type != "functiongroup":
parsed = parse_object_name(name, obj_type)
if client.object_exists(parsed.obj_uri):
if client.object_exists(parsed.exists_uri or parsed.obj_uri):
print(f" ✗ 对象已存在: {display_name}")
raise ObjectAlreadyExistsError(display_name, type_label)
else:
+69
View File
@@ -0,0 +1,69 @@
"""enhancement 命令 — 查询对象的增强实现。"""
from __future__ import annotations
import argparse
import logging
from sapcli.client import ADTClient
from sapcli.exceptions import ObjectNotFoundError
from sapcli.types import get_type_config, parse_object_name
logger = logging.getLogger("sapcli.commands.enhancement")
def cmd_enhancement(args: argparse.Namespace, client: ADTClient) -> None:
"""查询对象的增强实现(ENHO)。"""
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(" 对象增强实现查询")
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}")
raise ObjectNotFoundError(parsed.display_name, obj_type)
print(" ✓ 对象存在")
print(f"\n → 正在查询增强实现...")
try:
enhancements = client.get_enhancements(parsed.obj_uri)
except Exception as e:
print(f" ✗ 查询增强实现失败: {e}")
return
if not enhancements:
print(" 未找到增强实现")
return
print(f" ✓ 找到 {len(enhancements)} 个增强实现\n")
for enh in enhancements:
enh_name = enh.get("name", "")
enh_type = enh.get("type", "ENHO")
print(f" Enhancement Implementation: {enh_name} ({enh_type})")
enhanced_name = enh.get("enhanced_name", "")
enhanced_type = enh.get("enhanced_type", "")
if enhanced_name:
print(f" Enhanced Object: {enhanced_name} ({enhanced_type})")
elements = enh.get("elements", [])
for i, el in enumerate(elements, 1):
el_type = el.get("type", "")
el_name = el.get("name", "")
label = f"{el_type}: {el_name}" if el_type and el_name else (el_name or el_type)
print(f" Element {i}: {label}")
extras = []
if el.get("mode"):
extras.append(f"Mode: {el['mode']}")
if el.get("replacing"):
extras.append(f"Replacing: {el['replacing']}")
if extras:
print(f" {', '.join(extras)}")
print()
+65
View File
@@ -0,0 +1,65 @@
"""history 命令 — 查询对象版本历史。"""
from __future__ import annotations
import argparse
import logging
from sapcli.client import ADTClient
from sapcli.exceptions import ObjectNotFoundError
from sapcli.types import get_type_config, parse_object_name
logger = logging.getLogger("sapcli.commands.history")
def _format_date(value: str) -> str:
"""ISO 日期 ``2026-06-15T10:30:00`` → ``2026-06-15 10:30:00``。"""
if not value:
return ""
return value.replace("T", " ").rstrip("Z").strip()
def cmd_history(args: argparse.Namespace, client: ADTClient) -> None:
"""查询对象的版本历史。"""
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(" 对象版本历史")
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}")
raise ObjectNotFoundError(parsed.display_name, obj_type)
print(" ✓ 对象存在")
print(f"\n → 正在查询版本历史...")
try:
versions = client.get_object_versions(parsed.obj_uri)
except Exception as e:
print(f" ✗ 查询版本历史失败: {e}")
return
if not versions:
print(" 未找到版本历史")
return
print(f" ✓ 找到 {len(versions)} 个版本\n")
print(
f" {'Version':<10} {'Author':<12} {'Date':<22} Description"
)
print(
f" {'' * 10} {'' * 12} {'' * 22} {'' * 30}"
)
for v in versions:
version = v.get("version", "") or "active"
author = v.get("author", "")
date = _format_date(v.get("date", ""))
title = v.get("versionTitle", "")
print(f" {version:<10} {author:<12} {date:<22} {title}")
print()
+4
View File
@@ -25,6 +25,10 @@ _TYPE_FILTER_MAP: dict[str, str] = {
"cdsview": "DDLS/DF",
"messageclass": "MSAG",
"view": "VIEW",
"searchhelp": "SHLP",
"lockobject": "LOCK",
"dcl": "DCLS",
"ddlX": "DDLX",
}
+83
View File
@@ -0,0 +1,83 @@
"""unit-test 命令 — 执行 ABAP Unit 测试。"""
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.unit_test")
def _parse_duration(value: str) -> float:
"""安全解析方法耗时(秒),无法解析返回 0.0。"""
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def cmd_unit_test(args: argparse.Namespace, client: ADTClient) -> None:
"""执行 ABAP Unit 测试并汇总结果。"""
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(" ABAP Unit 测试")
print("=" * 60)
print(f" 对象类型: {type_label}")
print(f" 对象名称: {parsed.display_name}")
print(f"\n → 正在执行 ABAP Unit 测试...")
try:
result = client.run_unit_test(parsed.obj_uri)
except Exception as e:
print(f" ✗ 测试执行失败: {e}")
return
summary = result.get("summary", {})
classes = result.get("classes", [])
if not classes:
print(" ℹ 未找到测试类(对象可能不包含 ABAP Unit 测试)")
return
print()
for cls in classes:
cls_name = cls.get("name") or parsed.display_name
methods = cls.get("methods", [])
total_duration = sum(_parse_duration(m.get("duration", "")) for m in methods)
failed = [m for m in methods if m.get("alert")]
if not failed:
print(
f"{cls_name} {len(methods)} methods "
f"{total_duration:.2f}s PASSED"
)
else:
print(
f"{cls_name} {len(methods)} methods "
f"{total_duration:.2f}s FAILED"
)
for m in failed:
line_info = f" at line {m['line']}" if m.get("line") else ""
print(
f" method: {m['name']} FAILED: "
f"{m['alert']}{line_info}"
)
# 汇总
tests = summary.get("tests", "?")
failures = summary.get("failures", "0")
errors = summary.get("errors", "0")
skipped = summary.get("skipped", "0")
print(f"\n {'' * 50}")
print(
f" 合计: {tests} 个测试 | 失败 {failures} | 错误 {errors} | "
f"跳过 {skipped}"
)
print(f" {'' * 50}")