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,83 @@
|
||||
"""sapcli 命令模块包。
|
||||
|
||||
将原 commands.py 按功能拆分为多个子模块,保持统一的导出接口。
|
||||
"""
|
||||
from sapcli.commands.crud import (
|
||||
cmd_create,
|
||||
cmd_delete,
|
||||
cmd_download,
|
||||
cmd_info,
|
||||
cmd_sync,
|
||||
)
|
||||
from sapcli.commands.batch import (
|
||||
cmd_init,
|
||||
cmd_refresh,
|
||||
cmd_sync_all,
|
||||
)
|
||||
from sapcli.commands.config_cmd import (
|
||||
cmd_config,
|
||||
)
|
||||
from sapcli.commands.search import (
|
||||
cmd_list,
|
||||
cmd_whereused,
|
||||
cmd_search,
|
||||
)
|
||||
from sapcli.commands.transport import (
|
||||
cmd_transport,
|
||||
)
|
||||
from sapcli.commands.quality import (
|
||||
cmd_check,
|
||||
cmd_format,
|
||||
)
|
||||
from sapcli.commands.diff_cmd import (
|
||||
cmd_diff,
|
||||
)
|
||||
from sapcli.commands.package_cmd import (
|
||||
cmd_package,
|
||||
)
|
||||
from sapcli.commands.cds import (
|
||||
cmd_cds,
|
||||
)
|
||||
from sapcli.commands.analyze import (
|
||||
cmd_analyze,
|
||||
)
|
||||
from sapcli.commands.scaffold import (
|
||||
cmd_scaffold,
|
||||
)
|
||||
from sapcli.commands.ddl_query import (
|
||||
cmd_show_table,
|
||||
cmd_read_table,
|
||||
)
|
||||
from sapcli.commands.program_run import (
|
||||
cmd_run_program,
|
||||
)
|
||||
from sapcli.commands.activate import (
|
||||
cmd_activate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"cmd_create",
|
||||
"cmd_delete",
|
||||
"cmd_download",
|
||||
"cmd_info",
|
||||
"cmd_init",
|
||||
"cmd_refresh",
|
||||
"cmd_sync",
|
||||
"cmd_sync_all",
|
||||
"cmd_config",
|
||||
"cmd_list",
|
||||
"cmd_whereused",
|
||||
"cmd_search",
|
||||
"cmd_transport",
|
||||
"cmd_check",
|
||||
"cmd_format",
|
||||
"cmd_diff",
|
||||
"cmd_package",
|
||||
"cmd_cds",
|
||||
"cmd_analyze",
|
||||
"cmd_scaffold",
|
||||
"cmd_show_table",
|
||||
"cmd_read_table",
|
||||
"cmd_run_program",
|
||||
"cmd_activate",
|
||||
]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""activate 命令 — 单独激活 SAP 对象(无需重新 sync)。"""
|
||||
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.activate")
|
||||
|
||||
|
||||
def cmd_activate(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""单独激活一个 SAP 对象。
|
||||
|
||||
支持 --name / --type 激活单个对象,
|
||||
也支持 --names / --types 批量激活多个对象。
|
||||
可选 --corr_nr 传入传输请求号。
|
||||
"""
|
||||
corr_nr: str | None = getattr(args, "corr_nr", None)
|
||||
|
||||
# 批量模式:--names ZCLS1,ZCLS2 --types class,class
|
||||
names_raw: str | None = getattr(args, "names", None)
|
||||
types_raw: str | None = getattr(args, "types", None)
|
||||
|
||||
if names_raw and types_raw:
|
||||
names = [n.strip() for n in names_raw.split(",") if n.strip()]
|
||||
types = [t.strip() for t in types_raw.split(",") if t.strip()]
|
||||
if len(names) != len(types):
|
||||
print(" ✗ --names 和 --types 的数量不匹配")
|
||||
return
|
||||
_activate_batch(client, names, types, corr_nr)
|
||||
return
|
||||
|
||||
# 单对象模式:--name / --type
|
||||
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(" SAP ADT 对象激活")
|
||||
print("=" * 60)
|
||||
print(f" 对象名称: {parsed.display_name}")
|
||||
print(f" 对象类型: {type_label}")
|
||||
if corr_nr:
|
||||
print(f" 传输请求: {corr_nr}")
|
||||
|
||||
_activate_single(client, name, parsed.obj_uri, corr_nr, type_label)
|
||||
|
||||
|
||||
def _activate_single(
|
||||
client: ADTClient,
|
||||
name: str,
|
||||
obj_uri: str,
|
||||
corr_nr: str | None,
|
||||
type_label: str = "",
|
||||
) -> bool:
|
||||
"""激活单个对象,返回是否成功。"""
|
||||
label = type_label or name
|
||||
print(f"\n → 正在激活 {name}...")
|
||||
|
||||
try:
|
||||
success, messages = client.activate(name, obj_uri, corr_nr)
|
||||
except Exception as e:
|
||||
print(f" ✗ 激活失败: {e}")
|
||||
return False
|
||||
|
||||
errors = [m for m in messages if m["type"] == "E"]
|
||||
warnings = [m for m in messages if m["type"] == "W"]
|
||||
infos = [m for m in messages if m["type"] == "I"]
|
||||
|
||||
if success:
|
||||
print(f" ✓ {name} 激活成功!")
|
||||
else:
|
||||
print(f" ✗ {name} 激活失败! {len(errors)} 个错误, {len(warnings)} 个警告")
|
||||
|
||||
for msg in messages:
|
||||
icon = {"E": "✗", "W": "⚠", "I": "ℹ", "S": "✓"}.get(msg["type"], "?")
|
||||
line = msg.get("line", "?")
|
||||
text = msg.get("text", "")
|
||||
print(f" {icon} [{msg['type']}] 行 {line}: {text}")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def _activate_batch(
|
||||
client: ADTClient,
|
||||
names: list[str],
|
||||
types: list[str],
|
||||
corr_nr: str | None,
|
||||
) -> None:
|
||||
"""批量激活多个对象。"""
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 批量激活")
|
||||
print("=" * 60)
|
||||
print(f" 对象数量: {len(names)}")
|
||||
if corr_nr:
|
||||
print(f" 传输请求: {corr_nr}")
|
||||
|
||||
results: list[tuple[str, bool]] = []
|
||||
for name, obj_type in zip(names, types):
|
||||
parsed = parse_object_name(name, obj_type)
|
||||
ok = _activate_single(client, name, parsed.obj_uri, corr_nr)
|
||||
results.append((name, ok))
|
||||
|
||||
# 汇总
|
||||
print()
|
||||
print("─" * 60)
|
||||
print(" 激活结果汇总:")
|
||||
print("─" * 60)
|
||||
ok_count = sum(1 for _, ok in results if ok)
|
||||
fail_count = len(results) - ok_count
|
||||
for name, ok in results:
|
||||
icon = "✓" if ok else "✗"
|
||||
print(f" {icon} {name}")
|
||||
print()
|
||||
if fail_count == 0:
|
||||
print(f" ✓ 全部激活成功 ({ok_count}/{len(results)})")
|
||||
else:
|
||||
print(f" ⚠ 成功 {ok_count}, 失败 {fail_count} (共 {len(results)})")
|
||||
@@ -0,0 +1,126 @@
|
||||
"""依赖分析命令:analyze。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
|
||||
logger = logging.getLogger("sapcli.commands.analyze")
|
||||
|
||||
# ABAP 依赖模式
|
||||
_DEPENDENCY_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||
("TYPE REF TO", re.compile(r"TYPE\s+REF\s+TO\s+(\w+)", re.IGNORECASE)),
|
||||
("CALL METHOD", re.compile(r"CALL\s+METHOD\s+(\w+)", re.IGNORECASE)),
|
||||
("CALL METHOD (static)", re.compile(r"(\w+)=>(\w+)", re.IGNORECASE)),
|
||||
("CREATE OBJECT", re.compile(r"CREATE\s+OBJECT\s+\w+\s+TYPE\s+(\w+)", re.IGNORECASE)),
|
||||
("PERFORM", re.compile(r"PERFORM\s+(\w+)", re.IGNORECASE)),
|
||||
("CALL FUNCTION", re.compile(r"CALL\s+FUNCTION\s+\'(\w+)\'", re.IGNORECASE)),
|
||||
("SELECT ... FROM", re.compile(r"FROM\s+(\w+)", re.IGNORECASE)),
|
||||
("LIKE", re.compile(r"LIKE\s+(\w+)", re.IGNORECASE)),
|
||||
("TYPE", re.compile(r"TYPE\s+(\w+)", re.IGNORECASE)),
|
||||
]
|
||||
|
||||
# 已知的 ABAP 内置类型(不需要追踪)
|
||||
_BUILTIN_TYPES = frozenset({
|
||||
"STRING", "CHAR", "NUMC", "INT1", "INT2", "INT4", "INT8",
|
||||
"FLOAT", "DECFLOAT16", "DECFLOAT34", "DEC", "CURR", "QUAN",
|
||||
"RAW", "LRAW", "RAWSTRING", "DATS", "TIMS", "SSTRING",
|
||||
"XSTRING", "X", "C", "N", "D", "T", "I", "F", "P",
|
||||
"ANY", "DATA", "REF", "OBJECT", "STRUCTURE", "TABLE",
|
||||
"STANDARD", "SORTED", "HASHED", "INDEX", "SY",
|
||||
"ABAP_BOOL", "ABAP_TRUE", "ABAP_FALSE", "BOOLEAN",
|
||||
"XSDBOOLEAN", "FLAG", "CHAR1", "CHAR10",
|
||||
})
|
||||
|
||||
|
||||
def cmd_analyze(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""依赖自动分析。
|
||||
|
||||
解析本地 .abap 文件中的 TYPE REF TO / CALL METHOD / PERFORM 等,
|
||||
自动生成 depends_on 列表。
|
||||
"""
|
||||
project_path: str = getattr(args, "path", ".")
|
||||
|
||||
print("=" * 60)
|
||||
print(" sap-cli 依赖分析")
|
||||
print("=" * 60)
|
||||
print(f" 项目路径: {project_path}")
|
||||
|
||||
if not os.path.isdir(project_path):
|
||||
print(f"\n ✗ 目录不存在: {project_path}")
|
||||
return
|
||||
|
||||
# 扫描所有 .abap 文件
|
||||
abap_files: list[str] = []
|
||||
for root, dirs, files in os.walk(project_path):
|
||||
for fname in files:
|
||||
if fname.endswith(".abap"):
|
||||
abap_files.append(os.path.join(root, fname))
|
||||
|
||||
if not abap_files:
|
||||
print("\n ℹ 未找到 .abap 文件")
|
||||
return
|
||||
|
||||
print(f"\n → 扫描到 {len(abap_files)} 个 .abap 文件\n")
|
||||
|
||||
# 分析每个文件
|
||||
all_deps: dict[str, dict[str, list[str]]] = {}
|
||||
for filepath in abap_files:
|
||||
rel_path = os.path.relpath(filepath, project_path)
|
||||
file_base = os.path.splitext(os.path.basename(filepath))[0].upper()
|
||||
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
|
||||
deps = _analyze_dependencies(source)
|
||||
all_deps[file_base] = {"file": rel_path, "deps": deps}
|
||||
|
||||
if deps:
|
||||
print(f" 📄 {rel_path}")
|
||||
print(f" → {', '.join(deps)}")
|
||||
else:
|
||||
print(f" 📄 {rel_path} (无外部依赖)")
|
||||
|
||||
# 汇总
|
||||
print(f"\n {'─' * 50}")
|
||||
total_deps = sum(len(v["deps"]) for v in all_deps.values())
|
||||
files_with_deps = sum(1 for v in all_deps.values() if v["deps"])
|
||||
print(f" 总计: {len(abap_files)} 个文件, {files_with_deps} 个有外部依赖, {total_deps} 个依赖关系")
|
||||
|
||||
# 输出 depends_on 配置建议
|
||||
if total_deps > 0:
|
||||
print(f"\n 建议的 manifest.json depends_on 配置:")
|
||||
print(f" {'─' * 50}")
|
||||
for name, info in sorted(all_deps.items()):
|
||||
if info["deps"]:
|
||||
deps_str = ", ".join(f'"{d}"' for d in sorted(info["deps"]))
|
||||
print(f' "{name}": [{deps_str}]')
|
||||
|
||||
|
||||
def _analyze_dependencies(source: str) -> list[str]:
|
||||
"""分析 ABAP 源码中的依赖关系。
|
||||
|
||||
Args:
|
||||
source: ABAP 源码字符串。
|
||||
|
||||
Returns:
|
||||
依赖对象名称列表(去重)。
|
||||
"""
|
||||
deps: set[str] = set()
|
||||
|
||||
for pattern_name, pattern in _DEPENDENCY_PATTERNS:
|
||||
for match in pattern.finditer(source):
|
||||
name = match.group(1).upper()
|
||||
# 过滤内置类型和短名称
|
||||
if name in _BUILTIN_TYPES:
|
||||
continue
|
||||
if len(name) < 3:
|
||||
continue
|
||||
# 只追踪 Z/Y 开头的自定义对象(更精确)
|
||||
if name.startswith("Z") or name.startswith("Y"):
|
||||
deps.add(name)
|
||||
|
||||
return sorted(deps)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""批量操作命令:init / refresh / sync-all。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
from sapcli.commands.crud import _sync_single
|
||||
from sapcli.exceptions import ConfigError, CyclicDependencyError
|
||||
from sapcli.manifest import Manifest, ManifestEntry, init_manifest
|
||||
from sapcli.scanner import scan_project
|
||||
from sapcli.sorter import topological_sort
|
||||
from sapcli.types import parse_object_name
|
||||
|
||||
logger = logging.getLogger("sapcli.commands.batch")
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""项目初始化:扫描本地文件 → 查询 SAP → 生成 manifest.json。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sapcli.types import parse_object_name
|
||||
|
||||
project_path = os.path.abspath(args.path)
|
||||
if not os.path.isdir(project_path):
|
||||
raise ConfigError(f"项目目录不存在: {project_path}")
|
||||
|
||||
print("=" * 60)
|
||||
print(" sap-cli 项目初始化")
|
||||
print("=" * 60)
|
||||
print(f" 项目路径: {project_path}")
|
||||
|
||||
# ── Step 1: 扫描本地文件 ──
|
||||
print(f"\n → 正在扫描本地文件...")
|
||||
scanned = scan_project(project_path)
|
||||
|
||||
if not scanned:
|
||||
print(" ℹ 未扫描到 .abap 文件")
|
||||
print(" 请确认项目目录结构是否符合约定:")
|
||||
print(" reports/, classes/, interfaces/, functions/, domains/, ...")
|
||||
return
|
||||
|
||||
print(f" ✓ 扫描到 {len(scanned)} 个本地文件\n")
|
||||
|
||||
# ── Step 2: 逐个查询 SAP 状态 ──
|
||||
print(f" → 正在查询 SAP 系统状态...")
|
||||
manifest = init_manifest(project_path)
|
||||
counts = {"active": 0, "inactive": 0, "not_exists": 0}
|
||||
|
||||
for obj in scanned:
|
||||
parsed = parse_object_name(obj.name, obj.type)
|
||||
try:
|
||||
status_info = client.get_object_status(parsed.obj_uri)
|
||||
except Exception as e:
|
||||
logger.warning("查询 %s 状态失败: %s", obj.name, e)
|
||||
status_info = {"exists": False, "status": "not_exists", "corr_nr": None}
|
||||
|
||||
system_status = status_info["status"]
|
||||
corr_nr = status_info["corr_nr"]
|
||||
counts[system_status] = counts.get(system_status, 0) + 1
|
||||
|
||||
# 状态图标
|
||||
if system_status == "active":
|
||||
icon = "✓"
|
||||
corr_display = f"corr: {corr_nr or '本地对象'}"
|
||||
elif system_status == "inactive":
|
||||
icon = "⚠"
|
||||
corr_display = f"corr: {corr_nr or '本地对象'}"
|
||||
else:
|
||||
icon = "✗"
|
||||
corr_display = "不存在"
|
||||
|
||||
print(f" {icon} {obj.name:<20s} ({obj.type:<10s}) {system_status:<12s} {corr_display}")
|
||||
|
||||
entry = ManifestEntry(
|
||||
name=obj.name,
|
||||
type=obj.type,
|
||||
file=obj.file,
|
||||
system_status=system_status,
|
||||
corr_nr=corr_nr,
|
||||
depends_on=[],
|
||||
last_sync=None,
|
||||
last_sync_result="pending",
|
||||
)
|
||||
manifest.upsert(entry)
|
||||
|
||||
# ── Step 3: 保存清单 ──
|
||||
manifest.save()
|
||||
|
||||
total = sum(counts.values())
|
||||
print(f"\n ✓ 清单已生成: {os.path.join(project_path, 'manifest.json')}")
|
||||
print(f" {total} 个对象: "
|
||||
f"{counts.get('active', 0)} 已激活, "
|
||||
f"{counts.get('inactive', 0)} 未激活, "
|
||||
f"{counts.get('not_exists', 0)} 不存在")
|
||||
|
||||
|
||||
def cmd_refresh(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""刷新清单:重新查询 SAP 状态,更新 system_status 和 corr_nr。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sapcli.types import parse_object_name
|
||||
|
||||
project_path = os.path.abspath(args.path)
|
||||
manifest = Manifest.load(project_path)
|
||||
|
||||
print("=" * 60)
|
||||
print(" sap-cli 清单刷新")
|
||||
print("=" * 60)
|
||||
print(f" 项目路径: {project_path}")
|
||||
print(f" 清单对象: {len(manifest.objects)}\n")
|
||||
|
||||
print(f" → 正在查询 SAP 系统状态...")
|
||||
changed = 0
|
||||
unchanged = 0
|
||||
|
||||
for name, entry in manifest.objects.items():
|
||||
parsed = parse_object_name(name, entry.type)
|
||||
old_status = entry.system_status
|
||||
old_corr = entry.corr_nr
|
||||
|
||||
try:
|
||||
status_info = client.get_object_status(parsed.obj_uri)
|
||||
except Exception as e:
|
||||
logger.warning("查询 %s 状态失败: %s", name, e)
|
||||
print(f" ✗ {name:<20s} ({entry.type:<10s}) 查询失败: {e}")
|
||||
unchanged += 1
|
||||
continue
|
||||
|
||||
new_status = status_info["status"]
|
||||
new_corr = status_info["corr_nr"]
|
||||
|
||||
# 更新
|
||||
entry.system_status = new_status
|
||||
entry.corr_nr = new_corr
|
||||
|
||||
# 对比变化
|
||||
status_changed = old_status != new_status
|
||||
corr_changed = old_corr != new_corr
|
||||
is_changed = status_changed or corr_changed
|
||||
|
||||
if is_changed:
|
||||
changed += 1
|
||||
changes = []
|
||||
if status_changed:
|
||||
changes.append(f"状态变更: {old_status} → {new_status}")
|
||||
if corr_changed:
|
||||
changes.append(f"传输请求: {old_corr or '无'} → {new_corr or '无'}")
|
||||
change_str = ", ".join(changes)
|
||||
print(f" ~ {name:<20s} ({entry.type:<10s}) {new_status:<12s} corr: {new_corr or 'null':<15s} ({change_str})")
|
||||
else:
|
||||
unchanged += 1
|
||||
print(f" ✓ {name:<20s} ({entry.type:<10s}) {new_status:<12s} corr: {new_corr or 'null':<15s} (未变化)")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
manifest.last_refresh = now
|
||||
manifest.save()
|
||||
|
||||
print(f"\n ✓ 刷新完成")
|
||||
print(f" 变更: {changed} 个 | 未变化: {unchanged} 个")
|
||||
|
||||
|
||||
def cmd_sync_all(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""批量同步:读取清单 → 拓扑排序 → 逐个同步 → 更新清单。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sapcli.types import parse_object_name
|
||||
|
||||
project_path = os.path.abspath(args.path)
|
||||
manifest = Manifest.load(project_path)
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
fail_fast = getattr(args, "fail_fast", False)
|
||||
|
||||
all_entries = list(manifest.objects.values())
|
||||
total = len(all_entries)
|
||||
|
||||
print("=" * 60)
|
||||
if dry_run:
|
||||
print(" sap-cli 批量同步 (dry-run)")
|
||||
else:
|
||||
print(" sap-cli 批量同步")
|
||||
print("=" * 60)
|
||||
print(f" 项目路径: {project_path}")
|
||||
print(f" 清单对象: {total}")
|
||||
|
||||
# ── Step 1: 扫描本地文件 vs 清单差异 ──
|
||||
scanned = scan_project(project_path)
|
||||
scanned_names = {s.name for s in scanned}
|
||||
manifest_names = set(manifest.objects.keys())
|
||||
|
||||
new_files = scanned_names - manifest_names
|
||||
missing_files = manifest_names - scanned_names
|
||||
|
||||
if new_files:
|
||||
print(f"\n ⚠ {len(new_files)} 个本地文件未纳入清单,建议先 create:")
|
||||
for name in sorted(new_files):
|
||||
print(f" - {name}")
|
||||
|
||||
# ── Step 2: 过滤待处理对象 ──
|
||||
pending = []
|
||||
skipped_up_to_date = []
|
||||
for entry in all_entries:
|
||||
# 检查本地文件是否存在
|
||||
abs_file = manifest.file_path(entry)
|
||||
if entry.name in missing_files or not os.path.isfile(abs_file):
|
||||
print(f"\n ⚠ {entry.name} 本地文件缺失,将跳过")
|
||||
skipped_up_to_date.append(entry)
|
||||
continue
|
||||
# 跳过已同步且激活的对象
|
||||
if entry.system_status == "active" and entry.last_sync_result == "success":
|
||||
skipped_up_to_date.append(entry)
|
||||
continue
|
||||
pending.append(entry)
|
||||
|
||||
# ── Step 3: 拓扑排序 ──
|
||||
try:
|
||||
sorted_entries = topological_sort(all_entries)
|
||||
except CyclicDependencyError as e:
|
||||
print(f"\n ✗ {e}")
|
||||
raise
|
||||
|
||||
# ── Step 4: dry-run 模式 ──
|
||||
if dry_run:
|
||||
print(f"\n 执行计划(按依赖排序):")
|
||||
pending_set = {e.name for e in pending}
|
||||
idx = 0
|
||||
for entry in sorted_entries:
|
||||
idx += 1
|
||||
if entry in skipped_up_to_date and entry.name not in pending_set:
|
||||
print(f" {idx}. {entry.name:<20s} ({entry.type:<10s}) → 跳过 [已是最新]")
|
||||
elif entry.name in missing_files:
|
||||
print(f" {idx}. {entry.name:<20s} ({entry.type:<10s}) → 跳过 [文件缺失]")
|
||||
else:
|
||||
status_info = f"{entry.system_status} → active"
|
||||
deps = entry.depends_on
|
||||
dep_str = f" [依赖: {', '.join(deps)}]" if deps else ""
|
||||
print(f" {idx}. {entry.name:<20s} ({entry.type:<10s}) → sync [{status_info}]{dep_str}")
|
||||
|
||||
print(f"\n 待处理: {len(pending)} 个 | 跳过: {total - len(pending)} 个")
|
||||
return
|
||||
|
||||
# ── Step 5: 执行同步 ──
|
||||
print(f" 待处理: {len(pending)} 个对象\n")
|
||||
|
||||
succeeded: list[str] = []
|
||||
failed: list[tuple[str, str]] = [] # (name, reason)
|
||||
skipped_deps: list[str] = [] # 因依赖失败而跳过的
|
||||
failed_set: set[str] = set() # 失败的对象名集合(用于级联跳过)
|
||||
|
||||
# 建立排序后的顺序映射
|
||||
sorted_order = {entry.name: i for i, entry in enumerate(sorted_entries)}
|
||||
pending_sorted = sorted(pending, key=lambda e: sorted_order.get(e.name, 999))
|
||||
|
||||
for i, entry in enumerate(pending_sorted, 1):
|
||||
# 检查依赖是否全部成功
|
||||
dep_failed = [d for d in entry.depends_on if d in failed_set]
|
||||
if dep_failed:
|
||||
skipped_deps.append(entry.name)
|
||||
failed_set.add(entry.name)
|
||||
entry.last_sync_result = "skipped"
|
||||
print(f" ⊘ [{i}/{len(pending_sorted)}] {entry.name} — 跳过(依赖失败: {', '.join(dep_failed)})")
|
||||
continue
|
||||
|
||||
abs_file = manifest.file_path(entry)
|
||||
print(f"\n ── [{i}/{len(pending_sorted)}] {entry.name} ({entry.type}) ──")
|
||||
|
||||
# 如果对象不存在,先创建
|
||||
if entry.system_status == "not_exists":
|
||||
parsed = parse_object_name(entry.name, entry.type)
|
||||
print(f" ℹ 对象不存在,自动创建空对象...")
|
||||
try:
|
||||
if entry.type == "function":
|
||||
group_name = entry.name.split("/", 1)[0]
|
||||
if not client.function_group_exists(group_name):
|
||||
print(f" → 函数组 {group_name} 不存在,自动创建...")
|
||||
client.create_function_group(group_name)
|
||||
print(f" ✓ 函数组 {group_name} 创建成功")
|
||||
client.create_object(entry.type, entry.name, entry.name, source=None)
|
||||
print(f" ✓ 空对象创建成功")
|
||||
except Exception as e:
|
||||
error_msg = f"创建失败: {e}"
|
||||
print(f" ✗ {error_msg}")
|
||||
failed.append((entry.name, error_msg))
|
||||
failed_set.add(entry.name)
|
||||
entry.last_sync_result = "failed"
|
||||
if fail_fast:
|
||||
break
|
||||
continue
|
||||
|
||||
# 执行同步
|
||||
success, error_msg, actual_corr_nr = _sync_single(
|
||||
entry.name, entry.type, abs_file, client, quiet=False,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
if success:
|
||||
succeeded.append(entry.name)
|
||||
entry.system_status = "active"
|
||||
entry.corr_nr = actual_corr_nr
|
||||
entry.last_sync = now
|
||||
entry.last_sync_result = "success"
|
||||
else:
|
||||
failed.append((entry.name, error_msg or "未知错误"))
|
||||
failed_set.add(entry.name)
|
||||
entry.last_sync = now
|
||||
entry.last_sync_result = "failed"
|
||||
print(f" ⊘ 跳过(标记为 failed)")
|
||||
if fail_fast:
|
||||
break
|
||||
|
||||
# ── Step 6: 保存并输出汇总 ──
|
||||
manifest.save()
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" 批量同步完成")
|
||||
print(f"{'=' * 60}")
|
||||
if succeeded:
|
||||
print(f" ✓ 成功: {', '.join(succeeded)}")
|
||||
if failed:
|
||||
for name, reason in failed:
|
||||
print(f" ✗ 失败: {name} ({reason})")
|
||||
if skipped_deps:
|
||||
print(f" ⊘ 跳过: {', '.join(skipped_deps)}")
|
||||
if not failed and not skipped_deps:
|
||||
print(f" ⊘ 跳过: (无)")
|
||||
print(f" 清单已更新: {os.path.join(project_path, 'manifest.json')}")
|
||||
@@ -0,0 +1,186 @@
|
||||
"""CDS View 命令:cds (download / sync / create)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
|
||||
logger = logging.getLogger("sapcli.commands.cds")
|
||||
|
||||
|
||||
def cmd_cds(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""CDS View 操作。"""
|
||||
action = getattr(args, "cds_action", None)
|
||||
|
||||
if action == "download":
|
||||
_cds_download(args, client)
|
||||
elif action == "sync":
|
||||
_cds_sync(args, client)
|
||||
elif action == "create":
|
||||
_cds_create(args, client)
|
||||
else:
|
||||
print(" 用法: sap-cli cds [download|sync|create]")
|
||||
print(" download — 下载 CDS View DDL 源码")
|
||||
print(" sync — 同步本地 DDL 到 SAP")
|
||||
print(" create — 创建新的 CDS View")
|
||||
|
||||
|
||||
def _cds_download(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""下载 CDS View DDL 源码。"""
|
||||
name: str = args.name
|
||||
save_path: str = getattr(args, "path", ".")
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP CDS View DDL 源码下载")
|
||||
print("=" * 60)
|
||||
print(f" CDS 名称: {name}")
|
||||
print(f" 保存路径: {save_path}")
|
||||
|
||||
print(f"\n → 正在下载 DDL 源码...")
|
||||
try:
|
||||
source = client.get_cds_source(name)
|
||||
except Exception as e:
|
||||
print(f" ✗ 下载失败: {e}")
|
||||
return
|
||||
|
||||
source = source.replace("\r\n", "\n").replace("\r", "\n")
|
||||
line_count = len(source.splitlines())
|
||||
print(f" ✓ DDL 源码下载成功! {len(source)} 字符, {line_count} 行")
|
||||
|
||||
if not os.path.isdir(save_path):
|
||||
os.makedirs(save_path, exist_ok=True)
|
||||
|
||||
filename = f"{name.lower()}.ddl"
|
||||
filepath = os.path.join(save_path, filename)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(source)
|
||||
print(f"\n ✓ 文件已保存: {filepath}")
|
||||
|
||||
|
||||
def _cds_sync(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""同步本地 DDL 到 SAP。"""
|
||||
name: str = args.name
|
||||
ddl_path: str = args.path
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP CDS View DDL 同步")
|
||||
print("=" * 60)
|
||||
print(f" CDS 名称: {name}")
|
||||
print(f" DDL 文件: {ddl_path}")
|
||||
|
||||
if not os.path.isfile(ddl_path):
|
||||
print(f"\n ✗ DDL 文件不存在: {ddl_path}")
|
||||
return
|
||||
|
||||
with open(ddl_path, "r", encoding="utf-8") as f:
|
||||
ddl_source = f.read()
|
||||
|
||||
obj_uri = f"/sap/bc/adt/ddic/ddlsources/{name.lower()}"
|
||||
src_uri = f"/sap/bc/adt/ddic/ddlsources/{name.lower()}/source/main"
|
||||
|
||||
# 检查对象是否存在
|
||||
print(f"\n → 检查 CDS View 是否存在...")
|
||||
exists = client.object_exists(obj_uri)
|
||||
|
||||
if not exists:
|
||||
print(f" ℹ CDS View 不存在,需要先创建")
|
||||
try:
|
||||
client.create_cds(name, name, ddl_source)
|
||||
print(f" ✓ CDS View 创建并同步成功!")
|
||||
except Exception as e:
|
||||
print(f" ✗ 创建失败: {e}")
|
||||
return
|
||||
|
||||
print(f" ✓ CDS View 存在")
|
||||
|
||||
# 锁定 → 写入 → 解锁 → 激活
|
||||
print(f"\n → 正在同步 DDL 源码...")
|
||||
try:
|
||||
lock_handle, corr_nr = client.lock(obj_uri)
|
||||
try:
|
||||
client.set_source(src_uri, ddl_source, lock_handle, corr_nr)
|
||||
print(f" ✓ DDL 源码写入成功")
|
||||
finally:
|
||||
client.unlock(obj_uri, lock_handle)
|
||||
except Exception as e:
|
||||
print(f" ✗ 写入失败: {e}")
|
||||
return
|
||||
|
||||
# 激活
|
||||
print(f"\n → 正在激活...")
|
||||
try:
|
||||
success, messages = client.activate(name.upper(), obj_uri)
|
||||
if success:
|
||||
print(f" ✓ 激活成功")
|
||||
else:
|
||||
errors = [m for m in messages if m["type"] == "E"]
|
||||
print(f" ✗ 激活失败: {len(errors)} 个错误")
|
||||
for e in errors:
|
||||
print(f" [错误] 行 {e['line']}: {e['text']}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ 激活失败: {e}")
|
||||
|
||||
print(f"\n ✓ CDS View 同步完成!")
|
||||
|
||||
|
||||
def _cds_create(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""创建新的 CDS View。"""
|
||||
name: str = args.name
|
||||
description: str = getattr(args, "description", None) or name
|
||||
ddl_path: str | None = getattr(args, "ddl_path", None)
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP 创建 CDS View")
|
||||
print("=" * 60)
|
||||
print(f" CDS 名称: {name}")
|
||||
print(f" 描述: {description}")
|
||||
|
||||
# 读取 DDL 源码
|
||||
ddl_source = ""
|
||||
if ddl_path and os.path.isfile(ddl_path):
|
||||
with open(ddl_path, "r", encoding="utf-8") as f:
|
||||
ddl_source = f.read()
|
||||
print(f" DDL 文件: {ddl_path}")
|
||||
else:
|
||||
# 生成默认模板
|
||||
ddl_source = _default_cds_template(name, description)
|
||||
print(f" DDL: 使用默认模板")
|
||||
|
||||
print(f"\n → 正在创建 CDS View...")
|
||||
try:
|
||||
obj_uri, src_uri = client.create_cds(name, description, ddl_source)
|
||||
except Exception as e:
|
||||
print(f" ✗ 创建失败: {e}")
|
||||
return
|
||||
|
||||
print(f" ✓ CDS View 创建成功!")
|
||||
print(f" ✓ URI: {obj_uri}")
|
||||
|
||||
# 保存本地文件
|
||||
save_dir = getattr(args, "path", ".")
|
||||
if save_dir:
|
||||
if not os.path.isdir(save_dir):
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
filepath = os.path.join(save_dir, f"{name.lower()}.ddl")
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(ddl_source)
|
||||
print(f" ✓ DDL 已保存: {filepath}")
|
||||
|
||||
|
||||
def _default_cds_template(name: str, description: str) -> str:
|
||||
"""生成默认 CDS View DDL 模板。"""
|
||||
view_name = name[:16].upper()
|
||||
return (
|
||||
f"@AbapCatalog.sqlViewName: \'{view_name}\'\n"
|
||||
f"@EndUserText.label: \'{description}\'\n"
|
||||
f"define view {name.lower()}\n"
|
||||
f" as select from sflight\n"
|
||||
f" {{\n"
|
||||
f" carrid,\n"
|
||||
f" connid,\n"
|
||||
f" fldate\n"
|
||||
f" }}\n"
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""config 子命令:显示配置、列出 profile、设置配置项。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
|
||||
|
||||
def cmd_config(args: argparse.Namespace, client=None) -> None:
|
||||
"""配置管理命令。"""
|
||||
action = getattr(args, "config_action", None)
|
||||
if action == "show":
|
||||
_config_show(args)
|
||||
elif action == "list-profiles":
|
||||
_config_list_profiles(args)
|
||||
elif action == "set":
|
||||
_config_set(args)
|
||||
else:
|
||||
print(" 用法: sap-cli config [show|list-profiles|set]")
|
||||
|
||||
|
||||
def _config_show(args: argparse.Namespace) -> None:
|
||||
"""显示当前配置。"""
|
||||
from sapcli.config import load_config
|
||||
|
||||
config_path = getattr(args, "config", None)
|
||||
|
||||
try:
|
||||
cfg, loaded_from = load_config(config_path)
|
||||
except Exception as e:
|
||||
print(f" ✗ 加载配置失败: {e}")
|
||||
return
|
||||
|
||||
print("=" * 60)
|
||||
print(" sap-cli 当前配置")
|
||||
print("=" * 60)
|
||||
if loaded_from:
|
||||
print(f" 配置文件: {loaded_from}")
|
||||
print(f" 主机: {cfg.host}")
|
||||
print(f" Client: {cfg.client}")
|
||||
print(f" 用户: {cfg.user}")
|
||||
print(f" 密码: {'***' if cfg.password else '(未设置)'}")
|
||||
|
||||
|
||||
def _config_list_profiles(args: argparse.Namespace) -> None:
|
||||
"""列出所有可用的 profile。"""
|
||||
config_path = getattr(args, "config", None)
|
||||
if config_path is None:
|
||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "config.ini")
|
||||
|
||||
print("=" * 60)
|
||||
print(" sap-cli 可用 profile")
|
||||
print("=" * 60)
|
||||
|
||||
if not os.path.isfile(config_path):
|
||||
print(" (无配置文件)")
|
||||
return
|
||||
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read(config_path, encoding="utf-8")
|
||||
sections = parser.sections()
|
||||
if not sections:
|
||||
print(" (无 profile)")
|
||||
for name in sections:
|
||||
active = " (默认)" if name == "SAP" else ""
|
||||
print(f" - {name}{active}")
|
||||
|
||||
print(f"\n 配置文件: {config_path}")
|
||||
|
||||
|
||||
def _config_set(args: argparse.Namespace) -> None:
|
||||
"""设置配置项到 config.ini。"""
|
||||
key = getattr(args, "key", None)
|
||||
value = getattr(args, "value", None)
|
||||
profile = getattr(args, "profile", None) or "SAP"
|
||||
|
||||
if not key or not value:
|
||||
print(" ✗ 用法: sap-cli config set <key> <value>")
|
||||
print(" 可设置的 key: host, client, user, password")
|
||||
return
|
||||
|
||||
valid_keys = {"host", "client", "user", "password"}
|
||||
if key not in valid_keys:
|
||||
print(f" ✗ 不支持的配置项: {key}")
|
||||
print(f" 可设置的 key: {', '.join(sorted(valid_keys))}")
|
||||
return
|
||||
|
||||
# 找到或创建 config.ini
|
||||
config_path = getattr(args, "config", None)
|
||||
if not config_path:
|
||||
config_path = os.path.join(os.getcwd(), "config.ini")
|
||||
|
||||
parser = configparser.ConfigParser()
|
||||
if os.path.isfile(config_path):
|
||||
parser.read(config_path, encoding="utf-8")
|
||||
|
||||
if not parser.has_section(profile):
|
||||
parser.add_section(profile)
|
||||
|
||||
parser.set(profile, key, value)
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
parser.write(f)
|
||||
|
||||
print(f" ✓ 已设置 [{profile}] {key} = {'***' if key == 'password' else value}")
|
||||
print(f" 配置文件: {config_path}")
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,100 @@
|
||||
"""DDL query commands — show table fields, read table data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sapcli.cli.output import print_error, print_info
|
||||
|
||||
|
||||
def cmd_show_table(args, client) -> None:
|
||||
"""查看 DDIC 表字段结构。"""
|
||||
table_name = args.name.upper()
|
||||
print_info(f"查询表 {table_name} 的字段结构...")
|
||||
|
||||
fields = client.get_table_fields(table_name)
|
||||
|
||||
if not fields:
|
||||
print_error(f"表 {table_name} 无字段信息")
|
||||
return
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print(f" 表 {table_name} — 字段结构")
|
||||
print("=" * 72)
|
||||
print(f" {'字段名':<20} {'类型':<6} {'长度':<6} {'Key':<5} {'描述'}")
|
||||
print("-" * 72)
|
||||
for f in fields:
|
||||
print(f" {f['name']:<20} {f['type']:<6} {f['length']:<6} {f['key_attribute']:<5} {f['description']}")
|
||||
print("-" * 72)
|
||||
print(f" 共 {len(fields)} 个字段")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_read_table(args, client) -> None:
|
||||
"""查询表数据(ADT freestyle SQL)。"""
|
||||
table_name = args.name.upper()
|
||||
max_rows = getattr(args, "max_rows", 200)
|
||||
where = getattr(args, "where", None)
|
||||
fields = getattr(args, "fields", None)
|
||||
|
||||
select = fields if fields else "*"
|
||||
sql = f"SELECT {select} FROM {table_name}"
|
||||
if where:
|
||||
sql += f" WHERE {where}"
|
||||
sql += f" UP TO {max_rows} ROWS"
|
||||
|
||||
print_info(f"执行 SQL: {sql}")
|
||||
|
||||
result = client.query_table_data(sql, max_rows=max_rows)
|
||||
|
||||
columns = result["columns"]
|
||||
rows = result["rows"]
|
||||
total = result["total_rows"]
|
||||
|
||||
if not rows:
|
||||
print()
|
||||
print_error(f"表 {table_name} 无数据({total} 行)")
|
||||
print()
|
||||
return
|
||||
|
||||
# 计算列宽
|
||||
col_widths = []
|
||||
for i, col in enumerate(columns):
|
||||
max_w = len(col)
|
||||
for row in rows:
|
||||
if i < len(row):
|
||||
max_w = max(max_w, min(len(str(row[i])), 40))
|
||||
col_widths.append(max_w + 2)
|
||||
|
||||
# 限制总宽度
|
||||
total_width = sum(col_widths) + len(columns) + 1
|
||||
if total_width > 200:
|
||||
# 截断过宽的列
|
||||
scale = 200 / total_width
|
||||
col_widths = [max(int(w * scale), 6) for w in col_widths]
|
||||
|
||||
sep = "+" + "+".join("-" * w for w in col_widths) + "+"
|
||||
|
||||
print()
|
||||
print(sep)
|
||||
# 表头
|
||||
header = "|"
|
||||
for i, col in enumerate(columns):
|
||||
w = col_widths[i] if i < len(col_widths) else 10
|
||||
header += f" {col:<{w-1}}|"
|
||||
print(header)
|
||||
print(sep)
|
||||
|
||||
# 数据行
|
||||
for row in rows:
|
||||
line = "|"
|
||||
for i, val in enumerate(row):
|
||||
w = col_widths[i] if i < len(col_widths) else 10
|
||||
s = str(val)[:w-1]
|
||||
line += f" {s:<{w-1}}|"
|
||||
print(line)
|
||||
|
||||
print(sep)
|
||||
exec_time = result.get("execution_time", "")
|
||||
time_info = f" ({exec_time}ms)" if exec_time else ""
|
||||
print(f" {len(rows)} 行{time_info}")
|
||||
print()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""代码差异对比命令:diff。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
from sapcli.types import get_type_config, parse_object_name
|
||||
|
||||
logger = logging.getLogger("sapcli.commands.diff_cmd")
|
||||
|
||||
|
||||
def cmd_diff(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""本地 vs SAP 代码差异对比。"""
|
||||
name: str = args.name
|
||||
obj_type: str = args.type
|
||||
local_path: str | None = getattr(args, "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}没有源代码,不支持 diff 操作")
|
||||
return
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 代码差异对比")
|
||||
print("=" * 60)
|
||||
print(f" 对象名称: {parsed.display_name}")
|
||||
print(f" 对象类型: {type_label}")
|
||||
|
||||
# 读取本地文件
|
||||
if local_path is None:
|
||||
# 尝试自动推断文件路径
|
||||
local_path = f"{parsed.file_base}.abap"
|
||||
if not os.path.isfile(local_path):
|
||||
print(f"\n ✗ 本地文件不存在: {local_path}")
|
||||
return
|
||||
|
||||
with open(local_path, "r", encoding="utf-8") as f:
|
||||
local_source = f.read()
|
||||
|
||||
local_lines = local_source.splitlines(keepends=True)
|
||||
print(f" 本地文件: {local_path} ({len(local_lines)} 行)")
|
||||
|
||||
# 读取 SAP 端源码
|
||||
print(f"\n → 正在读取 SAP 端源码...")
|
||||
try:
|
||||
sap_source = client.read_source_for_diff(name, obj_type)
|
||||
except Exception as e:
|
||||
print(f" ✗ 读取 SAP 源码失败: {e}")
|
||||
return
|
||||
|
||||
sap_source = sap_source.replace("\r\n", "\n").replace("\r", "\n")
|
||||
sap_lines = sap_source.splitlines(keepends=True)
|
||||
print(f" ✓ SAP 源码读取成功 ({len(sap_lines)} 行)")
|
||||
|
||||
# 生成 unified diff
|
||||
diff_lines = list(difflib.unified_diff(
|
||||
sap_lines,
|
||||
local_lines,
|
||||
fromfile=f"SAP:{parsed.display_name}",
|
||||
tofile=f"本地:{os.path.basename(local_path)}",
|
||||
lineterm="",
|
||||
))
|
||||
|
||||
if not diff_lines:
|
||||
print(f"\n ✓ 本地文件与 SAP 端完全一致,无差异")
|
||||
return
|
||||
|
||||
print(f"\n {'─' * 60}")
|
||||
print(f" 差异摘要:")
|
||||
added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
|
||||
removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
|
||||
print(f" 新增行: {added} | 删除行: {removed}")
|
||||
print(f" {'─' * 60}\n")
|
||||
|
||||
# 输出 diff
|
||||
for line in diff_lines:
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
print(f" \033[32m{line}\033[0m")
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
print(f" \033[31m{line}\033[0m")
|
||||
elif line.startswith("@@"):
|
||||
print(f" \033[36m{line}\033[0m")
|
||||
else:
|
||||
print(f" {line}")
|
||||
@@ -0,0 +1,107 @@
|
||||
"""包管理命令:package (create / info / list)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
|
||||
logger = logging.getLogger("sapcli.commands.package_cmd")
|
||||
|
||||
|
||||
def cmd_package(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""ABAP 包操作。"""
|
||||
action = getattr(args, "package_action", None)
|
||||
|
||||
if action == "create":
|
||||
_package_create(args, client)
|
||||
elif action == "info":
|
||||
_package_info(args, client)
|
||||
elif action == "list":
|
||||
_package_list(args, client)
|
||||
else:
|
||||
print(" 用法: sap-cli package [create|info|list]")
|
||||
print(" create — 创建 ABAP 包")
|
||||
print(" info — 查看包详情")
|
||||
print(" list — 列出对象(通过 list 命令按包过滤)")
|
||||
|
||||
|
||||
def _package_create(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""创建 ABAP 包。"""
|
||||
name: str = args.name
|
||||
description: str = getattr(args, "description", None) or name
|
||||
superpackage: str | None = getattr(args, "superpackage", None)
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP 创建 ABAP 包")
|
||||
print("=" * 60)
|
||||
print(f" 包名: {name}")
|
||||
print(f" 描述: {description}")
|
||||
if superpackage:
|
||||
print(f" 上级包: {superpackage}")
|
||||
|
||||
print(f"\n → 正在创建包...")
|
||||
try:
|
||||
success = client.create_package(name, description, superpackage)
|
||||
except Exception as e:
|
||||
print(f" ✗ 创建失败: {e}")
|
||||
return
|
||||
|
||||
if success:
|
||||
print(f" ✓ 包 {name} 创建成功!")
|
||||
else:
|
||||
print(f" ✗ 包创建失败")
|
||||
|
||||
|
||||
def _package_info(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""查看包详情。"""
|
||||
name: str = args.name
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP 包信息")
|
||||
print("=" * 60)
|
||||
print(f" 包名: {name}")
|
||||
|
||||
print(f"\n → 正在查询...")
|
||||
try:
|
||||
info = client.get_package_info(name)
|
||||
except Exception as e:
|
||||
print(f" ✗ 查询失败: {e}")
|
||||
return
|
||||
|
||||
print(f"\n {'─' * 50}")
|
||||
print(f" 名称: {info.get('name', '')}")
|
||||
print(f" 描述: {info.get('description', '')}")
|
||||
print(f" 所有者: {info.get('owner', '')}")
|
||||
print(f" 上级包: {info.get('superpackage', '(无)')}")
|
||||
print(f" {'─' * 50}")
|
||||
|
||||
|
||||
def _package_list(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""列出包中的对象。"""
|
||||
name: str = args.name
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP 包对象列表")
|
||||
print("=" * 60)
|
||||
print(f" 包名: {name}")
|
||||
|
||||
print(f"\n → 正在查询...")
|
||||
try:
|
||||
results = client.list_objects(package=name)
|
||||
except Exception as e:
|
||||
print(f" ✗ 查询失败: {e}")
|
||||
return
|
||||
|
||||
if not results:
|
||||
print(" ℹ 包中没有找到对象")
|
||||
return
|
||||
|
||||
print(f" ✓ 找到 {len(results)} 个对象\n")
|
||||
print(f" {'名称':<30s} {'类型':<12s} {'描述':<30s}")
|
||||
print(f" {'─' * 30} {'─' * 12} {'─' * 30}")
|
||||
for obj in results:
|
||||
obj_name = obj.get("name", "")[:30]
|
||||
obj_type = obj.get("type", "")[:12]
|
||||
desc = obj.get("description", "")[:30]
|
||||
print(f" {obj_name:<30s} {obj_type:<12s} {desc:<30s}")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Program execution command — remotely run ABAP programs via ADT."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sapcli.cli.output import print_info, print_success, print_error
|
||||
|
||||
|
||||
def cmd_run_program(args, client) -> None:
|
||||
"""远程执行 ABAP 程序。"""
|
||||
program_name = args.name.upper()
|
||||
print_info(f"远程执行程序 {program_name}...")
|
||||
|
||||
output = client.run_program(program_name)
|
||||
|
||||
if output.strip():
|
||||
print()
|
||||
print(output.rstrip())
|
||||
else:
|
||||
print()
|
||||
print_info("程序执行完成,无输出。")
|
||||
@@ -0,0 +1,131 @@
|
||||
"""代码质量命令:check (ATC) / format (Pretty Printer)。"""
|
||||
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.quality")
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""ATC 代码检查。"""
|
||||
name: str = args.name
|
||||
obj_type: str = args.type
|
||||
variant: str | None = getattr(args, "variant", None)
|
||||
|
||||
parsed = parse_object_name(name, obj_type)
|
||||
type_label = get_type_config(obj_type).label
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ATC 代码检查")
|
||||
print("=" * 60)
|
||||
print(f" 对象名称: {parsed.display_name}")
|
||||
print(f" 对象类型: {type_label}")
|
||||
if variant:
|
||||
print(f" 检查变体: {variant}")
|
||||
|
||||
print(f"\n → 正在执行 ATC 检查...")
|
||||
try:
|
||||
success, findings = client.atc_check(name, parsed.obj_uri, variant)
|
||||
except Exception as e:
|
||||
print(f" ✗ ATC 检查失败: {e}")
|
||||
return
|
||||
|
||||
if success and not findings:
|
||||
print(" ✓ ATC 检查通过,无发现项")
|
||||
return
|
||||
|
||||
errors = [f for f in findings if f["type"] in ("E", "1")]
|
||||
warnings = [f for f in findings if f["type"] in ("W", "2")]
|
||||
infos = [f for f in findings if f["type"] in ("I", "3")]
|
||||
|
||||
print(f"\n {'─' * 50}")
|
||||
if success:
|
||||
print(f" ✓ ATC 检查完成(无严重错误)")
|
||||
else:
|
||||
print(f" ✗ ATC 检查发现 {len(errors)} 个错误")
|
||||
|
||||
print(f" 错误: {len(errors)} | 警告: {len(warnings)} | 信息: {len(infos)}")
|
||||
print(f" {'─' * 50}")
|
||||
|
||||
for finding in findings:
|
||||
severity_icon = {"E": "✗", "1": "✗", "W": "⚠", "2": "⚠", "I": "ℹ", "3": "ℹ"}.get(
|
||||
finding["type"], "?"
|
||||
)
|
||||
line = finding.get("line", "?")
|
||||
text = finding.get("text", "")
|
||||
print(f" {severity_icon} [{finding['type']}] 行 {line}: {text}")
|
||||
|
||||
|
||||
def cmd_format(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""代码格式化(ABAP Pretty Printer)。"""
|
||||
name: str = args.name
|
||||
obj_type: str = args.type
|
||||
|
||||
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}没有源代码,不支持格式化操作")
|
||||
return
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ABAP 代码格式化")
|
||||
print("=" * 60)
|
||||
print(f" 对象名称: {parsed.display_name}")
|
||||
print(f" 对象类型: {type_label}")
|
||||
|
||||
# 读取当前源码
|
||||
print(f"\n → 正在读取源代码...")
|
||||
try:
|
||||
source = client.get_source(parsed.src_uri)
|
||||
except Exception as e:
|
||||
print(f" ✗ 读取源代码失败: {e}")
|
||||
return
|
||||
|
||||
print(f" ✓ 源代码读取成功 ({len(source)} 字符)")
|
||||
|
||||
# 调用 Pretty Printer
|
||||
print(f"\n → 正在调用 Pretty Printer...")
|
||||
try:
|
||||
formatted = client.pretty_print(source)
|
||||
except Exception as e:
|
||||
print(f" ✗ 格式化失败: {e}")
|
||||
return
|
||||
|
||||
if formatted == source:
|
||||
print(" ℹ 代码已经是格式化的,无需修改")
|
||||
return
|
||||
|
||||
print(f" ✓ 格式化完成")
|
||||
|
||||
# 写回 SAP
|
||||
print(f"\n → 正在写回 SAP...")
|
||||
try:
|
||||
lock_handle, corr_nr = client.lock(parsed.obj_uri)
|
||||
try:
|
||||
client.set_source(parsed.src_uri, formatted, lock_handle, corr_nr)
|
||||
print(f" ✓ 源代码已写回")
|
||||
finally:
|
||||
client.unlock(parsed.obj_uri, lock_handle)
|
||||
except Exception as e:
|
||||
print(f" ✗ 写回失败: {e}")
|
||||
return
|
||||
|
||||
# 激活
|
||||
try:
|
||||
success, messages = client.activate(name, parsed.obj_uri)
|
||||
if success:
|
||||
print(f" ✓ 激活成功")
|
||||
else:
|
||||
errors = [m for m in messages if m["type"] == "E"]
|
||||
print(f" ⚠ 激活返回 {len(errors)} 个错误")
|
||||
for e in errors:
|
||||
print(f" [错误] 行 {e['line']}: {e['text']}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ 激活失败: {e}")
|
||||
|
||||
print(f"\n ✓ 格式化操作完成!")
|
||||
@@ -0,0 +1,251 @@
|
||||
"""项目模板命令:scaffold。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
|
||||
logger = logging.getLogger("sapcli.commands.scaffold")
|
||||
|
||||
|
||||
def _alv_report_template(name: str, package: str) -> str:
|
||||
"""生成 ALV 报表模板。"""
|
||||
return (
|
||||
f"REPORT {name.upper()}.\n"
|
||||
f"\n"
|
||||
f"TYPES: BEGIN OF ty_data,\n"
|
||||
f" field1 TYPE char10,\n"
|
||||
f" field2 TYPE char20,\n"
|
||||
f" END OF ty_data.\n"
|
||||
f"\n"
|
||||
f"DATA: gt_data TYPE STANDARD TABLE OF ty_data,\n"
|
||||
f" gs_data TYPE ty_data,\n"
|
||||
f" go_alv TYPE REF TO cl_salv_table,\n"
|
||||
f" go_msg TYPE REF TO cx_salv_msg.\n"
|
||||
f"\n"
|
||||
f"START-OF-SELECTION.\n"
|
||||
f" PERFORM get_data.\n"
|
||||
f" PERFORM display_alv.\n"
|
||||
f"\n"
|
||||
f"FORM get_data.\n"
|
||||
f" \" TODO: 填充数据\n"
|
||||
f" gs_data-field1 = '示例'.\n"
|
||||
f" gs_data-field2 = '数据'.\n"
|
||||
f" APPEND gs_data TO gt_data.\n"
|
||||
f"ENDFORM.\n"
|
||||
f"\n"
|
||||
f"FORM display_alv.\n"
|
||||
f" TRY.\n"
|
||||
f" cl_salv_table=>factory(\n"
|
||||
f" IMPORTING\n"
|
||||
f" r_salv_table = go_alv\n"
|
||||
f" CHANGING\n"
|
||||
f" t_table = gt_data\n"
|
||||
f" ).\n"
|
||||
f" go_alv->display( ).\n"
|
||||
f" CATCH cx_salv_msg INTO go_msg.\n"
|
||||
f" MESSAGE go_msg->get_text( ) TYPE 'I'.\n"
|
||||
f" ENDTRY.\n"
|
||||
f"ENDFORM.\n"
|
||||
)
|
||||
|
||||
|
||||
def _bapi_wrapper_template(name: str, package: str) -> str:
|
||||
"""生成 BAPI 包装类模板。"""
|
||||
class_name = name.upper()
|
||||
return (
|
||||
f"CLASS {class_name} DEFINITION\n"
|
||||
f" PUBLIC\n"
|
||||
f" FINAL\n"
|
||||
f" CREATE PUBLIC.\n"
|
||||
f"\n"
|
||||
f" PUBLIC SECTION.\n"
|
||||
f" TYPES: BEGIN OF ty_result,\n"
|
||||
f" success TYPE abap_bool,\n"
|
||||
f" message TYPE string,\n"
|
||||
f" END OF ty_result.\n"
|
||||
f"\n"
|
||||
f" CLASS-METHODS:\n"
|
||||
f" call_bapi\n"
|
||||
f" IMPORTING\n"
|
||||
f" iv_param1 TYPE string OPTIONAL\n"
|
||||
f" RETURNING\n"
|
||||
f" VALUE(rs_result) TYPE ty_result.\n"
|
||||
f"\n"
|
||||
f" PRIVATE SECTION.\n"
|
||||
f" CLASS-METHODS:\n"
|
||||
f" _call_remote_bapi\n"
|
||||
f" IMPORTING\n"
|
||||
f" iv_param1 TYPE string\n"
|
||||
f" EXPORTING\n"
|
||||
f" ev_success TYPE abap_bool\n"
|
||||
f" ev_message TYPE string.\n"
|
||||
f"ENDCLASS.\n"
|
||||
f"\n"
|
||||
f"\n"
|
||||
f"CLASS {class_name} IMPLEMENTATION.\n"
|
||||
f"\n"
|
||||
f" METHOD call_bapi.\n"
|
||||
f" _call_remote_bapi(\n"
|
||||
f" EXPORTING\n"
|
||||
f" iv_param1 = iv_param1\n"
|
||||
f" IMPORTING\n"
|
||||
f" ev_success = rs_result-success\n"
|
||||
f" ev_message = rs_result-message\n"
|
||||
f" ).\n"
|
||||
f" ENDMETHOD.\n"
|
||||
f"\n"
|
||||
f" METHOD _call_remote_bapi.\n"
|
||||
f" \" TODO: 调用 BAPI 函数\n"
|
||||
f" ev_success = abap_true.\n"
|
||||
f" ev_message = '未实现'.\n"
|
||||
f" ENDMETHOD.\n"
|
||||
f"\n"
|
||||
f"ENDCLASS.\n"
|
||||
)
|
||||
|
||||
|
||||
def _interface_class_template(name: str, package: str) -> str:
|
||||
"""生成接口 + 实现类模板。"""
|
||||
name_upper = name.upper()
|
||||
if name_upper.startswith("Z"):
|
||||
if_name = f"ZIF_{name_upper[2:]}"
|
||||
else:
|
||||
if_name = f"ZIF_{name_upper}"
|
||||
cls_name = name_upper
|
||||
return (
|
||||
f"INTERFACE {if_name}\n"
|
||||
f" PUBLIC.\n"
|
||||
f" METHODS:\n"
|
||||
f" execute\n"
|
||||
f" RETURNING VALUE(rv_result) TYPE string.\n"
|
||||
f"ENDINTERFACE.\n"
|
||||
f"\n"
|
||||
f"\n"
|
||||
f"CLASS {cls_name} DEFINITION\n"
|
||||
f" PUBLIC\n"
|
||||
f" FINAL\n"
|
||||
f" CREATE PUBLIC.\n"
|
||||
f"\n"
|
||||
f" PUBLIC SECTION.\n"
|
||||
f" INTERFACES: {if_name}.\n"
|
||||
f"\n"
|
||||
f" PRIVATE SECTION.\n"
|
||||
f" DATA: mv_state TYPE string.\n"
|
||||
f"ENDCLASS.\n"
|
||||
f"\n"
|
||||
f"\n"
|
||||
f"CLASS {cls_name} IMPLEMENTATION.\n"
|
||||
f"\n"
|
||||
f" METHOD {if_name}~execute.\n"
|
||||
f" rv_result = 'Hello from {cls_name}'.\n"
|
||||
f" ENDMETHOD.\n"
|
||||
f"\n"
|
||||
f"ENDCLASS.\n"
|
||||
)
|
||||
|
||||
|
||||
def _data_model_template(name: str, package: str) -> str:
|
||||
"""生成数据模型模板(DDIC DDL)。"""
|
||||
name_lower = name.lower()
|
||||
return (
|
||||
f"@EndUserText.label: '{name.upper()} 数据模型'\n"
|
||||
f"@AbapCatalog.enhancementCategory: #NOT_CLASSIFIED\n"
|
||||
f"@AbapCatalog.tableCategory: #TRANSPARENT\n"
|
||||
f"@AbapCatalog.deliveryClass: #A\n"
|
||||
f"@AbapCatalog.dataMaintenance: #LIMITED\n"
|
||||
f"define table {name_lower} {{\n"
|
||||
f" key_client : abap.clnt not null;\n"
|
||||
f" key_id : abap.char10 not null;\n"
|
||||
f" name : abap.char40;\n"
|
||||
f" created_at : timestampl;\n"
|
||||
f" changed_at : timestampl;\n"
|
||||
f"}}\n"
|
||||
)
|
||||
|
||||
|
||||
# 模板注册表
|
||||
_TEMPLATES: dict[str, dict] = {
|
||||
"alv-report": {
|
||||
"desc": "ALV 报表模板",
|
||||
"file_name": "{name_lower}.abap",
|
||||
"dir": "reports",
|
||||
"generator": _alv_report_template,
|
||||
},
|
||||
"bapi-wrapper": {
|
||||
"desc": "BAPI 包装类模板",
|
||||
"file_name": "{name_lower}.abap",
|
||||
"dir": "classes",
|
||||
"generator": _bapi_wrapper_template,
|
||||
},
|
||||
"interface-class": {
|
||||
"desc": "接口 + 实现类模板",
|
||||
"file_name": "{name_lower}.abap",
|
||||
"dir": "classes",
|
||||
"generator": _interface_class_template,
|
||||
},
|
||||
"data-model": {
|
||||
"desc": "数据模型模板 (DDIC DDL)",
|
||||
"file_name": "{name_lower}.ddl",
|
||||
"dir": "tables",
|
||||
"generator": _data_model_template,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def cmd_scaffold(args: argparse.Namespace, client: ADTClient | None = None) -> None:
|
||||
"""项目模板创建。"""
|
||||
# 不指定模板时,列出可用模板
|
||||
template = getattr(args, "template", None)
|
||||
if not template:
|
||||
print("=" * 60)
|
||||
print(" sap-cli 可用模板")
|
||||
print("=" * 60)
|
||||
for key, info in _TEMPLATES.items():
|
||||
desc = info.get("description", "")
|
||||
print(f" {key:20s} {desc}")
|
||||
print()
|
||||
print(" 用法: sap-cli scaffold --name ZXXX --template <模板名>")
|
||||
return
|
||||
|
||||
name: str = args.name
|
||||
package: str = getattr(args, "package", "$TMP") or "$TMP"
|
||||
output_dir: str = getattr(args, "path", ".") or "."
|
||||
|
||||
template_info = _TEMPLATES.get(template)
|
||||
if not template_info:
|
||||
print(f" ✗ 不支持的模板类型: {template}")
|
||||
print(f" 可用模板: {', '.join(_TEMPLATES.keys())}")
|
||||
return
|
||||
|
||||
print("=" * 60)
|
||||
print(" sap-cli 项目模板创建")
|
||||
print("=" * 60)
|
||||
print(f" 对象名称: {name}")
|
||||
print(f" 模板: {template} ({template_info['desc']})")
|
||||
print(f" 包: {package}")
|
||||
print(f" 输出目录: {output_dir}")
|
||||
|
||||
# 生成源码
|
||||
name_lower = name.lower()
|
||||
source = template_info["generator"](name, package)
|
||||
|
||||
# 确定输出路径
|
||||
target_dir = os.path.join(output_dir, template_info["dir"])
|
||||
if not os.path.isdir(target_dir):
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
filename = template_info["file_name"].format(name_lower=name_lower)
|
||||
filepath = os.path.join(target_dir, filename)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(source)
|
||||
|
||||
print(f"\n ✓ 模板已生成!")
|
||||
print(f" 文件: {filepath}")
|
||||
print(f" 大小: {len(source)} 字符")
|
||||
print(f"\n 下一步:")
|
||||
print(f" 1. 编辑 {filepath} 完善代码")
|
||||
print(f" 2. 运行 sap-cli sync --name {name} --type report --path {filepath}")
|
||||
@@ -0,0 +1,147 @@
|
||||
"""搜索与浏览命令:list / whereused / search。"""
|
||||
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.search")
|
||||
|
||||
# 对象类型关键字 → ADT 类型代码映射
|
||||
_TYPE_FILTER_MAP: dict[str, str] = {
|
||||
"report": "PROG/P",
|
||||
"class": "CLAS/OC",
|
||||
"interface": "INTF/OI",
|
||||
"function": "FUNC/F",
|
||||
"functiongroup": "FUGR/F",
|
||||
"domain": "DOMA/DD",
|
||||
"dataelement": "DTEL/DE",
|
||||
"table": "TABL/TT",
|
||||
"structure": "TABL/ST",
|
||||
"tabletype": "TTYP",
|
||||
"include": "PROG/I",
|
||||
"cdsview": "DDLS/DF",
|
||||
"messageclass": "MSAG",
|
||||
"view": "VIEW",
|
||||
}
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""列出 SAP 对象。"""
|
||||
obj_type = getattr(args, "type", None)
|
||||
package = getattr(args, "package", None)
|
||||
prefix = getattr(args, "prefix", None)
|
||||
|
||||
# 将友好类型名转换为 ADT 类型代码
|
||||
adt_type = None
|
||||
if obj_type:
|
||||
adt_type = _TYPE_FILTER_MAP.get(obj_type, obj_type)
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 对象列表")
|
||||
print("=" * 60)
|
||||
if obj_type:
|
||||
print(f" 类型过滤: {obj_type}")
|
||||
if package:
|
||||
print(f" 包过滤: {package}")
|
||||
if prefix:
|
||||
print(f" 名称前缀: {prefix}")
|
||||
|
||||
print(f"\n → 正在搜索对象...")
|
||||
try:
|
||||
results = client.list_objects(obj_type=adt_type, package=package, prefix=prefix)
|
||||
except Exception as e:
|
||||
print(f" ✗ 搜索失败: {e}")
|
||||
return
|
||||
|
||||
if not results:
|
||||
print(" ℹ 未找到匹配的对象")
|
||||
return
|
||||
|
||||
print(f" ✓ 找到 {len(results)} 个对象\n")
|
||||
print(f" {'名称':<30s} {'类型':<12s} {'包':<15s} {'描述':<30s}")
|
||||
print(f" {'─' * 30} {'─' * 12} {'─' * 15} {'─' * 30}")
|
||||
for obj in results:
|
||||
name = obj.get("name", "")[:30]
|
||||
otype = obj.get("type", "")[:12]
|
||||
pkg = obj.get("package", "")[:15]
|
||||
desc = obj.get("description", "")[:30]
|
||||
print(f" {name:<30s} {otype:<12s} {pkg:<15s} {desc:<30s}")
|
||||
|
||||
|
||||
def cmd_whereused(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""Where-Used 引用查询。"""
|
||||
name: str = args.name
|
||||
obj_type: str | None = getattr(args, "type", None)
|
||||
|
||||
parsed = parse_object_name(name, obj_type or "report")
|
||||
type_label = get_type_config(obj_type or "report").label
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT Where-Used 查询")
|
||||
print("=" * 60)
|
||||
print(f" 对象名称: {parsed.display_name}")
|
||||
if obj_type:
|
||||
print(f" 对象类型: {type_label}")
|
||||
|
||||
print(f"\n → 正在查询引用关系...")
|
||||
adt_type = _TYPE_FILTER_MAP.get(obj_type, obj_type) if obj_type else None
|
||||
try:
|
||||
results = client.where_used(name, parsed.obj_uri, adt_type)
|
||||
except Exception as e:
|
||||
print(f" ✗ 查询失败: {e}")
|
||||
return
|
||||
|
||||
if not results:
|
||||
print(" ℹ 未找到引用关系")
|
||||
return
|
||||
|
||||
print(f" ✓ 找到 {len(results)} 个引用\n")
|
||||
print(f" {'名称':<30s} {'类型':<12s} {'包':<15s} {'URI':<40s}")
|
||||
print(f" {'─' * 30} {'─' * 12} {'─' * 15} {'─' * 40}")
|
||||
for ref in results:
|
||||
ref_name = ref.get("name", "")[:30]
|
||||
ref_type = ref.get("type", "")[:12]
|
||||
ref_pkg = ref.get("package", "")[:15]
|
||||
ref_uri = ref.get("uri", "")[:40]
|
||||
print(f" {ref_name:<30s} {ref_type:<12s} {ref_pkg:<15s} {ref_uri:<40s}")
|
||||
|
||||
|
||||
def cmd_search(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""源代码搜索。"""
|
||||
query: str = args.query
|
||||
obj_type: str | None = getattr(args, "type", None)
|
||||
|
||||
# 将友好类型名转换为 ADT 类型代码
|
||||
adt_type = None
|
||||
if obj_type:
|
||||
adt_type = _TYPE_FILTER_MAP.get(obj_type, obj_type)
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP ADT 源代码搜索")
|
||||
print("=" * 60)
|
||||
print(f" 搜索关键词: {query}")
|
||||
if obj_type:
|
||||
print(f" 类型过滤: {obj_type}")
|
||||
|
||||
print(f"\n → 正在搜索...")
|
||||
try:
|
||||
results = client.search_code(query, obj_type=adt_type)
|
||||
except Exception as e:
|
||||
print(f" ✗ 搜索失败: {e}")
|
||||
return
|
||||
|
||||
if not results:
|
||||
print(" ℹ 未找到匹配的代码")
|
||||
return
|
||||
|
||||
print(f" ✓ 找到 {len(results)} 个结果\n")
|
||||
print(f" {'名称':<30s} {'类型':<12s} {'描述':<40s}")
|
||||
print(f" {'─' * 30} {'─' * 12} {'─' * 40}")
|
||||
for obj in results:
|
||||
obj_name = obj.get("name", "")[:30]
|
||||
obj_type_val = obj.get("type", "")[:12]
|
||||
desc = obj.get("description", "")[:40]
|
||||
print(f" {obj_name:<30s} {obj_type_val:<12s} {desc:<40s}")
|
||||
@@ -0,0 +1,136 @@
|
||||
"""传输管理命令:transport (list / info / release / objects)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
|
||||
logger = logging.getLogger("sapcli.commands.transport")
|
||||
|
||||
|
||||
def cmd_transport(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""传输请求管理。"""
|
||||
action = getattr(args, "transport_action", None)
|
||||
|
||||
if action == "list":
|
||||
_transport_list(args, client)
|
||||
elif action == "info":
|
||||
_transport_info(args, client)
|
||||
elif action == "release":
|
||||
_transport_release(args, client)
|
||||
elif action == "objects":
|
||||
_transport_objects(args, client)
|
||||
else:
|
||||
print(" 用法: sap-cli transport [list|info|release|objects]")
|
||||
print(" list — 列出可修改的传输请求")
|
||||
print(" info — 查看传输请求详情")
|
||||
print(" release — 释放传输请求")
|
||||
print(" objects — 列出传输请求中的对象")
|
||||
|
||||
|
||||
def _transport_list(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""列出可修改的传输请求。"""
|
||||
print("=" * 60)
|
||||
print(" SAP 传输请求列表")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\n → 正在查询传输请求...")
|
||||
try:
|
||||
requests_list = client.list_transport_requests()
|
||||
except Exception as e:
|
||||
print(f" ✗ 查询失败: {e}")
|
||||
return
|
||||
|
||||
if not requests_list:
|
||||
print(" ℹ 未找到可修改的传输请求")
|
||||
return
|
||||
|
||||
print(f" ✓ 找到 {len(requests_list)} 个传输请求\n")
|
||||
print(f" {'编号':<15s} {'描述':<40s} {'所有者':<15s}")
|
||||
print(f" {'─' * 15} {'─' * 40} {'─' * 15}")
|
||||
for req in requests_list:
|
||||
num = req.get("number", "")[:15]
|
||||
desc = req.get("description", "")[:40]
|
||||
owner = req.get("owner", "")[:15]
|
||||
print(f" {num:<15s} {desc:<40s} {owner:<15s}")
|
||||
|
||||
|
||||
def _transport_info(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""查看传输请求详情。"""
|
||||
corr_nr: str = args.corr_nr
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP 传输请求详情")
|
||||
print("=" * 60)
|
||||
print(f" 传输请求: {corr_nr}")
|
||||
|
||||
print(f"\n → 正在查询...")
|
||||
try:
|
||||
info = client.transport_info(corr_nr)
|
||||
except Exception as e:
|
||||
print(f" ✗ 查询失败: {e}")
|
||||
return
|
||||
|
||||
print(f"\n {'─' * 50}")
|
||||
print(f" 描述: {info.get('description', '')}")
|
||||
print(f" 状态: {info.get('status', '')}")
|
||||
print(f" 所有者: {info.get('owner', '')}")
|
||||
print(f" {'─' * 50}")
|
||||
|
||||
|
||||
def _transport_release(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""释放传输请求。"""
|
||||
corr_nr: str = args.corr_nr
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP 释放传输请求")
|
||||
print("=" * 60)
|
||||
print(f" 传输请求: {corr_nr}")
|
||||
|
||||
print(f"\n ⚠️ 即将释放传输请求: {corr_nr}")
|
||||
confirm = input(" 确认释放? (输入 yes 确认): ").strip()
|
||||
if confirm.lower() != "yes":
|
||||
print(" 已取消释放操作")
|
||||
return
|
||||
|
||||
print(f"\n → 正在释放...")
|
||||
try:
|
||||
success = client.transport_release(corr_nr)
|
||||
except Exception as e:
|
||||
print(f" ✗ 释放失败: {e}")
|
||||
return
|
||||
|
||||
if success:
|
||||
print(f" ✓ 传输请求 {corr_nr} 已成功释放!")
|
||||
else:
|
||||
print(f" ✗ 释放失败,请检查传输请求状态")
|
||||
|
||||
|
||||
def _transport_objects(args: argparse.Namespace, client: ADTClient) -> None:
|
||||
"""列出传输请求中的对象。"""
|
||||
corr_nr: str = args.corr_nr
|
||||
|
||||
print("=" * 60)
|
||||
print(" SAP 传输请求对象列表")
|
||||
print("=" * 60)
|
||||
print(f" 传输请求: {corr_nr}")
|
||||
|
||||
print(f"\n → 正在查询...")
|
||||
try:
|
||||
objects = client.transport_objects(corr_nr)
|
||||
except Exception as e:
|
||||
print(f" ✗ 查询失败: {e}")
|
||||
return
|
||||
|
||||
if not objects:
|
||||
print(" ℹ 传输请求中无对象")
|
||||
return
|
||||
|
||||
print(f" ✓ 找到 {len(objects)} 个对象\n")
|
||||
print(f" {'名称':<30s} {'类型':<15s}")
|
||||
print(f" {'─' * 30} {'─' * 15}")
|
||||
for obj in objects:
|
||||
obj_name = obj.get("name", "")[:30]
|
||||
obj_type = obj.get("type", "")[:15]
|
||||
print(f" {obj_name:<30s} {obj_type:<15s}")
|
||||
Reference in New Issue
Block a user