refactor: 本仓升为唯一源(原 sap-cli 源码仓归档)

方向反转:此前 SKILL.md 是「模板渲染产物」、sap-cli 是源;现 sap-cli 归档,
sap-cli-skill 承接开发与分发,SKILL.md 回归手工维护的正本。

迁移(来自 sap-cli,共 104 文件):
- tests/           692 例测试(15 个文件的内联 sys.path 改指 assets/)
- openspec/        SDD 规格与归档变更(42 文件)
- docs/            开发文档与 ADT 原理(含 dev/CLAUDE.md、AGENTS.md)
- .claude/         rules 副本 + settings.json(供 Claude Code)
- .github/ .hermes/ .pre-commit-config.yaml .editorconfig CLAUDE.md
- scripts/ 保持仅 setup.py(pack_skill.py 已随旧仓归档,不迁)

修复(迁移暴露的真实缺陷):
- assets/pyproject.toml 的 build-backend 写作 `setuptools.backends._legacy:_Backend`,
  该模块在 setuptools 中不存在 → `pip install -e` 从来装不上。改为 build_meta。
  实测:临时 venv 安装成功,sap-cli --help 正常列出 31 个命令
- pyproject readme 指向不存在的 assets/README.md(editable 安装会失败)→ 改内联文本
- pyproject urls 改指 sap-cli-skill

机制调整:
- .github/workflows/ci.yml 适配 assets/ 布局;顶部注明该工作流仅 GitHub 执行,
  本仓在 Gitee 不会自动跑
- pre-commit 增本地测试门禁(Gitee 上真正生效的那道)
- .gitignore 合并旧仓完整规则(保留 log/ 下 md 知识库入库,只忽略运行日志)
- 大文件上限 100KB→1MB(架构图 512KB)

守卫测试 tests/unit/test_repo_guards.py(10 → 18 例):
- SKILL.md 须记录 parser 全部 CLI 命令 / 铁律 1-5 须为真实小节标题 / 示例不得违反铁律 5
- references/ 规则齐备;.claude/rules 与 references 必须一致(实测抓到一次真实漂移)
- VERSION == sapcli.__version__ == README 版本
- 仓内不得再出现 pack_skill.py / skill-src(防废弃流程回潮)

698 tests OK;editable 安装与 CLI 入口经临时 venv 实测通过。
docs/RELEASING.md 重写为单源开发流程。
This commit is contained in:
吴让宇
2026-09-11 00:40:15 +08:00
parent e786742bcb
commit c5905a5b1e
104 changed files with 20744 additions and 10 deletions
+600
View File
@@ -0,0 +1,600 @@
"""commands/ 模块单元测试
运行: python tests/unit/test_commands.py
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch, PropertyMock
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets"))
from sapcli.client import ADTClient
def _mock_resp(status_code=200, text="", content=b"", headers=None):
r = MagicMock()
r.status_code = status_code
r.text = text
r.content = content
r.headers = headers or {}
r.raise_for_status = MagicMock()
return r
def _make_client():
"""创建完整 mock 的 ADTClient。"""
client = MagicMock()
client.host = "https://sap.example.com"
client.sap_client = "100"
client.csrf_token = "test-csrf-token"
client.user = "TESTUSER"
# client.session 用于直接 HTTP 调用
client.session = MagicMock()
client._headers.return_value = {
"content-type": "application/xml",
"x-csrf-token": "test-csrf-token",
}
return client
def _args(**kwargs):
defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None}
defaults.update(kwargs)
return argparse.Namespace(**defaults)
# ═══════════════════════════════════════════
# crud.py — download
# ═══════════════════════════════════════════
class TestCmdDownload(unittest.TestCase):
def test_download_report_success(self):
from sapcli.commands.crud import cmd_download
client = _make_client()
client.object_exists.return_value = True
client.get_source.return_value = "REPORT ztest.\nWRITE: / 'hello'."
with tempfile.TemporaryDirectory() as td:
cmd_download(_args(path=td), client)
self.assertTrue(os.path.isfile(os.path.join(td, "ztest.abap")))
def test_download_object_not_found(self):
from sapcli.commands.crud import cmd_download
from sapcli.exceptions import ObjectNotFoundError
client = _make_client()
client.object_exists.return_value = False
with tempfile.TemporaryDirectory() as td:
with self.assertRaises(ObjectNotFoundError):
cmd_download(_args(path=td), client)
def test_download_creates_dir(self):
from sapcli.commands.crud import cmd_download
client = _make_client()
client.object_exists.return_value = True
client.get_source.return_value = "REPORT ztest."
with tempfile.TemporaryDirectory() as td:
subdir = os.path.join(td, "sub", "dir")
cmd_download(_args(path=subdir), client)
self.assertTrue(os.path.isfile(os.path.join(subdir, "ztest.abap")))
def test_download_class(self):
from sapcli.commands.crud import cmd_download
client = _make_client()
client.object_exists.return_value = True
client.get_source.return_value = "CLASS zcl_test DEFINITION."
with tempfile.TemporaryDirectory() as td:
cmd_download(_args(type="class", name="ZCL_TEST", path=td), client)
self.assertTrue(os.path.isfile(os.path.join(td, "zcl_test.abap")))
# ═══════════════════════════════════════════
# crud.py — sync
# ═══════════════════════════════════════════
class TestCmdSync(unittest.TestCase):
def test_sync_report_success(self):
from sapcli.commands.crud import cmd_sync
client = _make_client()
client.lock.return_value = ("lh_1", "DEVK001")
client.set_source.return_value = True
client.unlock.return_value = True
client.activate.return_value = (True, [])
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.\nWRITE: / 'hello'.")
tmpfile = f.name
try:
cmd_sync(_args(path=tmpfile, project_path=None), client)
client.lock.assert_called_once()
client.set_source.assert_called_once()
finally:
os.unlink(tmpfile)
def test_sync_file_not_found(self):
from sapcli.commands.crud import cmd_sync
from sapcli.exceptions import ConfigError
client = _make_client()
with self.assertRaises(ConfigError):
cmd_sync(_args(path="/nonexistent/file.abap"), client)
def test_sync_with_check(self):
from sapcli.commands.crud import cmd_sync
client = _make_client()
client.lock.return_value = ("lh_1", "DEVK001")
client.set_source.return_value = True
client.unlock.return_value = True
client.activate.return_value = (True, [])
client.syntax_check.return_value = (True, [])
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.")
tmpfile = f.name
try:
cmd_sync(_args(path=tmpfile, check=True, project_path=None), client)
client.syntax_check.assert_called_once()
finally:
os.unlink(tmpfile)
# ═══════════════════════════════════════════
# crud.py — info(直接用 client.session.get
# ═══════════════════════════════════════════
_INFO_XML = (
b'<?xml version="1.0"?>'
b'<adtcore:object xmlns:adtcore="http://www.sap.com/adt/core"'
b' adtcore:name="ZTEST" adtcore:type="PROG/P"'
b' adtcore:version="active" adtcore:description="Test report"'
b' adtcore:masterLanguage="EN"/>'
)
class TestCmdInfo(unittest.TestCase):
def test_info_success(self):
from sapcli.commands.crud import cmd_info
client = _make_client()
client.session.get.return_value = _mock_resp(200, content=_INFO_XML)
cmd_info(_args(), client)
client.session.get.assert_called()
def test_info_not_exists(self):
from sapcli.commands.crud import cmd_info
from sapcli.exceptions import ObjectNotFoundError
client = _make_client()
client.session.get.return_value = _mock_resp(404)
with self.assertRaises(ObjectNotFoundError):
cmd_info(_args(), client)
# ═══════════════════════════════════════════
# crud.py — delete
# ═══════════════════════════════════════════
class TestCmdDelete(unittest.TestCase):
@patch("builtins.input", return_value="yes")
@patch("sapcli.commands.crud.Manifest")
def test_delete_success(self, MockManifest, mock_input):
from sapcli.commands.crud import cmd_delete
client = _make_client()
client.object_exists.return_value = True
client.delete_object.return_value = (True, "")
cmd_delete(_args(), client)
client.delete_object.assert_called_once()
def test_delete_failure(self):
from sapcli.commands.crud import cmd_delete
from sapcli.exceptions import DeleteError
client = _make_client()
client.object_exists.return_value = True
client.delete_object.side_effect = DeleteError("删除失败")
with self.assertRaises(DeleteError), patch("builtins.input", return_value="yes"):
cmd_delete(_args(), client)
# ═══════════════════════════════════════════
# crud.py — create
# ═══════════════════════════════════════════
class TestCmdCreate(unittest.TestCase):
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
@patch("sapcli.commands.crud.Manifest")
def test_create_report_no_source(self, MockManifest, mock_transport):
from sapcli.commands.crud import cmd_create
client = _make_client()
client.object_exists.return_value = False
client.create_object.return_value = ("/uri/ztest", "/uri/ztest/source/main")
cmd_create(_args(source=None, description="Test", package="$TMP", corr_nr=None), client)
client.create_object.assert_called_once()
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
@patch("sapcli.commands.crud.Manifest")
def test_create_with_source_file(self, MockManifest, mock_transport):
from sapcli.commands.crud import cmd_create
client = _make_client()
client.object_exists.return_value = False
client.create_object.return_value = ("/uri/ztest", "/uri/ztest/source/main")
client.lock.return_value = ("lh_1", "DEVK001")
client.set_source.return_value = True
client.unlock.return_value = True
client.activate.return_value = (True, [])
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.")
tmpfile = f.name
try:
cmd_create(_args(source=tmpfile, description="Test", package="$TMP", corr_nr=None), client)
finally:
os.unlink(tmpfile)
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
@patch("sapcli.commands.crud.Manifest")
def test_create_ddic_type(self, MockManifest, mock_transport):
from sapcli.commands.crud import cmd_create
client = _make_client()
client.object_exists.return_value = False
client.create_ddic_object.return_value = ("/uri/ztest_dom", "/uri/ztest_dom/source/main")
cmd_create(_args(type="domain", name="ZTEST_DOM", source=None, description="Domain", package="$TMP", corr_nr=None), client)
client.create_ddic_object.assert_called_once()
# ═══════════════════════════════════════════
# crud.py — print_source_preview
# ═══════════════════════════════════════════
class TestPrintSourcePreview(unittest.TestCase):
def test_preview_short(self):
from sapcli.commands.crud import print_source_preview
with patch("builtins.print"):
print_source_preview("REPORT ztest.\nWRITE: / 'hello'.")
def test_preview_long(self):
from sapcli.commands.crud import print_source_preview
long_src = "\n".join(f"LINE {i}" for i in range(100))
with patch("builtins.print"):
print_source_preview(long_src, max_lines=5)
# ═══════════════════════════════════════════
# batch.py — init
# ═══════════════════════════════════════════
class TestCmdInit(unittest.TestCase):
def test_init_empty_dir_no_crash(self):
"""空目录不崩溃(不扫描到 .abap 文件则提前返回)。"""
from sapcli.commands.batch import cmd_init
client = _make_client()
with tempfile.TemporaryDirectory() as td:
cmd_init(argparse.Namespace(name="ZTEST", type="report", path=td, config=None), client)
# 空目录无 .abap 文件 → 不创建 manifest → 不崩溃即可
def test_init_with_abap_file(self):
"""有 .abap 文件时,初始化应创建 manifest。"""
from sapcli.commands.batch import cmd_init
client = _make_client()
client.get_object_status.return_value = {"exists": True, "status": "active", "corr_nr": None}
with tempfile.TemporaryDirectory() as td:
report_dir = os.path.join(td, "reports")
os.makedirs(report_dir)
with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f:
f.write("REPORT ztest.")
cmd_init(argparse.Namespace(name="ZTEST", type="report", path=td, config=None), client)
self.assertTrue(os.path.isfile(os.path.join(td, "manifest.json")))
class TestCmdRefresh(unittest.TestCase):
def test_refresh_success(self):
from sapcli.commands.batch import cmd_refresh
from sapcli.manifest import Manifest, ManifestEntry
client = _make_client()
client.get_object_status.return_value = {
"exists": True, "status": "active", "corr_nr": None
}
with tempfile.TemporaryDirectory() as td:
# 直接写 manifest.json 文件,供 Manifest.load() 读取
entry = ManifestEntry(
name="ZTEST", type="report",
file="reports/ztest.abap",
system_status="not_exists",
)
import json
manifest_data = {
"version": 2,
"objects": {"ZTEST": entry.to_dict()},
}
manifest_path = os.path.join(td, "manifest.json")
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest_data, f)
os.makedirs(os.path.join(td, "reports"))
cmd_refresh(argparse.Namespace(path=td, config=None), client)
client.get_object_status.assert_called()
# ═══════════════════════════════════════════
# cds.py
# ═══════════════════════════════════════════
class TestCmdCds(unittest.TestCase):
def test_cds_download(self):
from sapcli.commands.cds import cmd_cds
client = _make_client()
client.get_cds_source.return_value = "define view Z_TEST as select from mara { mara.matnr };"
with tempfile.TemporaryDirectory() as td:
cmd_cds(argparse.Namespace(cds_action="download", name="Z_TEST", path=td, config=None), client)
client.get_cds_source.assert_called_once()
def test_cds_create(self):
from sapcli.commands.cds import cmd_cds
client = _make_client()
client.create_cds.return_value = ("/uri/z_test", "/uri/z_test/source/main")
with tempfile.TemporaryDirectory() as td:
cmd_cds(argparse.Namespace(
cds_action="create", name="Z_TEST", description="Test CDS",
source=None, path=td, config=None
), client)
client.create_cds.assert_called_once()
# ═══════════════════════════════════════════
# config_cmd.py
# ═══════════════════════════════════════════
class TestCmdConfig(unittest.TestCase):
def test_config_show(self):
from sapcli.commands.config_cmd import cmd_config
with patch("sapcli.commands.config_cmd._config_show"):
cmd_config(argparse.Namespace(config_action="show", config=None))
def test_config_list_profiles(self):
from sapcli.commands.config_cmd import cmd_config
with patch("sapcli.commands.config_cmd._config_list_profiles"):
cmd_config(argparse.Namespace(config_action="list-profiles", config=None))
def test_config_set(self):
from sapcli.commands.config_cmd import cmd_config
with patch("sapcli.commands.config_cmd._config_set"):
cmd_config(argparse.Namespace(config_action="set", key="host", value="http://new:8000", config=None))
# ═══════════════════════════════════════════
# diff_cmd.py
# ═══════════════════════════════════════════
class TestCmdDiff(unittest.TestCase):
def test_diff_success(self):
from sapcli.commands.diff_cmd import cmd_diff
client = _make_client()
client.read_source_for_diff.return_value = "REPORT zremote."
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT zlocal.")
tmpfile = f.name
try:
cmd_diff(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client)
client.read_source_for_diff.assert_called_once()
finally:
os.unlink(tmpfile)
# ═══════════════════════════════════════════
# package_cmd.py
# ═══════════════════════════════════════════
class TestCmdPackage(unittest.TestCase):
def test_package_info(self):
from sapcli.commands.package_cmd import cmd_package
client = _make_client()
client.get_package_info.return_value = {"name": "Z_TEST", "description": "Test"}
cmd_package(argparse.Namespace(package_action="info", name="Z_TEST", config=None), client)
client.get_package_info.assert_called_once()
def test_package_create(self):
from sapcli.commands.package_cmd import cmd_package
client = _make_client()
client.create_package.return_value = True
cmd_package(argparse.Namespace(package_action="create", name="Z_TEST_PKG", description="Test", superpackage=None, config=None), client)
client.create_package.assert_called_once()
# ═══════════════════════════════════════════
# quality.py
# ═══════════════════════════════════════════
class TestCmdCheck(unittest.TestCase):
def test_check_clean(self):
from sapcli.commands.quality import cmd_check
client = _make_client()
client.atc_check.return_value = (True, [])
cmd_check(_args(), client)
client.atc_check.assert_called_once()
def test_check_with_errors(self):
from sapcli.commands.quality import cmd_check
client = _make_client()
client.atc_check.return_value = (False, [{"type": "E", "line": "1", "text": "Error"}])
cmd_check(_args(), client) # 打印错误但不抛异常
class TestCmdFormat(unittest.TestCase):
def test_format_success(self):
from sapcli.commands.quality import cmd_format
client = _make_client()
client.pretty_print.return_value = "FORMATTED CODE"
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.")
tmpfile = f.name
try:
cmd_format(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client)
client.pretty_print.assert_called_once()
finally:
os.unlink(tmpfile)
# ═══════════════════════════════════════════
# search.py
# ═══════════════════════════════════════════
class TestCmdList(unittest.TestCase):
def test_list_found(self):
from sapcli.commands.search import cmd_list
client = _make_client()
client.list_objects.return_value = [{"name": "Z_TEST", "type": "PROG/P", "package": "$TMP"}]
cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client)
client.list_objects.assert_called_once()
def test_list_empty(self):
from sapcli.commands.search import cmd_list
client = _make_client()
client.list_objects.return_value = []
cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client)
class TestCmdWhereUsed(unittest.TestCase):
def test_whereused_found(self):
from sapcli.commands.search import cmd_whereused
client = _make_client()
client.where_used.return_value = [{"name": "Z_USER", "type": "PROG/P"}]
cmd_whereused(_args(), client)
client.where_used.assert_called_once()
class TestCmdSearch(unittest.TestCase):
def test_search_found(self):
from sapcli.commands.search import cmd_search
client = _make_client()
client.search_code.return_value = [{"name": "Z_RESULT", "type": "PROG/P"}]
cmd_search(argparse.Namespace(query="SELECT", type=None, config=None), client)
client.search_code.assert_called_once()
# ═══════════════════════════════════════════
# transport.py
# ═══════════════════════════════════════════
class TestCmdTransport(unittest.TestCase):
def test_transport_list(self):
from sapcli.commands.transport import cmd_transport
client = _make_client()
client.list_transport_requests.return_value = [
{"number": "DEVK001", "description": "Test", "owner": "TESTUSER", "status": "D"}
]
cmd_transport(argparse.Namespace(transport_action="list", config=None), client)
client.list_transport_requests.assert_called_once()
def test_transport_info(self):
from sapcli.commands.transport import cmd_transport
client = _make_client()
client.transport_info.return_value = {"number": "DEVK001", "description": "Test", "status": "D"}
cmd_transport(argparse.Namespace(transport_action="info", number="DEVK001", corr_nr="DEVK001", config=None), client)
client.transport_info.assert_called_once()
def test_transport_release(self):
from sapcli.commands.transport import cmd_transport
client = _make_client()
client.transport_release.return_value = True
with patch("builtins.input", return_value="yes"):
cmd_transport(argparse.Namespace(transport_action="release", number="DEVK001", corr_nr="DEVK001", config=None), client)
client.transport_release.assert_called_once()
# ═══════════════════════════════════════════
# analyze.py
# ═══════════════════════════════════════════
class TestCmdAnalyze(unittest.TestCase):
def test_analyze_success(self):
from sapcli.commands.analyze import cmd_analyze
client = _make_client()
client.get_source.return_value = "REPORT ztest.\nCALL FUNCTION 'BAPI_TEST'."
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
f.write("REPORT ztest.")
tmpfile = f.name
try:
cmd_analyze(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client)
finally:
os.unlink(tmpfile)
# ═══════════════════════════════════════════
# scaffold.py
# ═══════════════════════════════════════════
class TestCmdScaffold(unittest.TestCase):
def test_scaffold_no_template_lists(self):
from sapcli.commands.scaffold import cmd_scaffold
with patch("builtins.print") as mock_print:
cmd_scaffold(argparse.Namespace(template=None, name="ZTEST", path=".", config=None, package="$TMP"), None)
self.assertTrue(mock_print.called)
def test_scaffold_alv_report(self):
from sapcli.commands.scaffold import cmd_scaffold
with tempfile.TemporaryDirectory() as td:
cmd_scaffold(argparse.Namespace(template="alv-report", name="ZALV_TEST", path=td, config=None, package="$TMP"), None)
self.assertTrue(len(os.listdir(td)) > 0)
def test_scaffold_bapi_wrapper(self):
from sapcli.commands.scaffold import cmd_scaffold
with tempfile.TemporaryDirectory() as td:
cmd_scaffold(argparse.Namespace(template="bapi-wrapper", name="ZBAPI_TEST", path=td, config=None, package="$TMP"), None)
self.assertTrue(len(os.listdir(td)) > 0)
def test_scaffold_interface_class(self):
from sapcli.commands.scaffold import cmd_scaffold
with tempfile.TemporaryDirectory() as td:
cmd_scaffold(argparse.Namespace(template="interface-class", name="ZIF_TEST", path=td, config=None, package="$TMP"), None)
self.assertTrue(len(os.listdir(td)) > 0)
def test_scaffold_data_model(self):
from sapcli.commands.scaffold import cmd_scaffold
with tempfile.TemporaryDirectory() as td:
cmd_scaffold(argparse.Namespace(template="data-model", name="ZMODEL", path=td, config=None, package="$TMP"), None)
self.assertTrue(len(os.listdir(td)) > 0)
# ═══════════════════════════════════════════
# auth.py — 需 mock getpass + load_config
# ═══════════════════════════════════════════
class TestAuth(unittest.TestCase):
@patch("sapcli.auth.getpass")
@patch("sapcli.auth.set_password", return_value=True)
@patch("sapcli.config.load_config")
def test_cmd_auth_login(self, mock_load, mock_set, mock_getpass):
from sapcli.auth import cmd_auth_login
mock_cfg = MagicMock()
mock_cfg.host = "http://sap:8000"
mock_cfg.client = "100"
mock_cfg.user = "TESTUSER"
mock_load.return_value = (mock_cfg, None)
mock_getpass.getpass.return_value = "SECRET"
cmd_auth_login(argparse.Namespace(config=None))
mock_set.assert_called_once()
def test_cmd_auth_status_no_keyring(self):
from sapcli.auth import cmd_auth_status
with patch("sapcli.auth._KEYRING_AVAILABLE", False):
cmd_auth_status(argparse.Namespace()) # 不抛异常
if __name__ == "__main__":
unittest.main(verbosity=2)