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:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: sap-cli
|
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."
|
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.2.0"
|
version: "2.2.1"
|
||||||
author: WuRangyu
|
author: WuRangyu
|
||||||
license: MIT
|
license: MIT
|
||||||
---
|
---
|
||||||
@@ -270,7 +270,7 @@ python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX
|
|||||||
2. **禁止修改 SAP 标准对象**(`CL_*`、`SAPL*` 等)— 只允许只读操作
|
2. **禁止修改 SAP 标准对象**(`CL_*`、`SAPL*` 等)— 只允许只读操作
|
||||||
3. **禁止未授权操作系统表**(TADIR、E071、SEOCLASS 等)— SELECT 诊断可以
|
3. **禁止未授权操作系统表**(TADIR、E071、SEOCLASS 等)— SELECT 诊断可以
|
||||||
4. **sync 必须带 `--corr_nr`** — 否则触发交互提示
|
4. **sync 必须带 `--corr_nr`** — 否则触发交互提示
|
||||||
5. **NW 7.40 DDIC Lock 返回 HTTP 406** — 系统限制,报告用户去 SE09
|
5. **非交互 delete 用管道确认** — `echo "yes" | python main.py delete ...`(否则 EOFError)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -278,19 +278,36 @@ python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX
|
|||||||
|
|
||||||
| 问题 | 原因 | 解决 |
|
| 问题 | 原因 | 解决 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| HTTP 406(DDIC Lock) | NW 7.40 不支持 DDIC ADT Lock | 报告用户 → SE09 |
|
| HTTP 406(DDIC sync Lock) | NW 7.40 DDIC sync 路径 lock 仍 406(delete 已修复) | 报告用户 → SE09 手动同步 DDIC |
|
||||||
| HTTP 403(Locked) | 残留 enqueue lock | 先试 `run-program` 清锁,不行 → SM12 |
|
| HTTP 403(Locked) | 残留 enqueue lock | 先试 `run-program` 清锁,不行 → SM12 |
|
||||||
| HTTP 400(SaveFailure) | 类 DEFINITION 与 SAP 不一致 | 对比本地 vs SAP 源码 |
|
| HTTP 400(SaveFailure) | 类 DEFINITION 与 SAP 不一致 | 对比本地 vs SAP 源码 |
|
||||||
|
| HTTP 404(DDIC download/sync) | NW 7.40 无 `/source/main` 端点(7.50+ 特性) | DDIC 对象只能 create/info/delete,不能 download/sync |
|
||||||
| HTTP 423(Transport lock) | 对象绑定在传输请求中 | 报告用户 → SE09 |
|
| HTTP 423(Transport lock) | 对象绑定在传输请求中 | 报告用户 → SE09 |
|
||||||
| 激活失败 | NW 7.40 ADT 限制 | sap-cli 内置 double-activate,仍失败 → SE09 |
|
| 激活失败 | NW 7.40 ADT 限制 | sap-cli 内置 double-activate,仍失败 → SE09 |
|
||||||
| `WITH EMPTY KEY` dump | NW 7.40 不支持 | 改用 `WITH NON-UNIQUE KEY` |
|
| `WITH EMPTY KEY` dump | NW 7.40 不支持 | 改用 `WITH NON-UNIQUE KEY` |
|
||||||
| 函数名格式错误 | 缺少 `/` 分隔符 | 使用 `ZGROUP/Z_FUNC` 格式 |
|
| 函数名格式错误 | 缺少 `/` 分隔符 | 使用 `ZGROUP/Z_FUNC` 格式 |
|
||||||
|
|
||||||
## NW 7.40 兼容性
|
## NW 7.40 兼容性(真实系统 E2E 验证 — v2.2.1)
|
||||||
|
|
||||||
- **DDIC Lock**:HTTP 406,无法通过 ADT 锁定 DDIC 对象
|
> 完整矩阵见 `docs/NW740-COMPATIBILITY.md`
|
||||||
|
|
||||||
|
### 完整可用(download → 编辑 → sync 全闭环)
|
||||||
|
|
||||||
|
`report` · `class` · `interface` · `function` · `functiongroup` · `include`
|
||||||
|
|
||||||
|
### 仅 CRUD 可用(create/info/delete,无源码编辑)
|
||||||
|
|
||||||
|
`domain` · `dataelement` — NW 7.40 的 DDIC 对象以 XML 存储于对象 URI,没有独立的 source/main 端点(7.50+ 特性)。即便 Eclipse ADT 在 7.40 上也无法编辑 DDIC 源码。
|
||||||
|
|
||||||
|
### 端点缺失(代码已就位,待更高版本验证)
|
||||||
|
|
||||||
|
`table` · `structure` · `tabletype` · `view` · `messageclass` · `searchhelp` · `lockobject` · `cdsview` — NW 7.40 ADT 端点层返回 404/415,请求未到达 XML 解析。
|
||||||
|
|
||||||
|
### 其他 NW 7.40 特性
|
||||||
|
|
||||||
|
- **DDIC Lock**:delete 已修复(专用 Accept 头),sync 路径仍 406
|
||||||
- **激活误报**:首次激活可能报失败但实际成功,sap-cli 已内置 double-activate
|
- **激活误报**:首次激活可能报失败但实际成功,sap-cli 已内置 double-activate
|
||||||
- **ABAP 语法限制**:不支持字符串模板 `|...|`、inline 声明 `DATA(...)`、`WITH EMPTY KEY`
|
- **ABAP 语法限制**:不支持字符串模板 `\|...\|`、inline 声明 `DATA(...)`、`WITH EMPTY KEY`
|
||||||
- **CSRF Token**:所有写操作必须携带,sap-cli 自动处理
|
- **CSRF Token**:所有写操作必须携带,sap-cli 自动处理
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
sap-cli — 基于 SAP ADT REST API 的 ABAP 开发对象管理工具
|
sap-cli — 基于 SAP ADT REST API 的 ABAP 开发对象管理工具
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.2.0"
|
__version__ = "2.2.1"
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ from sapcli.commands import (
|
|||||||
cmd_activate,
|
cmd_activate,
|
||||||
cmd_upload,
|
cmd_upload,
|
||||||
cmd_syntax_check,
|
cmd_syntax_check,
|
||||||
|
cmd_unit_test,
|
||||||
|
cmd_history,
|
||||||
|
cmd_clone,
|
||||||
|
cmd_enhancement,
|
||||||
)
|
)
|
||||||
from sapcli.auth import cmd_auth_login, cmd_auth_logout, cmd_auth_status
|
from sapcli.auth import cmd_auth_login, cmd_auth_logout, cmd_auth_status
|
||||||
from sapcli.exceptions import SapCliError, ConfigError
|
from sapcli.exceptions import SapCliError, ConfigError
|
||||||
@@ -101,6 +105,15 @@ def main() -> None:
|
|||||||
cmd_auth_status(args)
|
cmd_auth_status(args)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# clone 自行按 --from/--to profile 构建源/目标连接,跳过默认登录
|
||||||
|
if args.command == "clone":
|
||||||
|
try:
|
||||||
|
cmd_clone(args, None)
|
||||||
|
except SapCliError as e:
|
||||||
|
print(f"\n ✗ {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
return
|
||||||
|
|
||||||
# 加载配置
|
# 加载配置
|
||||||
try:
|
try:
|
||||||
sap_cfg, loaded_from = load_config(args.config, profile=getattr(args, "profile", None))
|
sap_cfg, loaded_from = load_config(args.config, profile=getattr(args, "profile", None))
|
||||||
@@ -146,6 +159,9 @@ def main() -> None:
|
|||||||
"show-table": cmd_show_table,
|
"show-table": cmd_show_table,
|
||||||
"read-table": cmd_read_table,
|
"read-table": cmd_read_table,
|
||||||
"run-program": cmd_run_program,
|
"run-program": cmd_run_program,
|
||||||
|
"unit-test": cmd_unit_test,
|
||||||
|
"history": cmd_history,
|
||||||
|
"enhancement": cmd_enhancement,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 批量 sync 参数校验
|
# 批量 sync 参数校验
|
||||||
|
|||||||
@@ -260,4 +260,27 @@ CDS View:
|
|||||||
p_run = subparsers.add_parser("run-program", help="远程执行 ABAP 程序 (SA38)")
|
p_run = subparsers.add_parser("run-program", help="远程执行 ABAP 程序 (SA38)")
|
||||||
p_run.add_argument("--name", required=True, help="程序名")
|
p_run.add_argument("--name", required=True, help="程序名")
|
||||||
|
|
||||||
|
# ── unit-test ──
|
||||||
|
sp_unit = subparsers.add_parser("unit-test", help="运行 ABAP Unit 测试")
|
||||||
|
sp_unit.add_argument("--name", required=True, help="对象名称")
|
||||||
|
sp_unit.add_argument("--type", required=True, help="对象类型")
|
||||||
|
|
||||||
|
# ── history ──
|
||||||
|
sp_history = subparsers.add_parser("history", help="查看对象版本历史")
|
||||||
|
sp_history.add_argument("--name", required=True, help="对象名称")
|
||||||
|
sp_history.add_argument("--type", required=True, help="对象类型")
|
||||||
|
|
||||||
|
# ── clone ──
|
||||||
|
sp_clone = subparsers.add_parser("clone", help="跨系统克隆对象")
|
||||||
|
sp_clone.add_argument("--name", required=True, help="对象名称")
|
||||||
|
sp_clone.add_argument("--type", required=True, help="对象类型")
|
||||||
|
sp_clone.add_argument("--from", dest="from_profile", required=True, help="源系统 profile")
|
||||||
|
sp_clone.add_argument("--to", dest="to_profile", required=True, help="目标系统 profile")
|
||||||
|
sp_clone.add_argument("--corr_nr", default=None, help="目标系统的传输请求号")
|
||||||
|
|
||||||
|
# ── enhancement ──
|
||||||
|
sp_enh = subparsers.add_parser("enhancement", help="查询对象的增强实现")
|
||||||
|
sp_enh.add_argument("--name", required=True, help="对象名称")
|
||||||
|
sp_enh.add_argument("--type", required=True, help="对象类型")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|||||||
@@ -12,6 +12,41 @@ from sapcli.types import ObjectTypeConfig, get_type_config
|
|||||||
logger = logging.getLogger("sapcli.client")
|
logger = logging.getLogger("sapcli.client")
|
||||||
|
|
||||||
|
|
||||||
|
def _local(tag: str) -> str:
|
||||||
|
"""返回 XML 标签/属性名的本地部分(去掉 ``{namespace}`` 前缀)。"""
|
||||||
|
if tag and tag[0] == "{":
|
||||||
|
return tag.split("}", 1)[1]
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
def _attr_local(el: ET.Element, name: str) -> str:
|
||||||
|
"""按本地名查找元素属性值(忽略命名空间)。
|
||||||
|
|
||||||
|
例如 ``adtcore:name`` 与 ``name`` 都能匹配 ``name``。
|
||||||
|
"""
|
||||||
|
for key, val in el.attrib.items():
|
||||||
|
if _local(key) == name:
|
||||||
|
return val
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_local(container: ET.Element, name: str) -> ET.Element | None:
|
||||||
|
"""返回容器(含自身)下首个本地标签名为 ``name`` 的元素。"""
|
||||||
|
for el in container.iter():
|
||||||
|
if _local(el.tag) == name:
|
||||||
|
return el
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ADT lock 端点返回 ABAP 结构 XML。DDIC 对象(domain/dataelement/table/structure)
|
||||||
|
# 的 lock 端点对默认 Accept: */* 返回 HTTP 406,必须显式请求 lock 结果类型。
|
||||||
|
# 参考 abap-adt-api objectcontents.ts 的 lock 实现。
|
||||||
|
LOCK_RESULT_ACCEPT = (
|
||||||
|
"application/*,application/vnd.sap.as+xml;charset=UTF-8;"
|
||||||
|
"dataname=com.sap.adt.lock.result"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DdicMixin:
|
class DdicMixin:
|
||||||
"""Object creation / deletion, DDIC operations, CDS, packages, ATC, pretty-print."""
|
"""Object creation / deletion, DDIC operations, CDS, packages, ATC, pretty-print."""
|
||||||
|
|
||||||
@@ -24,7 +59,8 @@ class DdicMixin:
|
|||||||
obj_uri: str,
|
obj_uri: str,
|
||||||
corr_nr: str | None = None,
|
corr_nr: str | None = None,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
lock_handle, _ = self.lock(obj_uri, corr_nr)
|
# DDIC 对象的 lock 端点对 Accept: */* 返回 406,需传 lock 结果专用 Accept 头
|
||||||
|
lock_handle, _ = self.lock(obj_uri, corr_nr, accept=LOCK_RESULT_ACCEPT)
|
||||||
url = f"{self.host}{obj_uri}"
|
url = f"{self.host}{obj_uri}"
|
||||||
params: dict[str, str] = {"lockHandle": lock_handle}
|
params: dict[str, str] = {"lockHandle": lock_handle}
|
||||||
if corr_nr:
|
if corr_nr:
|
||||||
@@ -193,6 +229,19 @@ class DdicMixin:
|
|||||||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||||||
"</tabl:table>"
|
"</tabl:table>"
|
||||||
)
|
)
|
||||||
|
elif obj_type == "structure":
|
||||||
|
# 结构体(TABL/DS):参考 table,但使用 structures 命名空间
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<struc:structure xmlns:struc="http://www.sap.com/adt/dictionary/structures"'
|
||||||
|
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||||||
|
f' adtcore:name="{name}"'
|
||||||
|
f' adtcore:type="TABL/DS"'
|
||||||
|
f' adtcore:description="{desc}"'
|
||||||
|
' adtcore:language="EN">'
|
||||||
|
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||||||
|
"</struc:structure>"
|
||||||
|
)
|
||||||
elif obj_type == "tabletype":
|
elif obj_type == "tabletype":
|
||||||
return (
|
return (
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
@@ -204,6 +253,75 @@ class DdicMixin:
|
|||||||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||||||
"</ttyp:abapTableType>"
|
"</ttyp:abapTableType>"
|
||||||
)
|
)
|
||||||
|
elif obj_type == "include":
|
||||||
|
# POST /programs/includes/{name} — include 使用 programs/includes 命名空间,
|
||||||
|
# 不能复用 report 的 <program:abapProgram>(否则端点返回 400)。
|
||||||
|
# 参考 abap-adt-api objectcreator.ts:
|
||||||
|
# rootName="include:abapInclude"
|
||||||
|
# nameSpace="http://www.sap.com/adt/programs/includes"
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<include:abapInclude xmlns:include="http://www.sap.com/adt/programs/includes"'
|
||||||
|
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||||||
|
f' adtcore:name="{name}"'
|
||||||
|
' adtcore:type="PROG/I"'
|
||||||
|
f' adtcore:description="{desc}">'
|
||||||
|
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||||||
|
"</include:abapInclude>"
|
||||||
|
)
|
||||||
|
elif obj_type == "messageclass":
|
||||||
|
# POST /oo/t100/messages/classes/{name} — body 含 class name + description
|
||||||
|
# // TODO: verify XML structure on live SAP system
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<msgcls:t100MessageClass xmlns:msgcls="http://www.sap.com/adt/t100/message/classes"'
|
||||||
|
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||||||
|
f' adtcore:name="{name}"'
|
||||||
|
' adtcore:type="MSAG"'
|
||||||
|
f' adtcore:description="{desc}">'
|
||||||
|
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||||||
|
"</msgcls:t100MessageClass>"
|
||||||
|
)
|
||||||
|
elif obj_type == "view":
|
||||||
|
# POST /ddic/views/{name}(数据库视图)— body 结构参考 table 的创建
|
||||||
|
# // TODO: verify XML structure on live SAP system
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<view:view xmlns:view="http://www.sap.com/adt/dictionary/views"'
|
||||||
|
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||||||
|
f' adtcore:name="{name}"'
|
||||||
|
f' adtcore:type="VIEW"'
|
||||||
|
f' adtcore:description="{desc}"'
|
||||||
|
' adtcore:language="EN">'
|
||||||
|
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||||||
|
"</view:view>"
|
||||||
|
)
|
||||||
|
elif obj_type == "searchhelp":
|
||||||
|
# POST /ddic/searchhelps/{name} — body 含 search help 基本定义
|
||||||
|
# // TODO: verify XML structure on live SAP system
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<shlp:searchHelp xmlns:shlp="http://www.sap.com/adt/dictionary/searchhelps"'
|
||||||
|
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||||||
|
f' adtcore:name="{name}"'
|
||||||
|
f' adtcore:type="SHLP"'
|
||||||
|
f' adtcore:description="{desc}">'
|
||||||
|
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||||||
|
"</shlp:searchHelp>"
|
||||||
|
)
|
||||||
|
elif obj_type == "lockobject":
|
||||||
|
# POST /ddic/lockobjects/{name} — body 含 lock mode + table name
|
||||||
|
# // TODO: verify XML structure on live SAP system (lock mode + table name)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<lock:lockObject xmlns:lock="http://www.sap.com/adt/dictionary/lockobjects"'
|
||||||
|
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||||||
|
f' adtcore:name="{name}"'
|
||||||
|
f' adtcore:type="LOCK"'
|
||||||
|
f' adtcore:description="{desc}">'
|
||||||
|
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||||||
|
"</lock:lockObject>"
|
||||||
|
)
|
||||||
raise ValueError(f"不支持的对象类型: {obj_type}")
|
raise ValueError(f"不支持的对象类型: {obj_type}")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -222,6 +340,31 @@ class DdicMixin:
|
|||||||
src_uri = config.format_src_uri(name.lower())
|
src_uri = config.format_src_uri(name.lower())
|
||||||
|
|
||||||
if obj_type in ("table", "structure"):
|
if obj_type in ("table", "structure"):
|
||||||
|
# 先 POST 集合端点创建对象实体,再 PUT 写入 DDL 定义。
|
||||||
|
# 之前直接 _put_ddl_source,PUT 到尚未创建的对象 /source/main 返回 405。
|
||||||
|
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 ENTITY: POST %s name=%s",
|
||||||
|
collection_url,
|
||||||
|
obj_name,
|
||||||
|
)
|
||||||
|
resp = self.session.post(
|
||||||
|
f"{self.host}{collection_url}",
|
||||||
|
headers=hdrs,
|
||||||
|
params=params,
|
||||||
|
data=body.encode("utf-8"),
|
||||||
|
)
|
||||||
|
logger.info("CREATE DDIC ENTITY RESPONSE: HTTP %s", resp.status_code)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise CreateError(
|
||||||
|
f"创建 DDIC 对象实体失败: HTTP {resp.status_code} — {resp.text[:500]}"
|
||||||
|
)
|
||||||
self._put_ddl_source(src_uri, definition_body, "", corr_nr)
|
self._put_ddl_source(src_uri, definition_body, "", corr_nr)
|
||||||
else:
|
else:
|
||||||
obj_name = name.upper()
|
obj_name = name.upper()
|
||||||
@@ -754,3 +897,288 @@ class DdicMixin:
|
|||||||
logger.info("RUN PROGRAM RESPONSE: HTTP %s (%d bytes)", resp.status_code, len(resp.content))
|
logger.info("RUN PROGRAM RESPONSE: HTTP %s (%d bytes)", resp.status_code, len(resp.content))
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.text
|
return resp.text
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ABAP Unit tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_unit_test(self, obj_uri: str) -> dict[str, Any]:
|
||||||
|
"""执行 ABAP Unit 测试。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj_uri: 被测对象的 ADT URI。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{"summary": {...}, "classes": [{"name", "methods": [...]}]}``。
|
||||||
|
每个方法项含 ``name``, ``duration``, ``alert``(失败原因), ``line``。
|
||||||
|
"""
|
||||||
|
url = f"{self.host}/sap/bc/adt/abapunit/testruns"
|
||||||
|
body = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<aunit:runConfiguration xmlns:aunit="http://www.sap.com/adt/aunit">'
|
||||||
|
"<external><coverage active=\"false\"/></external>"
|
||||||
|
"<options>"
|
||||||
|
'<uriType value="semantic"/>'
|
||||||
|
'<testDeterminationStrategy sameProgram="true" assignedTests="false"/>'
|
||||||
|
'<testRiskLevels harmless="true" dangerous="true" critical="true"/>'
|
||||||
|
'<testDurations short="true" medium="true" long="true"/>'
|
||||||
|
'<withNavigationUri enabled="true"/>'
|
||||||
|
"</options>"
|
||||||
|
'<adtcore:objectSets xmlns:adtcore="http://www.sap.com/adt/core">'
|
||||||
|
'<objectSet kind="inclusive">'
|
||||||
|
"<adtcore:objectReferences>"
|
||||||
|
f'<adtcore:objectReference adtcore:uri="{obj_uri}"/>'
|
||||||
|
"</adtcore:objectReferences>"
|
||||||
|
"</objectSet>"
|
||||||
|
"</adtcore:objectSets>"
|
||||||
|
"</aunit:runConfiguration>"
|
||||||
|
)
|
||||||
|
hdrs = self._headers("application/*")
|
||||||
|
hdrs["Accept"] = "application/*"
|
||||||
|
logger.info("RUN UNIT TEST: POST %s uri=%s", url, obj_uri)
|
||||||
|
resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8"))
|
||||||
|
logger.info("RUN UNIT TEST RESPONSE: HTTP %s", resp.status_code)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return self._parse_aunit_result(resp.content)
|
||||||
|
|
||||||
|
def _parse_aunit_result(self, content: bytes) -> dict[str, Any]:
|
||||||
|
"""解析 ABAP Unit 测试结果 XML。
|
||||||
|
|
||||||
|
响应结构(aunit + adtcore 命名空间)::
|
||||||
|
|
||||||
|
<aunit:runResult>
|
||||||
|
<summary tests="3" failures="1" errors="0" skipped="0" duration="450"/>
|
||||||
|
<program adtcore:name="...">
|
||||||
|
<testClasses>
|
||||||
|
<testClass adtcore:name="ZCL_MY_TEST">
|
||||||
|
<testMethods>
|
||||||
|
<testMethod adtcore:name="test_foo" duration="0.45">
|
||||||
|
<alert kind="t" title="assertion error">
|
||||||
|
<stackInfo><info line="42"/></stackInfo>
|
||||||
|
</alert>
|
||||||
|
</testMethod>
|
||||||
|
</testMethods>
|
||||||
|
</testClass>
|
||||||
|
</testClasses>
|
||||||
|
</program>
|
||||||
|
</aunit:runResult>
|
||||||
|
|
||||||
|
解析逻辑按本地标签名匹配,兼容命名空间前缀变化。
|
||||||
|
"""
|
||||||
|
# TODO: verify XML structure on live SAP system
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"tests": "0", "failures": "0", "errors": "0",
|
||||||
|
"skipped": "0", "duration": "0",
|
||||||
|
}
|
||||||
|
summary_el = _find_local(root, "summary")
|
||||||
|
if summary_el is not None:
|
||||||
|
for key in summary:
|
||||||
|
summary[key] = summary_el.attrib.get(key, summary[key])
|
||||||
|
|
||||||
|
classes: list[dict[str, Any]] = []
|
||||||
|
for tc in root.iter():
|
||||||
|
if _local(tc.tag) != "testClass":
|
||||||
|
continue
|
||||||
|
cls_name = _attr_local(tc, "name")
|
||||||
|
methods: list[dict[str, str]] = []
|
||||||
|
for tm in tc.iter():
|
||||||
|
if _local(tm.tag) != "testMethod":
|
||||||
|
continue
|
||||||
|
m_name = _attr_local(tm, "name")
|
||||||
|
duration = tm.attrib.get("duration", "")
|
||||||
|
alert_title = ""
|
||||||
|
alert_line = ""
|
||||||
|
for alert in tm.iter():
|
||||||
|
if _local(alert.tag) != "alert":
|
||||||
|
continue
|
||||||
|
if not alert_title:
|
||||||
|
alert_title = alert.attrib.get("title", "")
|
||||||
|
info = _find_local(alert, "info")
|
||||||
|
if info is not None:
|
||||||
|
line = info.attrib.get("line", "")
|
||||||
|
if line:
|
||||||
|
alert_line = line
|
||||||
|
methods.append({
|
||||||
|
"name": m_name,
|
||||||
|
"duration": duration,
|
||||||
|
"alert": alert_title,
|
||||||
|
"line": alert_line,
|
||||||
|
})
|
||||||
|
classes.append({"name": cls_name, "methods": methods})
|
||||||
|
|
||||||
|
return {"summary": summary, "classes": classes}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Version history
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_object_versions(self, obj_uri: str) -> list[dict[str, str]]:
|
||||||
|
"""查询对象的版本历史。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj_uri: 对象 ADT URI。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
版本列表,每项含 ``version``, ``author``, ``date``, ``versionTitle``。
|
||||||
|
"""
|
||||||
|
versions_href = self._get_versions_link(obj_uri)
|
||||||
|
if not versions_href:
|
||||||
|
return []
|
||||||
|
|
||||||
|
url = (
|
||||||
|
versions_href
|
||||||
|
if versions_href.startswith("http")
|
||||||
|
else f"{self.host}{versions_href}"
|
||||||
|
)
|
||||||
|
hdrs = self._headers()
|
||||||
|
hdrs["Accept"] = "application/xml"
|
||||||
|
logger.info("GET VERSIONS: GET %s", url)
|
||||||
|
resp = self.session.get(url, headers=hdrs)
|
||||||
|
logger.info("GET VERSIONS RESPONSE: HTTP %s", resp.status_code)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return self._parse_versions(resp.content)
|
||||||
|
|
||||||
|
def _get_versions_link(self, obj_uri: str) -> str | None:
|
||||||
|
"""GET 对象结构 XML,提取 versions 关系链接的 href。"""
|
||||||
|
url = f"{self.host}{obj_uri}"
|
||||||
|
hdrs = self._headers()
|
||||||
|
hdrs["Accept"] = "application/xml"
|
||||||
|
logger.info("GET OBJECT STRUCTURE: GET %s", url)
|
||||||
|
resp = self.session.get(url, headers=hdrs)
|
||||||
|
logger.info("GET OBJECT STRUCTURE RESPONSE: HTTP %s", resp.status_code)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(resp.content)
|
||||||
|
except ET.ParseError:
|
||||||
|
return None
|
||||||
|
for el in root.iter():
|
||||||
|
if _local(el.tag) != "link":
|
||||||
|
continue
|
||||||
|
if "versions" in el.attrib.get("rel", ""):
|
||||||
|
href = el.attrib.get("href", "")
|
||||||
|
if href:
|
||||||
|
return href
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_versions(self, content: bytes) -> list[dict[str, str]]:
|
||||||
|
"""解析版本历史 feed。
|
||||||
|
|
||||||
|
典型结构(Atom feed,每个 entry 含版本元数据属性)::
|
||||||
|
|
||||||
|
<atom:feed>
|
||||||
|
<atom:entry>
|
||||||
|
<app:control>
|
||||||
|
<d:version d:versionId="0001" d:author="DEVUSER"
|
||||||
|
d:date="2026-06-10T14:20:00" d:versionTitle="Initial"/>
|
||||||
|
</app:control>
|
||||||
|
</atom:entry>
|
||||||
|
</atom:feed>
|
||||||
|
|
||||||
|
按本地名(忽略命名空间)匹配属性,兼容 ``versionId`` / ``version``。
|
||||||
|
"""
|
||||||
|
# TODO: verify XML structure on live SAP system
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
except ET.ParseError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
fields = ("version", "author", "date", "versionTitle")
|
||||||
|
alias = {"versionId": "version"}
|
||||||
|
|
||||||
|
def extract(container: ET.Element) -> dict[str, str]:
|
||||||
|
v = {f: "" for f in fields}
|
||||||
|
for el in container.iter():
|
||||||
|
for key, val in el.attrib.items():
|
||||||
|
field = alias.get(_local(key), _local(key))
|
||||||
|
if field in fields and not v[field]:
|
||||||
|
v[field] = val
|
||||||
|
return v
|
||||||
|
|
||||||
|
entries = [e for e in root.iter() if _local(e.tag) == "entry"]
|
||||||
|
if entries:
|
||||||
|
return [extract(e) for e in entries]
|
||||||
|
|
||||||
|
# 回退:无 entry 时,凡携带 version/versionId 的元素各成一行
|
||||||
|
versions: list[dict[str, str]] = []
|
||||||
|
for el in root.iter():
|
||||||
|
if any(_local(k) in ("version", "versionId") for k in el.attrib):
|
||||||
|
versions.append(extract(el))
|
||||||
|
return versions
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Enhancements
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_enhancements(self, obj_uri: str) -> list[dict[str, Any]]:
|
||||||
|
"""查询对象的增强实现(ENHO)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj_uri: 被增强对象的 ADT URI。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
增强实现列表,每项含 ``name``, ``type``, ``enhanced_name``,
|
||||||
|
``enhanced_type``, ``elements``(含 ``name``/``type``/``mode``/``replacing``)。
|
||||||
|
"""
|
||||||
|
url = f"{self.host}/sap/bc/adt/enhancements"
|
||||||
|
body = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<enh:enhancementSearch xmlns:enh="http://www.sap.com/adt/enhancements"'
|
||||||
|
' xmlns:adtcore="http://www.sap.com/adt/core">'
|
||||||
|
"<adtcore:objectReferences>"
|
||||||
|
f'<adtcore:objectReference adtcore:uri="{obj_uri}"/>'
|
||||||
|
"</adtcore:objectReferences>"
|
||||||
|
"</enh:enhancementSearch>"
|
||||||
|
)
|
||||||
|
hdrs = self._headers()
|
||||||
|
logger.info("GET ENHANCEMENTS: POST %s uri=%s", url, obj_uri)
|
||||||
|
resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8"))
|
||||||
|
logger.info("GET ENHANCEMENTS RESPONSE: HTTP %s", resp.status_code)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return self._parse_enhancements(resp.content)
|
||||||
|
|
||||||
|
def _parse_enhancements(self, content: bytes) -> list[dict[str, Any]]:
|
||||||
|
"""解析增强实现列表 XML。
|
||||||
|
|
||||||
|
元素标签名与命名空间在不同 SAP 版本差异较大,故按本地名 + ENHO 类型码
|
||||||
|
匹配增强实现,再在其下查找被增强对象与插件元素。
|
||||||
|
"""
|
||||||
|
# TODO: verify XML structure on live SAP system
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
except ET.ParseError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for el in root.iter():
|
||||||
|
local = _local(el.tag)
|
||||||
|
otype = _attr_local(el, "type")
|
||||||
|
is_enh = local in (
|
||||||
|
"enhancement", "enhancementImplementation", "enhancementSpotUse",
|
||||||
|
) or "ENHO" in otype
|
||||||
|
if not is_enh:
|
||||||
|
continue
|
||||||
|
|
||||||
|
enhanced_obj = _find_local(el, "enhancedObject")
|
||||||
|
if enhanced_obj is None:
|
||||||
|
enhanced_obj = _find_local(el, "enhanced")
|
||||||
|
elements: list[dict[str, str]] = []
|
||||||
|
for pe in el.iter():
|
||||||
|
if _local(pe.tag) not in ("pluginElement", "element", "sourceCodePluginElement"):
|
||||||
|
continue
|
||||||
|
elements.append({
|
||||||
|
"name": _attr_local(pe, "elementName") or _attr_local(pe, "name"),
|
||||||
|
"type": _attr_local(pe, "elementType") or _attr_local(pe, "type"),
|
||||||
|
"mode": _attr_local(pe, "mode"),
|
||||||
|
"replacing": _attr_local(pe, "replacing"),
|
||||||
|
})
|
||||||
|
results.append({
|
||||||
|
"name": _attr_local(el, "name"),
|
||||||
|
"type": otype or "ENHO",
|
||||||
|
"enhanced_name": _attr_local(enhanced_obj, "name") if enhanced_obj is not None else "",
|
||||||
|
"enhanced_type": _attr_local(enhanced_obj, "type") if enhanced_obj is not None else "",
|
||||||
|
"elements": elements,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ from sapcli.commands.upload import (
|
|||||||
from sapcli.commands.syntax_check import (
|
from sapcli.commands.syntax_check import (
|
||||||
cmd_syntax_check,
|
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__ = [
|
__all__ = [
|
||||||
"cmd_create",
|
"cmd_create",
|
||||||
@@ -88,4 +100,8 @@ __all__ = [
|
|||||||
"cmd_activate",
|
"cmd_activate",
|
||||||
"cmd_upload",
|
"cmd_upload",
|
||||||
"cmd_syntax_check",
|
"cmd_syntax_check",
|
||||||
|
"cmd_unit_test",
|
||||||
|
"cmd_history",
|
||||||
|
"cmd_clone",
|
||||||
|
"cmd_enhancement",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -56,6 +56,39 @@ DEFAULT_TEMPLATES: dict[str, str] = {
|
|||||||
' METHODS: hello.\n'
|
' METHODS: hello.\n'
|
||||||
'ENDINTERFACE.\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" 保存路径: {save_dir}")
|
||||||
|
|
||||||
print(f"\n → 检查对象是否存在...")
|
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" ✗ 对象不存在: {parsed.display_name}")
|
||||||
print(f" 请确认名称和类型是否正确")
|
print(f" 请确认名称和类型是否正确")
|
||||||
raise ObjectNotFoundError(parsed.display_name, obj_type)
|
raise ObjectNotFoundError(parsed.display_name, obj_type)
|
||||||
@@ -210,7 +243,7 @@ def _sync_single(
|
|||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"\n → 检查对象是否存在...")
|
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:
|
if not quiet:
|
||||||
print(f" ℹ 对象不存在,自动创建空对象...")
|
print(f" ℹ 对象不存在,自动创建空对象...")
|
||||||
try:
|
try:
|
||||||
@@ -386,6 +419,15 @@ def cmd_info(args: argparse.Namespace, client: ADTClient) -> None:
|
|||||||
"dataelement": "*/*",
|
"dataelement": "*/*",
|
||||||
"table": "*/*",
|
"table": "*/*",
|
||||||
"tabletype": "*/*",
|
"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)
|
print("=" * 60)
|
||||||
@@ -471,7 +513,7 @@ def cmd_delete(args: argparse.Namespace, client: ADTClient) -> None:
|
|||||||
print(f" 对象名称: {parsed.display_name}")
|
print(f" 对象名称: {parsed.display_name}")
|
||||||
|
|
||||||
print(f"\n → 检查对象是否存在...")
|
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" ✗ 对象不存在: {parsed.display_name}")
|
||||||
print(f" 请确认名称和类型是否正确")
|
print(f" 请确认名称和类型是否正确")
|
||||||
raise ObjectNotFoundError(parsed.display_name, obj_type)
|
raise ObjectNotFoundError(parsed.display_name, obj_type)
|
||||||
@@ -639,7 +681,7 @@ def cmd_create(args: argparse.Namespace, client: ADTClient) -> None:
|
|||||||
print(f"\n → 检查对象是否存在...")
|
print(f"\n → 检查对象是否存在...")
|
||||||
if obj_type != "functiongroup":
|
if obj_type != "functiongroup":
|
||||||
parsed = parse_object_name(name, obj_type)
|
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}")
|
print(f" ✗ 对象已存在: {display_name}")
|
||||||
raise ObjectAlreadyExistsError(display_name, type_label)
|
raise ObjectAlreadyExistsError(display_name, type_label)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -25,6 +25,10 @@ _TYPE_FILTER_MAP: dict[str, str] = {
|
|||||||
"cdsview": "DDLS/DF",
|
"cdsview": "DDLS/DF",
|
||||||
"messageclass": "MSAG",
|
"messageclass": "MSAG",
|
||||||
"view": "VIEW",
|
"view": "VIEW",
|
||||||
|
"searchhelp": "SHLP",
|
||||||
|
"lockobject": "LOCK",
|
||||||
|
"dcl": "DCLS",
|
||||||
|
"ddlX": "DDLX",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -26,6 +26,8 @@ DIRECTORY_TYPE_MAP: dict[str, str] = {
|
|||||||
"views": "view",
|
"views": "view",
|
||||||
"searchhelps": "searchhelp",
|
"searchhelps": "searchhelp",
|
||||||
"lockobjects": "lockobject",
|
"lockobjects": "lockobject",
|
||||||
|
"dcl": "dcl",
|
||||||
|
"ddlx": "ddlX",
|
||||||
}
|
}
|
||||||
|
|
||||||
FUNCTIONS_DIR = "functions"
|
FUNCTIONS_DIR = "functions"
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ TYPE_PRIORITY: dict[str, int] = {
|
|||||||
"include": 78,
|
"include": 78,
|
||||||
"cdsview": 79,
|
"cdsview": 79,
|
||||||
"report": 80,
|
"report": 80,
|
||||||
|
# DCL/DDLX 依赖 CDS View,排在 cdsview(79)/report(80) 之后
|
||||||
|
"dcl": 81,
|
||||||
|
"ddlX": 82,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 未注册类型的默认优先级
|
# 未注册类型的默认优先级
|
||||||
|
|||||||
+42
-3
@@ -14,6 +14,10 @@ class ObjectTypeConfig:
|
|||||||
collection_uri: str
|
collection_uri: str
|
||||||
create_content_type: str
|
create_content_type: str
|
||||||
has_source: bool = True
|
has_source: bool = True
|
||||||
|
# 存在性检查专用端点模板;为 None 时回退到 obj_uri_template。
|
||||||
|
# 个别类型(如 tabletype)的 obj_uri 是 VIT 端点,对任意名称都返回 200,
|
||||||
|
# 无法区分存在/不存在,因此存在性检查需走标准 DDIC 端点。
|
||||||
|
exists_uri_template: str | None = None
|
||||||
|
|
||||||
def format_obj_uri(self, name: str, **kwargs: str) -> str:
|
def format_obj_uri(self, name: str, **kwargs: str) -> str:
|
||||||
return self.obj_uri_template.format(name=name, **kwargs)
|
return self.obj_uri_template.format(name=name, **kwargs)
|
||||||
@@ -23,6 +27,11 @@ class ObjectTypeConfig:
|
|||||||
return None
|
return None
|
||||||
return self.src_uri_template.format(name=name, **kwargs)
|
return self.src_uri_template.format(name=name, **kwargs)
|
||||||
|
|
||||||
|
def format_exists_uri(self, name: str, **kwargs: str) -> str:
|
||||||
|
"""格式化存在性检查端点。无专用模板时回退到 obj_uri_template。"""
|
||||||
|
template = self.exists_uri_template or self.obj_uri_template
|
||||||
|
return template.format(name=name, **kwargs)
|
||||||
|
|
||||||
def format_collection_uri(self, **kwargs: str) -> str:
|
def format_collection_uri(self, **kwargs: str) -> str:
|
||||||
return self.collection_uri.format(**kwargs)
|
return self.collection_uri.format(**kwargs)
|
||||||
|
|
||||||
@@ -125,6 +134,8 @@ TABLETYPE = _register(ObjectTypeConfig(
|
|||||||
collection_uri="/sap/bc/adt/ddic/tabletypes",
|
collection_uri="/sap/bc/adt/ddic/tabletypes",
|
||||||
create_content_type="application/vnd.sap.adt.ddic.tabletypes.v1+xml",
|
create_content_type="application/vnd.sap.adt.ddic.tabletypes.v1+xml",
|
||||||
has_source=False,
|
has_source=False,
|
||||||
|
# VIT obj_uri 对任意名称都返回 200,存在性检查必须走 DDIC 端点
|
||||||
|
exists_uri_template="/sap/bc/adt/ddic/tabletypes/{name}",
|
||||||
))
|
))
|
||||||
|
|
||||||
INCLUDE = _register(ObjectTypeConfig(
|
INCLUDE = _register(ObjectTypeConfig(
|
||||||
@@ -139,9 +150,30 @@ INCLUDE = _register(ObjectTypeConfig(
|
|||||||
CDSVIEW = _register(ObjectTypeConfig(
|
CDSVIEW = _register(ObjectTypeConfig(
|
||||||
key="cdsview",
|
key="cdsview",
|
||||||
label="CDS视图(CDS VIEW)",
|
label="CDS视图(CDS VIEW)",
|
||||||
obj_uri_template="/sap/bc/adt/dds/ddl/sources/{name}",
|
# 与 commands/cds.py 和 client/_ddic.py 保持一致:/ddic/ddlsources(NW 7.40 验证可用)
|
||||||
src_uri_template="/sap/bc/adt/dds/ddl/sources/{name}/content",
|
obj_uri_template="/sap/bc/adt/ddic/ddlsources/{name}",
|
||||||
collection_uri="/sap/bc/adt/dds/ddl/sources",
|
src_uri_template="/sap/bc/adt/ddic/ddlsources/{name}/source/main",
|
||||||
|
collection_uri="/sap/bc/adt/ddic/ddlsources",
|
||||||
|
create_content_type="text/plain",
|
||||||
|
))
|
||||||
|
|
||||||
|
DCL = _register(ObjectTypeConfig(
|
||||||
|
key="dcl",
|
||||||
|
label="访问控制(DCL)",
|
||||||
|
# CDS Access Control,端点 /ddic/dclsources(参考 docs/dev/adt-endpoint-reference.md)
|
||||||
|
obj_uri_template="/sap/bc/adt/ddic/dclsources/{name}",
|
||||||
|
src_uri_template="/sap/bc/adt/ddic/dclsources/{name}/source/main",
|
||||||
|
collection_uri="/sap/bc/adt/ddic/dclsources",
|
||||||
|
create_content_type="text/plain",
|
||||||
|
))
|
||||||
|
|
||||||
|
DDLX = _register(ObjectTypeConfig(
|
||||||
|
key="ddlX",
|
||||||
|
label="元数据扩展(DDLX)",
|
||||||
|
# Metadata Extension,端点 /ddic/metadataextensions(参考 docs/dev/adt-endpoint-reference.md)
|
||||||
|
obj_uri_template="/sap/bc/adt/ddic/metadataextensions/{name}",
|
||||||
|
src_uri_template="/sap/bc/adt/ddic/metadataextensions/{name}/source/main",
|
||||||
|
collection_uri="/sap/bc/adt/ddic/metadataextensions",
|
||||||
create_content_type="text/plain",
|
create_content_type="text/plain",
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -201,6 +233,9 @@ class ParsedName:
|
|||||||
src_uri: str | None
|
src_uri: str | None
|
||||||
display_name: str
|
display_name: str
|
||||||
file_base: str
|
file_base: str
|
||||||
|
# 存在性检查专用端点;默认与 obj_uri 相同。tabletype 等类型的 obj_uri
|
||||||
|
# 不能用于存在性检查,需走标准 DDIC 端点。
|
||||||
|
exists_uri: str = ""
|
||||||
|
|
||||||
|
|
||||||
def parse_object_name(name: str, obj_type: str) -> ParsedName:
|
def parse_object_name(name: str, obj_type: str) -> ParsedName:
|
||||||
@@ -221,9 +256,11 @@ def parse_object_name(name: str, obj_type: str) -> ParsedName:
|
|||||||
src_uri=cfg.format_src_uri(fm.lower(), group=group.lower()),
|
src_uri=cfg.format_src_uri(fm.lower(), group=group.lower()),
|
||||||
display_name=name,
|
display_name=name,
|
||||||
file_base=fm.lower(),
|
file_base=fm.lower(),
|
||||||
|
exists_uri=cfg.format_exists_uri(fm.lower(), group=group.lower()),
|
||||||
)
|
)
|
||||||
|
|
||||||
# VIT 端点需要大写名称以返回完整元数据;去掉前导 /
|
# VIT 端点需要大写名称以返回完整元数据;去掉前导 /
|
||||||
|
# 但 VIT 对存在性检查无效(任意名称都返回 200),exists_uri 走 DDIC 端点(小写名)
|
||||||
if obj_type == "tabletype":
|
if obj_type == "tabletype":
|
||||||
clean = name.lstrip("/")
|
clean = name.lstrip("/")
|
||||||
name_upper = clean.upper()
|
name_upper = clean.upper()
|
||||||
@@ -232,6 +269,7 @@ def parse_object_name(name: str, obj_type: str) -> ParsedName:
|
|||||||
src_uri=cfg.format_src_uri(name_upper),
|
src_uri=cfg.format_src_uri(name_upper),
|
||||||
display_name=clean,
|
display_name=clean,
|
||||||
file_base=clean.lower(),
|
file_base=clean.lower(),
|
||||||
|
exists_uri=cfg.format_exists_uri(clean.lower()),
|
||||||
)
|
)
|
||||||
|
|
||||||
name_lower = name.lower()
|
name_lower = name.lower()
|
||||||
@@ -240,4 +278,5 @@ def parse_object_name(name: str, obj_type: str) -> ParsedName:
|
|||||||
src_uri=cfg.format_src_uri(name_lower),
|
src_uri=cfg.format_src_uri(name_lower),
|
||||||
display_name=name,
|
display_name=name,
|
||||||
file_base=name_lower,
|
file_base=name_lower,
|
||||||
|
exists_uri=cfg.format_exists_uri(name_lower),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user