feat: sap-cli skill v2.1.0 — self-contained distributable package
- assets/: sap-cli source (v2.1.0, 26 commands, 16 object types) - references/: tool constraints + error handling (self-contained) - scripts/setup.py: one-click install/config/verify - SKILL.md: full command reference + dual-platform install guide - VERSION: 2.1.0 Built from D:/Codespace/sap-cli via scripts/pack_skill.py
This commit is contained in:
@@ -0,0 +1,890 @@
|
||||
"""CRUD 命令:download / sync / info / delete / create。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
from sapcli.exceptions import (
|
||||
ConfigError,
|
||||
CreateError,
|
||||
DeleteError,
|
||||
InvalidNameError,
|
||||
LockError,
|
||||
ObjectAlreadyExistsError,
|
||||
ObjectNotFoundError,
|
||||
SapCliError,
|
||||
)
|
||||
from sapcli.manifest import Manifest, ManifestEntry
|
||||
from sapcli.types import get_type_config, parse_object_name
|
||||
|
||||
DDIC_TYPES = {"domain", "dataelement", "table", "structure", "tabletype"}
|
||||
|
||||
logger = logging.getLogger("sapcli.commands.crud")
|
||||
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
LOG_DIR = os.path.join(_PROJECT_ROOT, "log")
|
||||
LOG_FILE = os.path.join(LOG_DIR, "adt_tools.log")
|
||||
|
||||
DEFAULT_TEMPLATES: dict[str, str] = {
|
||||
"report": 'REPORT {name}.\nWRITE: / \'Hello from {name}\'.\n',
|
||||
"class": (
|
||||
'CLASS {name} DEFINITION\n'
|
||||
' PUBLIC\n'
|
||||
' FINAL\n'
|
||||
' CREATE PUBLIC.\n'
|
||||
' PUBLIC SECTION.\n'
|
||||
' METHODS: hello.\n'
|
||||
'ENDCLASS.\n'
|
||||
'CLASS {name} IMPLEMENTATION.\n'
|
||||
' METHOD hello.\n'
|
||||
' ENDMETHOD.\n'
|
||||
'ENDCLASS.\n'
|
||||
),
|
||||
"function": (
|
||||
'FUNCTION {name}\n'
|
||||
' EXPORTING\n'
|
||||
' VALUE(EV_RESULT) TYPE STRING.\n'
|
||||
' ev_result = \'hello\'.\n'
|
||||
'ENDFUNCTION.\n'
|
||||
),
|
||||
"interface": (
|
||||
'INTERFACE {name}\n'
|
||||
' PUBLIC.\n'
|
||||
' METHODS: hello.\n'
|
||||
'ENDINTERFACE.\n'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def print_source_preview(source: str, max_lines: int = 20) -> None:
|
||||
"""打印源代码预览。"""
|
||||
lines = source.splitlines()
|
||||
print(f" ┌─── 源代码 (前 {max_lines} 行) ──────────────────────")
|
||||
for i, line in enumerate(lines[:max_lines], 1):
|
||||
print(f" │ {i:4d} | {line}")
|
||||
if len(lines) > max_lines:
|
||||
print(f" │ ... 省略剩余 {len(lines) - max_lines} 行 ...")
|
||||
print(f" └────────────────────────────────────────────")
|
||||
|
||||
|
||||
def cmd_download(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""下载 SAP 对象源代码到本地文件。"""
|
||||
obj_type: str = args.type
|
||||
name: str = args.name
|
||||
save_dir: str = args.path
|
||||
|
||||
parsed = parse_object_name(name, obj_type)
|
||||
type_label = get_type_config(obj_type).label
|
||||
|
||||
if parsed.src_uri is None:
|
||||
print(f"\n ✗ {type_label}没有源代码,不支持 download 操作")
|
||||
print(f" functiongroup 是函数模块的容器,不包含可编辑的源代码文件")
|
||||
raise InvalidNameError(f"{type_label}没有源代码,不支持 download 操作")
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 源代码下载")
|
||||
print("=" * 60)
|
||||
print(f" 对象类型: {type_label}")
|
||||
print(f" 对象名称: {parsed.display_name}")
|
||||
print(f" 保存路径: {save_dir}")
|
||||
|
||||
print(f"\n → 检查对象是否存在...")
|
||||
if not client.object_exists(parsed.obj_uri):
|
||||
print(f" ✗ 对象不存在: {parsed.display_name}")
|
||||
print(f" 请确认名称和类型是否正确")
|
||||
raise ObjectNotFoundError(parsed.display_name, obj_type)
|
||||
print(" ✓ 对象存在")
|
||||
|
||||
print(f"\n → 正在下载源代码...")
|
||||
source = client.get_source(parsed.src_uri)
|
||||
source = source.replace("\r\n", "\n").replace("\r", "\n")
|
||||
line_count = len(source.splitlines())
|
||||
print(f" ✓ 源代码下载成功! {len(source)} 字符, {line_count} 行")
|
||||
|
||||
if not os.path.isdir(save_dir):
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
filename = f"{parsed.file_base}.abap"
|
||||
filepath = os.path.join(save_dir, filename)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(source)
|
||||
print(f"\n ✓ 文件已保存: {filepath}")
|
||||
|
||||
print()
|
||||
print_source_preview(source)
|
||||
|
||||
print(f"\n ✓ 下载完成!")
|
||||
print(f" 文件: {filepath}")
|
||||
print(f" 大小: {len(source)} 字符, {line_count} 行")
|
||||
|
||||
|
||||
def cmd_sync(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""同步单个对象源代码到 SAP 并激活。"""
|
||||
obj_type: str = args.type
|
||||
name: str = args.name
|
||||
file_path: str = args.path
|
||||
|
||||
# 向后兼容:检测项目根目录(从文件路径向上查找 manifest.json)
|
||||
if not hasattr(args, "project_path") or not args.project_path:
|
||||
parent = os.path.dirname(os.path.abspath(file_path))
|
||||
if os.path.isfile(os.path.join(parent, "manifest.json")):
|
||||
args.project_path = parent
|
||||
else:
|
||||
args.project_path = None
|
||||
|
||||
parsed = parse_object_name(name, obj_type)
|
||||
type_label = get_type_config(obj_type).label
|
||||
|
||||
if parsed.src_uri is None:
|
||||
print(f"\n ✗ {type_label}没有源代码,不支持 sync 操作")
|
||||
print(f" functiongroup 是函数模块的容器,不包含可编辑的源代码文件")
|
||||
raise InvalidNameError(f"{type_label}没有源代码,不支持 sync 操作")
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
print(f"\n ✗ 文件不存在: {file_path}")
|
||||
raise ConfigError(f"文件不存在: {file_path}")
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 源代码同步激活")
|
||||
print("=" * 60)
|
||||
print(f" 对象类型: {type_label}")
|
||||
print(f" 对象名称: {parsed.display_name}")
|
||||
print(f" 本地文件: {file_path}")
|
||||
print(f" 源代码: {len(source)} 字符, {len(source.splitlines())} 行")
|
||||
print(f" 流程: 检查/创建 → 写入 → 语法检查 → 激活")
|
||||
|
||||
corr_nr_arg = getattr(args, "corr_nr", None)
|
||||
success, error_msg, actual_corr_nr = _sync_single(
|
||||
name, obj_type, file_path, client, corr_nr=corr_nr_arg,
|
||||
)
|
||||
|
||||
if success:
|
||||
# 更新清单(如果适用)
|
||||
_update_manifest_after_sync(args, name, obj_type, file_path, actual_corr_nr)
|
||||
return
|
||||
|
||||
# 失败时也更新清单
|
||||
_update_manifest_after_sync(args, name, obj_type, file_path, actual_corr_nr, failed=True)
|
||||
raise SapCliError(error_msg or "同步失败")
|
||||
|
||||
|
||||
def _sync_single(
|
||||
name: str,
|
||||
obj_type: str,
|
||||
file_path: str,
|
||||
client: ADTClient,
|
||||
corr_nr: str | None = None,
|
||||
quiet: bool = False,
|
||||
) -> tuple[bool, str | None, str | None]:
|
||||
"""单个对象的同步核心逻辑。
|
||||
|
||||
Args:
|
||||
name: 对象名称(function 类型含 /)
|
||||
obj_type: 对象类型
|
||||
file_path: 本地源代码文件路径
|
||||
client: ADT 客户端
|
||||
corr_nr: 传输请求号(可选)
|
||||
quiet: 是否静默模式(批量同步时减少输出)
|
||||
|
||||
Returns:
|
||||
(success, error_msg, actual_corr_nr)
|
||||
"""
|
||||
parsed = parse_object_name(name, obj_type)
|
||||
type_label = get_type_config(obj_type).label
|
||||
|
||||
if parsed.src_uri is None:
|
||||
return False, f"{type_label}没有源代码,不支持 sync 操作", None
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return False, f"文件不存在: {file_path}", None
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
|
||||
if not quiet:
|
||||
print(f"\n → 检查对象是否存在...")
|
||||
if not client.object_exists(parsed.obj_uri):
|
||||
if not quiet:
|
||||
print(f" ℹ 对象不存在,自动创建空对象...")
|
||||
try:
|
||||
client.create_object(obj_type, name, name, source=None)
|
||||
if not quiet:
|
||||
print(f" ✓ 空对象创建成功,进入同步流程")
|
||||
except Exception as e:
|
||||
return False, f"创建失败: {e}", None
|
||||
else:
|
||||
if not quiet:
|
||||
print(" ✓ 对象存在")
|
||||
|
||||
# ── Step 1: 锁定 → 写入 → 解锁 ──
|
||||
if not quiet:
|
||||
print("\n ── 锁定 → 写入 → 解锁 ──")
|
||||
try:
|
||||
if corr_nr:
|
||||
lock_handle, _ = client.lock(parsed.obj_uri, corr_nr)
|
||||
if not quiet:
|
||||
print(f" ✓ 锁定成功(传输请求: {corr_nr})")
|
||||
else:
|
||||
try:
|
||||
lock_handle, detected_corr_nr = client.lock(parsed.obj_uri)
|
||||
if detected_corr_nr:
|
||||
corr_nr = detected_corr_nr
|
||||
if not quiet:
|
||||
print(f" ✓ 锁定成功(对象已绑定传输请求: {corr_nr})")
|
||||
else:
|
||||
if not quiet:
|
||||
print(f" ✓ 锁定成功(本地对象,无需传输请求)")
|
||||
except LockError:
|
||||
if not quiet:
|
||||
print(" ℹ 对象需要传输请求号")
|
||||
corr_nr = _select_transport_request(client)
|
||||
if corr_nr:
|
||||
lock_handle, _ = client.lock(parsed.obj_uri, corr_nr)
|
||||
if not quiet:
|
||||
print(f" ✓ 锁定成功(传输请求: {corr_nr})")
|
||||
else:
|
||||
return False, "锁定失败: 无法获取传输请求号", None
|
||||
except LockError as e:
|
||||
return False, str(e), None
|
||||
|
||||
try:
|
||||
client.set_source(parsed.src_uri, source, lock_handle, corr_nr)
|
||||
if not quiet:
|
||||
print(f" ✓ 源代码写入成功")
|
||||
finally:
|
||||
client.unlock(parsed.obj_uri, lock_handle)
|
||||
if not quiet:
|
||||
print(f" ✓ 解锁成功")
|
||||
|
||||
# ── Step 2: 语法检查 ──
|
||||
if not quiet:
|
||||
print("\n ── 语法检查 ──")
|
||||
try:
|
||||
check_ok, check_msgs = client.syntax_check(name, parsed.obj_uri)
|
||||
except Exception as e:
|
||||
if not quiet:
|
||||
print(f" ℹ 语法检查异常(跳过,直接激活): {e}")
|
||||
check_ok = True
|
||||
check_msgs = []
|
||||
|
||||
if not check_ok:
|
||||
errors = [m for m in check_msgs if m["type"] == "E"]
|
||||
warnings = [m for m in check_msgs if m["type"] == "W"]
|
||||
if not quiet:
|
||||
print(f" ✗ 语法检查未通过! {len(errors)} 个错误, {len(warnings)} 个警告")
|
||||
for e in errors:
|
||||
print(f" [错误] 行 {e['line']}: {e['text']}")
|
||||
for w in warnings:
|
||||
print(f" [警告] 行 {w['line']}: {w['text']}")
|
||||
error_summary = "; ".join(f"行{e['line']}: {e['text']}" for e in errors)
|
||||
return False, f"语法检查未通过: {error_summary}", corr_nr
|
||||
|
||||
if not quiet:
|
||||
print(" ✓ 语法检查通过")
|
||||
|
||||
# ── Step 3: 激活 ──
|
||||
if not quiet:
|
||||
print("\n ── 激活对象 ──")
|
||||
success, messages = client.activate(name, parsed.obj_uri, corr_nr)
|
||||
|
||||
if success:
|
||||
if not quiet:
|
||||
print(f" ✓ 激活成功")
|
||||
print(f"\n {'=' * 60}")
|
||||
print(f" ✓ {parsed.display_name} 已成功同步并激活")
|
||||
print(f" 文件: {file_path}")
|
||||
print(f" 源代码: {len(source)} 字符, {len(source.splitlines())} 行")
|
||||
print(f" 日志: {LOG_FILE}")
|
||||
print(f" {'=' * 60}")
|
||||
return True, None, corr_nr
|
||||
|
||||
errors = [m for m in messages if m["type"] == "E"]
|
||||
if not quiet:
|
||||
warnings = [m for m in messages if m["type"] == "W"]
|
||||
print(f" ✗ 激活失败! {len(errors)} 个错误, {len(warnings)} 个警告")
|
||||
for e in errors:
|
||||
print(f" [错误] 行 {e['line']}: {e['text']}")
|
||||
for w in warnings:
|
||||
print(f" [警告] 行 {w['line']}: {w['text']}")
|
||||
error_summary = "; ".join(f"行{e['line']}: {e['text']}" for e in errors)
|
||||
return False, f"激活失败: {error_summary}", corr_nr
|
||||
|
||||
|
||||
def _update_manifest_after_sync(
|
||||
args: argparse.Namespace,
|
||||
name: str,
|
||||
obj_type: str,
|
||||
file_path: str,
|
||||
corr_nr: str | None,
|
||||
failed: bool = False,
|
||||
) -> None:
|
||||
"""sync 成功/失败后尝试更新清单(如果适用)。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
project_path = getattr(args, "project_path", None)
|
||||
if not project_path:
|
||||
return
|
||||
|
||||
manifest_path = os.path.join(project_path, "manifest.json")
|
||||
if not os.path.isfile(manifest_path):
|
||||
return
|
||||
|
||||
try:
|
||||
manifest = Manifest.load(project_path)
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
# 确定相对文件路径
|
||||
try:
|
||||
rel_file = os.path.relpath(file_path, project_path).replace("\\", "/")
|
||||
except ValueError:
|
||||
rel_file = file_path
|
||||
|
||||
entry = manifest.get(name)
|
||||
if entry:
|
||||
entry.corr_nr = corr_nr
|
||||
entry.system_status = "active" if not failed else entry.system_status
|
||||
entry.last_sync = now
|
||||
entry.last_sync_result = "failed" if failed else "success"
|
||||
else:
|
||||
manifest.upsert(ManifestEntry(
|
||||
name=name,
|
||||
type=obj_type,
|
||||
file=rel_file,
|
||||
system_status="active" if not failed else "inactive",
|
||||
corr_nr=corr_nr,
|
||||
depends_on=[],
|
||||
last_sync=now,
|
||||
last_sync_result="failed" if failed else "success",
|
||||
))
|
||||
manifest.save()
|
||||
except Exception as e:
|
||||
logger.warning("更新清单失败: %s", e)
|
||||
|
||||
|
||||
def cmd_info(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""查询对象元数据信息。"""
|
||||
obj_type: str = args.type
|
||||
name: str = args.name
|
||||
|
||||
parsed = parse_object_name(name, obj_type)
|
||||
type_label = get_type_config(obj_type).label
|
||||
|
||||
INFO_ACCEPT: dict[str, str] = {
|
||||
"report": "application/vnd.sap.adt.programs.programs.v2+xml",
|
||||
"class": "application/vnd.sap.adt.oo.classes.v2+xml",
|
||||
"interface": "application/vnd.sap.adt.oo.interfaces.v2+xml",
|
||||
"function": "application/vnd.sap.adt.functions.fmodules.v2+xml",
|
||||
"functiongroup": "application/vnd.sap.adt.functions.groups.v2+xml",
|
||||
"domain": "*/*",
|
||||
"dataelement": "*/*",
|
||||
"table": "*/*",
|
||||
"tabletype": "*/*",
|
||||
}
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 对象信息查询")
|
||||
print("=" * 60)
|
||||
print(f" 对象类型: {type_label}")
|
||||
print(f" 对象名称: {parsed.display_name}")
|
||||
|
||||
print(f"\n → 正在查询对象信息...")
|
||||
accept = INFO_ACCEPT.get(obj_type, "application/xml")
|
||||
url = client.host + parsed.obj_uri
|
||||
hdrs = client._headers("application/xml")
|
||||
hdrs["Accept"] = accept
|
||||
logger.info("INFO: GET %s", url)
|
||||
resp = client.session.get(url, headers=hdrs)
|
||||
logger.info("INFO RESPONSE: HTTP %s", resp.status_code)
|
||||
|
||||
if resp.status_code == 404:
|
||||
print(f" ✗ 对象不存在: {parsed.display_name}")
|
||||
raise ObjectNotFoundError(parsed.display_name, obj_type)
|
||||
if resp.status_code == 406:
|
||||
hdrs["Accept"] = "application/xml"
|
||||
resp = client.session.get(url, headers=hdrs)
|
||||
if resp.status_code != 200:
|
||||
print(f" ✗ 查询失败: HTTP {resp.status_code}")
|
||||
print(f" {resp.text[:200]}")
|
||||
raise SapCliError(f"查询失败: HTTP {resp.status_code}")
|
||||
|
||||
root = ET.fromstring(resp.content)
|
||||
ns = {"adtcore": "http://www.sap.com/adt/core"}
|
||||
|
||||
info_name = root.attrib.get(f"{{{ns['adtcore']}}}name", "")
|
||||
info_type = root.attrib.get(f"{{{ns['adtcore']}}}type", "")
|
||||
info_desc = root.attrib.get(f"{{{ns['adtcore']}}}description", "")
|
||||
info_version = root.attrib.get(f"{{{ns['adtcore']}}}version", "")
|
||||
info_changed_at = root.attrib.get(f"{{{ns['adtcore']}}}changedAt", "")
|
||||
info_changed_by = root.attrib.get(f"{{{ns['adtcore']}}}changedBy", "")
|
||||
info_created_by = root.attrib.get(f"{{{ns['adtcore']}}}createdBy", "")
|
||||
info_responsible = root.attrib.get(f"{{{ns['adtcore']}}}responsible", "")
|
||||
info_language = root.attrib.get(f"{{{ns['adtcore']}}}masterLanguage", "")
|
||||
|
||||
if info_version == "active":
|
||||
status_icon = "✓ 已激活"
|
||||
elif info_version == "inactive":
|
||||
status_icon = "⚠️ 未激活"
|
||||
else:
|
||||
status_icon = f"❓ {info_version}"
|
||||
|
||||
print(f" ✓ 查询成功\n")
|
||||
print(f" {'─' * 50}")
|
||||
print(f" 名称: {info_name}")
|
||||
print(f" 类型: {info_type}")
|
||||
print(f" 描述: {info_desc}")
|
||||
print(f" 状态: {status_icon}")
|
||||
print(f" 负责人: {info_responsible}")
|
||||
print(f" 语言: {info_language}")
|
||||
print(f" 创建者: {info_created_by}")
|
||||
print(f" 修改者: {info_changed_by}")
|
||||
print(f" 修改时间: {info_changed_at}")
|
||||
print(f" URI: {parsed.obj_uri}")
|
||||
print(f" {'─' * 50}")
|
||||
|
||||
expected_name = parsed.display_name.upper().split("/")[-1]
|
||||
returned_name = info_name.upper()
|
||||
if returned_name != expected_name:
|
||||
print(f"\n ✗ 名称不匹配! 请求: {expected_name}, 返回: {returned_name}")
|
||||
print(f" 可能查询到了错误的对象")
|
||||
raise SapCliError(f"名称不匹配! 请求: {expected_name}, 返回: {returned_name}")
|
||||
|
||||
|
||||
def cmd_delete(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""从 SAP 系统删除对象。"""
|
||||
obj_type: str = args.type
|
||||
name: str = args.name
|
||||
|
||||
parsed = parse_object_name(name, obj_type)
|
||||
type_label = get_type_config(obj_type).label
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 源代码删除")
|
||||
print("=" * 60)
|
||||
print(f" 对象类型: {type_label}")
|
||||
print(f" 对象名称: {parsed.display_name}")
|
||||
|
||||
print(f"\n → 检查对象是否存在...")
|
||||
if not client.object_exists(parsed.obj_uri):
|
||||
print(f" ✗ 对象不存在: {parsed.display_name}")
|
||||
print(f" 请确认名称和类型是否正确")
|
||||
raise ObjectNotFoundError(parsed.display_name, obj_type)
|
||||
print(" ✓ 对象存在")
|
||||
|
||||
print(f"\n ⚠️ 即将从 SAP 系统中删除: {parsed.display_name}")
|
||||
print(f" 类型: {type_label}")
|
||||
print(f" URI: {parsed.obj_uri}")
|
||||
confirm = input("\n 确认删除? (输入 yes 确认): ").strip()
|
||||
if confirm.lower() != "yes":
|
||||
print(" 已取消删除操作")
|
||||
return
|
||||
|
||||
corr_nr = client.get_transport_request()
|
||||
if corr_nr:
|
||||
print(f" ✓ 传输请求: {corr_nr}")
|
||||
else:
|
||||
print(" ℹ 未找到可修改的传输请求,将尝试无传输号删除")
|
||||
|
||||
print(f"\n → 正在删除对象...")
|
||||
success, error = client.delete_object(parsed.obj_uri, corr_nr)
|
||||
|
||||
if success:
|
||||
print(f" ✓ 删除成功!")
|
||||
print(f"\n ✓ {parsed.display_name} 已从 SAP 系统中删除")
|
||||
|
||||
# 更新清单(如果适用)
|
||||
_update_manifest_after_delete(args, name)
|
||||
else:
|
||||
print(f" ✗ 删除失败: {error}")
|
||||
print(f"\n 可能原因:")
|
||||
print(f" - 对象被其他用户锁定")
|
||||
print(f" - 缺少删除权限")
|
||||
print(f" - 需要传输请求号")
|
||||
raise DeleteError(f"删除失败: {error}")
|
||||
|
||||
|
||||
def _update_manifest_after_delete(
|
||||
args: argparse.Namespace,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""delete 成功后尝试从清单移除(如果适用)。"""
|
||||
project_path = getattr(args, "project_path", None)
|
||||
if not project_path:
|
||||
return
|
||||
|
||||
manifest_path = os.path.join(project_path, "manifest.json")
|
||||
if not os.path.isfile(manifest_path):
|
||||
return
|
||||
|
||||
try:
|
||||
manifest = Manifest.load(project_path)
|
||||
if manifest.remove(name):
|
||||
manifest.save()
|
||||
logger.info("清单已更新: 移除 %s", name)
|
||||
except Exception as e:
|
||||
logger.warning("更新清单失败: %s", e)
|
||||
|
||||
|
||||
def _select_transport_request(client: ADTClient) -> str | None:
|
||||
"""交互式传输请求选择:列出已有请求或新建。
|
||||
|
||||
Returns:
|
||||
选中的传输请求编号,或 None 表示无传输号创建。
|
||||
"""
|
||||
print(f"\n → 查询可用的传输请求...")
|
||||
try:
|
||||
requests_list = client.list_transport_requests()
|
||||
except Exception as e:
|
||||
logger.warning("查询传输请求失败: %s", e)
|
||||
print(f" ℹ 查询传输请求失败,将尝试无传输号创建")
|
||||
return None
|
||||
|
||||
if requests_list:
|
||||
print(f" 找到 {len(requests_list)} 个可修改的传输请求:\n")
|
||||
for i, req in enumerate(requests_list, 1):
|
||||
desc = req.get("description", "")
|
||||
owner = req.get("owner", "")
|
||||
print(f" {i}. {req['number']} {desc} (所有者: {owner})")
|
||||
print(f" {len(requests_list) + 1}. 新建传输请求")
|
||||
print(f" 0. 不使用传输请求(本地对象)")
|
||||
print()
|
||||
|
||||
while True:
|
||||
choice = input(" 请选择 [0-{}]: ".format(len(requests_list) + 1)).strip()
|
||||
if not choice:
|
||||
continue
|
||||
try:
|
||||
idx = int(choice)
|
||||
except ValueError:
|
||||
print(" ✗ 请输入数字")
|
||||
continue
|
||||
|
||||
if idx == 0:
|
||||
print(" ℹ 将尝试无传输号创建(本地对象 $TMP)")
|
||||
return None
|
||||
elif 1 <= idx <= len(requests_list):
|
||||
selected = requests_list[idx - 1]["number"]
|
||||
print(f" ✓ 已选择传输请求: {selected}")
|
||||
return selected
|
||||
elif idx == len(requests_list) + 1:
|
||||
# 新建传输请求
|
||||
tr_desc = input(" 请输入新传输请求描述: ").strip()
|
||||
if not tr_desc:
|
||||
print(" ✗ 描述不能为空,请重新选择")
|
||||
continue
|
||||
try:
|
||||
new_nr = client.create_transport_request(tr_desc)
|
||||
if new_nr:
|
||||
print(f" ✓ 传输请求已创建: {new_nr}")
|
||||
return new_nr
|
||||
else:
|
||||
print(" ✗ 创建传输请求失败(未返回编号),将尝试无传输号创建")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" ✗ 创建传输请求失败: {e}")
|
||||
return None
|
||||
else:
|
||||
print(f" ✗ 请输入 0-{len(requests_list) + 1} 之间的数字")
|
||||
else:
|
||||
print(" ℹ 未找到可修改的传输请求")
|
||||
print()
|
||||
choice = input(" 是否新建传输请求? (y/n): ").strip().lower()
|
||||
if choice in ("y", "yes"):
|
||||
tr_desc = input(" 请输入新传输请求描述: ").strip()
|
||||
if tr_desc:
|
||||
try:
|
||||
new_nr = client.create_transport_request(tr_desc)
|
||||
if new_nr:
|
||||
print(f" ✓ 传输请求已创建: {new_nr}")
|
||||
return new_nr
|
||||
except Exception as e:
|
||||
print(f" ✗ 创建传输请求失败: {e}")
|
||||
else:
|
||||
print(" ✗ 描述不能为空")
|
||||
print(" ℹ 将尝试无传输号创建")
|
||||
return None
|
||||
|
||||
|
||||
def cmd_create(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""在 SAP 系统创建开发对象。"""
|
||||
obj_type: str = args.type
|
||||
name: str = args.name
|
||||
description: str = getattr(args, "description", None) or name
|
||||
source_file: str | None = getattr(args, "source", None)
|
||||
definition_file: str | None = getattr(args, "definition", None)
|
||||
|
||||
type_label = get_type_config(obj_type).label
|
||||
|
||||
if obj_type == "function":
|
||||
if "/" not in name:
|
||||
print(f" ✗ function 类型需要'函数组名/函数模块名' 格式,例如: ZGROUP/Z_MY_FUNC")
|
||||
raise InvalidNameError(
|
||||
"function 类型需要'函数组名/函数模块名' 格式,例如: ZGROUP/Z_MY_FUNC"
|
||||
)
|
||||
display_name = name
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 创建开发对象")
|
||||
print("=" * 60)
|
||||
print(f" 对象类型: {type_label}")
|
||||
print(f" 对象名称: {display_name}")
|
||||
print(f" 描述: {description}")
|
||||
|
||||
print(f"\n → 检查对象是否存在...")
|
||||
if obj_type != "functiongroup":
|
||||
parsed = parse_object_name(name, obj_type)
|
||||
if client.object_exists(parsed.obj_uri):
|
||||
print(f" ✗ 对象已存在: {display_name}")
|
||||
raise ObjectAlreadyExistsError(display_name, type_label)
|
||||
else:
|
||||
if client.function_group_exists(name):
|
||||
print(f" ✗ 函数组已存在: {name}")
|
||||
raise ObjectAlreadyExistsError(name, type_label)
|
||||
print(" ✓ 对象不存在,可以创建")
|
||||
|
||||
# ── 传输请求选择 ──
|
||||
corr_nr = getattr(args, "corr_nr", None)
|
||||
if corr_nr:
|
||||
# 非交互模式:用户通过 --corr_nr 指定
|
||||
print(f" ✓ 传输请求(指定): {corr_nr}")
|
||||
else:
|
||||
corr_nr = _select_transport_request(client)
|
||||
|
||||
if obj_type == "function":
|
||||
group_name = name.split("/", 1)[0]
|
||||
if not client.function_group_exists(group_name):
|
||||
print(f"\n → 函数组 {group_name} 不存在,自动创建...")
|
||||
try:
|
||||
client.create_function_group(group_name, corr_nr=corr_nr)
|
||||
print(f" ✓ 函数组 {group_name} 创建成功")
|
||||
except Exception as e:
|
||||
print(f" ✗ 函数组创建失败: {e}")
|
||||
raise CreateError(f"函数组创建失败: {e}") from e
|
||||
else:
|
||||
print(f" ✓ 函数组 {group_name} 已存在")
|
||||
|
||||
if obj_type == "functiongroup":
|
||||
print(f"\n → 正在创建函数组...")
|
||||
try:
|
||||
created_uri = client.create_function_group(name, description, corr_nr)
|
||||
except Exception as e:
|
||||
print(f" ✗ 创建失败: {e}")
|
||||
raise CreateError(str(e)) from e
|
||||
print(f" ✓ 函数组创建成功")
|
||||
print(f" ✓ URI: {created_uri}")
|
||||
print(f"\n ✓ {name} 创建完成!")
|
||||
print(f" 类型: 函数组(FUNCTION GROUP)")
|
||||
print(f" 描述: {description}")
|
||||
return
|
||||
|
||||
if obj_type in DDIC_TYPES:
|
||||
_create_ddic(args, client, obj_type, name, description, corr_nr, definition_file)
|
||||
return
|
||||
|
||||
if source_file:
|
||||
with open(source_file, "r", encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
else:
|
||||
template_name = name.split("/", 1)[-1].upper() if obj_type == "function" else name.upper()
|
||||
source = DEFAULT_TEMPLATES[obj_type].format(name=template_name)
|
||||
|
||||
print(f"\n → 正在创建对象...")
|
||||
try:
|
||||
created_uri, created_src_uri = client.create_object(
|
||||
obj_type, name, description, corr_nr, source
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ✗ 创建失败: {e}")
|
||||
raise CreateError(str(e)) from e
|
||||
|
||||
print(f" ✓ 对象创建成功!")
|
||||
print(f" ✓ URI: {created_uri}")
|
||||
|
||||
if source:
|
||||
print(f" ✓ 源代码已写入并激活 ({len(source)} 字符)")
|
||||
|
||||
print(f"\n ✓ {display_name} 创建完成!")
|
||||
print(f" 类型: {type_label}")
|
||||
print(f" 描述: {description}")
|
||||
|
||||
# 更新清单(如果适用)
|
||||
_update_manifest_after_create(args, name, obj_type, corr_nr)
|
||||
|
||||
|
||||
def _update_manifest_after_create(
|
||||
args: argparse.Namespace,
|
||||
name: str,
|
||||
obj_type: str,
|
||||
corr_nr: str | None,
|
||||
) -> None:
|
||||
"""create 成功后尝试写入清单(如果适用)。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
project_path = getattr(args, "project_path", None)
|
||||
if not project_path:
|
||||
return
|
||||
|
||||
manifest_path = os.path.join(project_path, "manifest.json")
|
||||
if not os.path.isfile(manifest_path):
|
||||
return
|
||||
|
||||
try:
|
||||
manifest = Manifest.load(project_path)
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
# 根据类型推断文件路径
|
||||
from sapcli.scanner import DIRECTORY_TYPE_MAP
|
||||
type_to_dir = {v: k for k, v in DIRECTORY_TYPE_MAP.items()}
|
||||
dir_name = type_to_dir.get(obj_type, "")
|
||||
|
||||
if obj_type == "function" and "/" in name:
|
||||
group, func = name.split("/", 1)
|
||||
rel_file = f"functions/{group.lower()}/{func.lower()}.abap"
|
||||
elif dir_name:
|
||||
file_base = name.split("/", 1)[-1].lower()
|
||||
rel_file = f"{dir_name}/{file_base}.abap"
|
||||
else:
|
||||
rel_file = ""
|
||||
|
||||
manifest.upsert(ManifestEntry(
|
||||
name=name,
|
||||
type=obj_type,
|
||||
file=rel_file,
|
||||
system_status="active",
|
||||
corr_nr=corr_nr,
|
||||
depends_on=[],
|
||||
last_sync=now,
|
||||
last_sync_result="success",
|
||||
))
|
||||
manifest.save()
|
||||
logger.info("清单已更新: 新增 %s", name)
|
||||
except Exception as e:
|
||||
logger.warning("更新清单失败: %s", e)
|
||||
|
||||
|
||||
def _create_ddic(
|
||||
args: argparse.Namespace,
|
||||
client: ADTClient,
|
||||
obj_type: str,
|
||||
name: str,
|
||||
description: str,
|
||||
corr_nr: str | None,
|
||||
definition_file: str | None,
|
||||
) -> None:
|
||||
"""创建 DDIC 对象(domain/dataelement/table/structure/tabletype)。"""
|
||||
from sapcli.ddic import (
|
||||
DomainDefinition,
|
||||
DataElementDefinition,
|
||||
TableDefinition,
|
||||
StructureDefinition,
|
||||
TableTypeDefinition,
|
||||
TableField,
|
||||
)
|
||||
|
||||
type_label = get_type_config(obj_type).label
|
||||
definition_body: str | None = None
|
||||
|
||||
if definition_file:
|
||||
import json
|
||||
with open(definition_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if obj_type == "domain":
|
||||
defn = DomainDefinition(
|
||||
datatype=data.get("datatype", "CHAR"),
|
||||
length=data.get("length", 10),
|
||||
decimals=data.get("decimals", 0),
|
||||
lowercase=data.get("lowercase", False),
|
||||
fix_values=data.get("fix_values", []),
|
||||
)
|
||||
definition_body = defn.to_xml(name.upper(), description)
|
||||
elif obj_type == "dataelement":
|
||||
defn = DataElementDefinition(
|
||||
datatype=data.get("datatype", "CHAR"),
|
||||
length=data.get("length", 10),
|
||||
decimals=data.get("decimals", 0),
|
||||
domain_name=data.get("domain_name", ""),
|
||||
)
|
||||
definition_body = defn.to_xml(name.upper(), description)
|
||||
elif obj_type == "table":
|
||||
fields = [
|
||||
TableField(
|
||||
name=f.get("name", ""),
|
||||
type_name=f.get("type", ""),
|
||||
is_key=f.get("key", False),
|
||||
not_null=f.get("not_null", False),
|
||||
)
|
||||
for f in data.get("fields", [])
|
||||
]
|
||||
defn = TableDefinition(
|
||||
fields=fields,
|
||||
enhancement_category=data.get("enhancement_category", "#NOT_CLASSIFIED"),
|
||||
delivery_class=data.get("delivery_class", "#A"),
|
||||
data_maintenance=data.get("data_maintenance", "#LIMITED"),
|
||||
table_category=data.get("table_category", "#TRANSPARENT"),
|
||||
)
|
||||
definition_body = defn.to_ddl(name.lower(), description)
|
||||
elif obj_type == "structure":
|
||||
fields = [
|
||||
TableField(
|
||||
name=f.get("name", ""),
|
||||
type_name=f.get("type", ""),
|
||||
is_key=f.get("key", False),
|
||||
not_null=f.get("not_null", False),
|
||||
)
|
||||
for f in data.get("fields", [])
|
||||
]
|
||||
defn = StructureDefinition(
|
||||
fields=fields,
|
||||
enhancement_category=data.get("enhancement_category", "#NOT_CLASSIFIED"),
|
||||
)
|
||||
definition_body = defn.to_ddl(name.lower(), description)
|
||||
elif obj_type == "tabletype":
|
||||
defn = TableTypeDefinition(
|
||||
line_type=data.get("line_type", ""),
|
||||
key_type=data.get("key_type", "#USER_DEFINED"),
|
||||
access_mode=data.get("access_mode", "#STANDARD"),
|
||||
)
|
||||
definition_body = defn.to_xml(name.upper(), description)
|
||||
|
||||
if definition_body is None:
|
||||
if obj_type == "domain":
|
||||
defn = DomainDefinition()
|
||||
definition_body = defn.to_xml(name.upper(), description)
|
||||
elif obj_type == "dataelement":
|
||||
defn = DataElementDefinition()
|
||||
definition_body = defn.to_xml(name.upper(), description)
|
||||
elif obj_type == "table":
|
||||
defn = TableDefinition(fields=[
|
||||
TableField(name="key_field", type_name="char10", not_null=True),
|
||||
])
|
||||
definition_body = defn.to_ddl(name.lower(), description)
|
||||
elif obj_type == "structure":
|
||||
defn = StructureDefinition(fields=[
|
||||
TableField(name="field1", type_name="char10"),
|
||||
])
|
||||
definition_body = defn.to_ddl(name.lower(), description)
|
||||
elif obj_type == "tabletype":
|
||||
print(f" ✗ tabletype 需要通过 --definition 指定 line_type")
|
||||
raise CreateError("tabletype 需要通过 --definition 指定 line_type")
|
||||
|
||||
print(f"\n → 正在创建 DDIC 对象...")
|
||||
try:
|
||||
created_uri, created_src_uri = client.create_ddic_object(
|
||||
obj_type, name, definition_body, corr_nr
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ✗ 创建失败: {e}")
|
||||
raise CreateError(str(e)) from e
|
||||
|
||||
print(f" ✓ DDIC 对象创建并激活成功!")
|
||||
print(f" ✓ URI: {created_uri}")
|
||||
print(f"\n ✓ {name} 创建完成!")
|
||||
print(f" 类型: {type_label}")
|
||||
print(f" 描述: {description}")
|
||||
Reference in New Issue
Block a user