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:
2026-06-13 20:52:24 +08:00
commit 1e39b6da88
49 changed files with 7172 additions and 0 deletions
+328
View File
@@ -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')}")