SKILL.md(唯一来源 = sap-cli/skill-src/SKILL.md.tmpl):
- 本仓 SKILL.md 自此为构建产物,勿手工编辑;手改后再构建会被漂移护栏拦下
- 并入模板独有内容:调用方式节 + 补齐 parser 真实命令(clone/enhancement/
history/unit-test/unlock/transport create,原表只 9 个,实际 31 个)
- 版本号改为 {{VERSION}} 注入(原字面量停在 v2.2.1/v2.3.0,与工具实际版本脱节)
- 修 1 处违反铁律 5 的示例(--path ./src → ./src/TMP/class/)
assets/(工具代码,此前严重过时):
- 9 个文件与源码不一致,scanner.py 尤甚:3818B → 6664B
- 修复前从本仓装出的工具**不支持三层 src 结构**,铁律 5 实际跑不通
- transport.py 反向漂移(分发版多 transport create,源码无)已随源码回迁对齐
VERSION 2.5.1;references/ 三份规则与源码一致(无差异)
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""批量操作命令: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(" 三层: src/<开发包>/<对象类型>/<文件名>.abap (本地开发包固定 TMP)")
|
||
print(" 扁平: <对象类型>/<文件名>.abap (对象类型目录单复数均可)")
|
||
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')}")
|