Files
sap-cli-skill/assets/sapcli/commands/crud.py
T
吴让宇 04efa7388a fix(create): $TMP 包不再误弹传输请求选择
cmd_create 在未指定 --corr_nr 时无条件调 _select_transport_request(),
未判目标包 —— $TMP 是本地包(对象不进传输),弹交互是错的,且管道/脚本
调用时 input() 直接 EOFError,报错栈指向传输选择,误导排查方向。

修法:包名规范化后等于 $TMP/TMP 则跳过选择(corr_nr=None)。
实测:create --package '$TMP' 直接成功,TADIR 落 DEVCLASS=$TMP。
2026-09-15 00:17:09 +08:00

1161 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CRUD 命令:download / sync / info / delete / create。"""
from __future__ import annotations
import argparse
import logging
import os
import re
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_r3tr_object_code, get_type_config, parse_object_name
DDIC_TYPES = {"domain", "dataelement", "table", "structure", "tabletype"}
# NW 7.40 ADT 对 DDIC 对象(structure/table/tabletype/dataelement/domain)的 */* 响应
# 不含 description / packageRef,需回退查文本表补描述、查 TADIR 补开发包。
# 描述表的语言列固定为 DDLANGUAGE、文本列固定为 DDTEXT。
_DDIC_DESC_TABLE: dict[str, tuple[str, str]] = {
"structure": ("DD02T", "TABNAME"),
"table": ("DD02T", "TABNAME"),
"tabletype": ("DD40T", "TYPENAME"),
"dataelement": ("DD04T", "ROLLNAME"),
"domain": ("DD01T", "DOMNAME"),
}
# DDIC 对象 → TADIR.OBJECT 代码(用于补开发包 DEVCLASS)。
_DDIC_PACKAGE_OBJECT: dict[str, str] = {
"structure": "TABL",
"table": "TABL",
"tabletype": "TTYP",
"dataelement": "DTEL",
"domain": "DOMA",
}
# ADT masterLanguage 返回 ISO 码(如 'ZH'/'EN'),文本表 DDLANGUAGE 用 SAP 单字符键。
_ISO_TO_SAP_LANGUAGE: dict[str, str] = {
"ZH": "1",
"EN": "E",
"DE": "D",
"JA": "J",
"KO": "3",
}
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'
),
# 以下类型不提供完整业务骨架,仅给出占位注释。
# 建议通过 --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'
),
}
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.exists_uri or 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.exists_uri or 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 _query_transport_request(
client: ADTClient, obj_type: str, display_name: str
) -> str:
"""查对象当前所在(未释放)的传输请求号。纯只读查 E071;失败优雅降级。"""
r3tr_code = get_r3tr_object_code(obj_type)
if not r3tr_code:
return "—(非独立传输对象)"
obj_name_upper = display_name.upper().split("/")[-1]
if not re.fullmatch(r"[A-Za-z0-9_/$]+", obj_name_upper):
return "(对象名非法,跳过)"
try:
sql = (
f"SELECT trkorr FROM e071 WHERE pgmid IN ('R3TR') "
f"AND object IN ('{r3tr_code}') AND obj_name IN ('{obj_name_upper}') "
f"UP TO 10 ROWS"
)
result = client.query_table_data(sql, max_rows=10)
trkorrs = sorted({row[0] for row in result.get("rows", []) if row and row[0]})
return ", ".join(trkorrs) if trkorrs else "无"
except Exception:
logger.debug("查询 E071 传输请求失败", exc_info=True)
return "(无法获取)"
def _sap_language_key(master_language: str) -> str:
"""把 ADT masterLanguageISO 码,如 'ZH'/'EN')转成 SAP 单字符语言键。
已是单字符时原样返回;无法映射时原样返回(回退顺序会自然跳过不匹配项)。
"""
lang = (master_language or "").strip().upper()
if not lang:
return ""
if len(lang) == 1:
return lang
return _ISO_TO_SAP_LANGUAGE.get(lang, lang)
def _pick_description_text(rows, preferred_lang: str = "") -> str:
"""按「登录语言 -> 'E' -> '1' -> 首行」回退,从 (语言, 文本) 行中挑描述文本。"""
if not rows:
return ""
by_lang: dict[str, str] = {}
order: list[str] = []
for lang, text in rows:
lang = (lang or "").strip().upper()
if lang not in by_lang:
by_lang[lang] = (text or "").strip()
order.append(lang)
priority = [p for p in (preferred_lang, "E", "1") if p]
for p in priority:
p = p.strip().upper()
for lang in order:
if lang == p and by_lang[lang]:
return by_lang[lang]
for lang in order:
if by_lang[lang]:
return by_lang[lang]
return ""
def _query_ddic_description(
client: ADTClient, obj_type: str, obj_name: str, preferred_lang: str
) -> str:
"""ADT 返回空描述时,查描述表补 DDIC 对象描述;失败返回 '' 不抛。"""
if obj_type not in _DDIC_DESC_TABLE:
return ""
table, key_col = _DDIC_DESC_TABLE[obj_type]
name_upper = obj_name.upper().split("/")[-1]
if not re.fullmatch(r"[A-Za-z0-9_/$]+", name_upper):
return ""
try:
sql = (
f"SELECT DDLANGUAGE, DDTEXT FROM {table} "
f"WHERE {key_col} IN ('{name_upper}')"
)
result = client.query_table_data(sql, max_rows=20)
columns = result.get("columns", [])
rows = result.get("rows", [])
lang_idx = columns.index("DDLANGUAGE") if "DDLANGUAGE" in columns else 0
text_idx = columns.index("DDTEXT") if "DDTEXT" in columns else 1
pairs = []
for row in rows:
if row is None:
continue
lang = row[lang_idx] if lang_idx < len(row) else ""
text = row[text_idx] if text_idx < len(row) else ""
pairs.append((lang, text))
return _pick_description_text(pairs, preferred_lang)
except Exception:
logger.debug("查询 %s 描述失败", table, exc_info=True)
return ""
def _query_ddic_package(client: ADTClient, obj_type: str, obj_name: str) -> str:
"""ADT 返回空开发包时,查 TADIR 补 DEVCLASS;失败返回 '' 不抛。"""
if obj_type not in _DDIC_PACKAGE_OBJECT:
return ""
obj_code = _DDIC_PACKAGE_OBJECT[obj_type]
name_upper = obj_name.upper().split("/")[-1]
if not re.fullmatch(r"[A-Za-z0-9_/$]+", name_upper):
return ""
try:
sql = (
f"SELECT DEVCLASS FROM TADIR "
f"WHERE OBJECT IN ('{obj_code}') AND OBJ_NAME IN ('{name_upper}')"
)
result = client.query_table_data(sql, max_rows=10)
columns = result.get("columns", [])
rows = result.get("rows", [])
dev_idx = columns.index("DEVCLASS") if "DEVCLASS" in columns else 0
for row in rows:
if row and dev_idx < len(row) and row[dev_idx]:
return str(row[dev_idx]).strip()
return ""
except Exception:
logger.debug("查询 TADIR 开发包失败", exc_info=True)
return ""
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": "*/*",
"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(" 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:
# NW 7.40 实测:DDIC 专属媒体类型返回 406,且 application/xml 本身也 406
# structure 专属头 → 406、application/xml → 406、*/* → 200)。
# 回退必须是 */*,否则回退恒败、命令报「查询失败: HTTP 406」。
hdrs["Accept"] = "*/*"
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", "")
# 开发包:解析对象 XML 自带的 packageRef 子元素
pkg_ref = root.find(f".//{{{ns['adtcore']}}}packageRef")
info_package = (
pkg_ref.attrib.get(f"{{{ns['adtcore']}}}name", "")
if pkg_ref is not None
else ""
)
# NW 7.40 DDIC 的 */* 响应不含 description/packageRef:仅在 ADT 返回值为空时补查,
# 补查失败静默降级(保持原样),不打断 info 输出。
if not info_desc:
info_desc = _query_ddic_description(
client, obj_type, parsed.display_name, _sap_language_key(info_language)
)
if not info_package:
info_package = _query_ddic_package(client, obj_type, parsed.display_name)
if info_version == "active":
status_icon = "✓ 已激活"
elif info_version == "inactive":
status_icon = "⚠️ 未激活"
else:
status_icon = f"❓ {info_version}"
# 传输请求号:纯只读查 E071(不在对象元数据 XML 中)
corr_display = _query_transport_request(client, obj_type, parsed.display_name)
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_package or '(未指定)'}")
print(f" 传输请求: {corr_display}")
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.exists_uri or 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 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,
) -> 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)
# JSON definition description: --description wins, then JSON, then name.
# (NW740 ADT create does not persist texts anyway — see SKILL.md known
# limitation; this only affects the XML body sent at creation time.)
if (
definition_file
and not getattr(args, "description", None)
and obj_type in DDIC_TYPES
):
import json as _json
import os as _os
if _os.path.isfile(definition_file):
try:
with open(definition_file, "r", encoding="utf-8") as f:
_data = _json.load(f)
_json_desc = _data.get("description")
if _json_desc:
description = _json_desc
except Exception as _e:
logger.warning("读取定义文件 description 失败: %s", _e)
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.exists_uri or 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:
package_for_transport = getattr(args, "package", None) or "$TMP"
if package_for_transport.strip().upper() in ("$TMP", "TMP"):
# $TMP = 本地包,对象不进传输;弹交互选择是错的(管道调用会 EOFError)
print(" ℹ 目标包为 $TMP(本地对象),跳过传输请求选择")
corr_nr = None
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:
package = getattr(args, "package", None) or "$TMP"
_create_ddic(args, client, obj_type, name, description, corr_nr, definition_file, package)
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:
package = getattr(args, "package", None) or "$TMP"
created_uri, created_src_uri = client.create_object(
obj_type, name, description, corr_nr, source, package
)
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,
package: str = "$TMP",
) -> 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, package)
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, package)
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, package)
if definition_body is None:
if obj_type == "domain":
defn = DomainDefinition()
definition_body = defn.to_xml(name.upper(), description, package)
elif obj_type == "dataelement":
defn = DataElementDefinition()
definition_body = defn.to_xml(name.upper(), description, package)
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, package
)
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}")