Stage 0: SAP(ZAA_ICF)에서 패키지 단위로 프로그램 소스 수집 (ingest/from_sap.py)
- sap/: abap-mcp 의 catalog/sap_client/mcp_server 복사. import 와 .env 탐색만 이 저장소에 맞춤 - ingest/from_sap.py: 패키지명 → GET_PROGRAM_LIST → GET_PROGRAM_SOURCE(+Include) → data/raw/*.txt (normalize 가 읽는 수집 JSON 형식 그대로). 받은 파일은 건너뛰고 --force 로 재수집 - normalize: 응답의 TCODE_LIST 를 tcodes.jsonl 로 (from_dir 와 같은 모양, 로더가 적재) - .env.example 에 SAP_URL/SAP_USER/SAP_PASS, tests/test_from_sap.py Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
11ae3629b2
commit
921b8ce229
@@ -0,0 +1,231 @@
|
||||
"""Stage 0 — SAP 에서 패키지 단위로 프로그램 소스를 받아 data/raw/ 에 저장한다.
|
||||
|
||||
abap-mcp 의 SAP 클라이언트(sap/sap_client.py, ZAA_ICF = ZCL_ZAA_AGENT_API)를 그대로 쓴다.
|
||||
저장 형식은 `ingest/normalize.py` 가 읽는 수집 JSON 과 같다 — 그래서 받은 뒤엔 평소대로
|
||||
`python -m ingest.normalize` 부터 돌리면 된다.
|
||||
|
||||
python -m ingest.from_sap ZFI01 # 패키지 하나
|
||||
python -m ingest.from_sap ZFI01 ZCO01 ZSD01 # 여러 개
|
||||
python -m ingest.from_sap ZFI01 --limit 3 # 시험: 프로그램 3본만
|
||||
python -m ingest.from_sap ZFI01 --force # 이미 받은 파일도 다시 받기
|
||||
python -m ingest.from_sap --pattern "ZFIR1*" # 패키지 대신 이름 패턴
|
||||
|
||||
접속 정보는 `.env` 의 SAP_URL / SAP_USER / SAP_PASS (sap/README.md). 고객사 SAP 에
|
||||
ZAA_ICF 서비스(ZCL_ZAA_AGENT_API)가 설치돼 있어야 한다.
|
||||
|
||||
만드는 파일 (기본 data/raw/):
|
||||
<PACKAGE>.txt 프로그램 목록 [{DEVCLASS, OBJ_NAME, TEXT, CREATED_ON, CHANGED_ON}]
|
||||
<PROGRAM>.txt 프로그램 소스 {MAIN_PROGRAM, DESCRIPTION, TEXT_SYMBOL: [],
|
||||
INCLUDE_PROGRAM: [{INCLUDE, SOURCE_CODE}], TCODE_LIST: [...]}
|
||||
_errors.log 실패한 프로그램 (계속 진행하고 여기 기록)
|
||||
|
||||
- SAP 목록 API 는 SUBC 를 생략하면 실행 프로그램(1)과 모듈 풀(M)만 준다. Include 는 각 프로그램의
|
||||
INCLUDE_LIST 로 따라오므로 따로 받지 않는다.
|
||||
- 텍스트 심볼(TEXT-nnn)은 이 API 에 없다 → TEXT_SYMBOL 은 빈 배열 (from_dir 와 같은 한계).
|
||||
- 이미 받은 프로그램(.txt 존재)은 건너뛴다. 다시 받으려면 --force.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _call(method: str, params: dict, retries: int = 3, timeout: int = 180) -> dict:
|
||||
"""sap_call + 네트워크 오류 재시도. 결과는 sap_client.sap_call 과 같은 dict."""
|
||||
from sap.sap_client import sap_call
|
||||
|
||||
last: dict = {}
|
||||
for attempt in range(1, retries + 1):
|
||||
last = sap_call(method, params, timeout=timeout)
|
||||
# error 는 네트워크/인증/파싱 실패. RETURN.TYPE=E 는 SAP 이 정상 응답한 것이라 재시도 안 함
|
||||
if not last.get("error"):
|
||||
return last
|
||||
_log(f" ! {method} {params} 실패({attempt}/{retries}): {last['error']}")
|
||||
if attempt < retries:
|
||||
time.sleep(2 * attempt)
|
||||
return last
|
||||
|
||||
|
||||
def list_programs(package: str | None = None, pattern: str | None = None,
|
||||
subc: str | None = None) -> list[dict]:
|
||||
"""GET_PROGRAM_LIST → [{OBJ_NAME, TEXT, SUBC, UDAT, UNAM, ...}]"""
|
||||
params = {"IV_PACKAGE": package or "", "IV_PATTERN": pattern or "", "IV_SUBC": subc or ""}
|
||||
res = _call("GET_PROGRAM_LIST", params)
|
||||
if res.get("error"):
|
||||
raise RuntimeError(res["error"])
|
||||
ret = res["return"]
|
||||
if ret["type"] == "E":
|
||||
raise RuntimeError(f"SAP 오류: {ret['message']}")
|
||||
rows = (res.get("parsed") or {}).get("RESULT") or []
|
||||
if ret["type"] == "W":
|
||||
_log(f" ! 목록이 잘렸습니다: {ret['message']}")
|
||||
return [r for r in rows if isinstance(r, dict) and r.get("OBJ_NAME")]
|
||||
|
||||
|
||||
def fetch_program(name: str, description: str = "") -> dict:
|
||||
"""GET_PROGRAM_SOURCE(+Include) → 수집 JSON 모양의 payload. 실패하면 RuntimeError."""
|
||||
res = _call("GET_PROGRAM_SOURCE", {"IV_PROGRAM": name, "IV_WITH_INCLUDE": "X"})
|
||||
if res.get("error"):
|
||||
raise RuntimeError(res["error"])
|
||||
ret = res["return"]
|
||||
if ret["type"] == "E":
|
||||
raise RuntimeError(f"SAP 오류: {ret['message']}")
|
||||
data = res.get("parsed") or {}
|
||||
main_code = data.get("SOURCE_CODE") or ""
|
||||
if not main_code.strip():
|
||||
raise RuntimeError("소스가 비어 있음")
|
||||
|
||||
program = (data.get("PROGRAM") or name).strip().upper()
|
||||
includes = [{"INCLUDE": program, "SOURCE_CODE": main_code}]
|
||||
seen = {program}
|
||||
for inc in data.get("INCLUDE_LIST") or []:
|
||||
if not isinstance(inc, dict):
|
||||
continue
|
||||
inc_name = (inc.get("INCL_NAME") or "").strip().upper()
|
||||
code = inc.get("SOURCE") or ""
|
||||
# 이름 없음 / 빈 소스(권한 없음·삭제됨) / 중복은 뺀다
|
||||
if not inc_name or inc_name in seen or not code.strip():
|
||||
continue
|
||||
seen.add(inc_name)
|
||||
includes.append({"INCLUDE": inc_name, "SOURCE_CODE": code})
|
||||
|
||||
tcodes = [
|
||||
{"TCODE": (t.get("TCODE") or "").strip().upper(), "TTEXT": t.get("TTEXT") or ""}
|
||||
for t in data.get("TCODE_LIST") or [] if isinstance(t, dict) and t.get("TCODE")
|
||||
]
|
||||
return {
|
||||
"MAIN_PROGRAM": program,
|
||||
"DESCRIPTION": description or "",
|
||||
"TEXT_SYMBOL": [],
|
||||
"INCLUDE_PROGRAM": includes,
|
||||
"TCODE_LIST": tcodes,
|
||||
}
|
||||
|
||||
|
||||
def package_rows(package: str, rows: list[dict]) -> list[dict]:
|
||||
"""프로그램 목록 → normalize 가 packages.jsonl 로 만드는 package_list 모양."""
|
||||
return [
|
||||
{
|
||||
"DEVCLASS": package.upper(),
|
||||
"OBJ_NAME": r["OBJ_NAME"].upper(),
|
||||
"TEXT": r.get("TEXT") or "",
|
||||
"CREATED_ON": "",
|
||||
"CHANGED_ON": r.get("UDAT") or "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _write_json(path: Path, obj) -> None:
|
||||
# 한 줄 JSON. normalize 는 "줄바꿈+패딩 → 공백" 치환을 먼저 하므로 들여쓰기(indent)를 쓰면 안 된다.
|
||||
path.write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def run(packages: list[str], out_dir: Path, pattern: str | None = None, subc: str | None = None,
|
||||
limit: int | None = None, force: bool = False, dry_run: bool = False) -> dict:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
stats = {"packages": 0, "listed": 0, "fetched": 0, "skipped": 0, "errors": 0, "includes": 0}
|
||||
errors: list[str] = []
|
||||
|
||||
targets: list[tuple[str, str]] = [] # (program, description)
|
||||
if packages:
|
||||
for pkg in packages:
|
||||
pkg = pkg.strip().upper()
|
||||
if not pkg:
|
||||
continue
|
||||
_log(f"[{pkg}] 프로그램 목록 조회")
|
||||
try:
|
||||
rows = list_programs(package=pkg, pattern=pattern, subc=subc)
|
||||
except Exception as e: # noqa: BLE001
|
||||
stats["errors"] += 1
|
||||
errors.append(f"{pkg}\tLIST\t{e}")
|
||||
_log(f" ! 목록 실패: {e}")
|
||||
continue
|
||||
stats["packages"] += 1
|
||||
_log(f" 프로그램 {len(rows)}건")
|
||||
if not dry_run:
|
||||
_write_json(out_dir / f"{pkg}.txt", package_rows(pkg, rows))
|
||||
targets += [(r["OBJ_NAME"].upper(), r.get("TEXT") or "") for r in rows]
|
||||
else:
|
||||
_log(f"[패턴 {pattern}] 프로그램 목록 조회")
|
||||
rows = list_programs(pattern=pattern, subc=subc)
|
||||
_log(f" 프로그램 {len(rows)}건")
|
||||
targets += [(r["OBJ_NAME"].upper(), r.get("TEXT") or "") for r in rows]
|
||||
|
||||
# 같은 프로그램이 두 패키지 목록에 있을 일은 없지만 안전하게 중복 제거
|
||||
uniq: dict[str, str] = {}
|
||||
for name, desc in targets:
|
||||
uniq.setdefault(name, desc)
|
||||
targets = list(uniq.items())
|
||||
if limit:
|
||||
targets = targets[:limit]
|
||||
stats["listed"] = len(targets)
|
||||
|
||||
for i, (name, desc) in enumerate(targets, 1):
|
||||
path = out_dir / f"{name}.txt"
|
||||
if path.exists() and not force:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
if dry_run:
|
||||
_log(f" ({i}/{len(targets)}) {name} {desc}")
|
||||
continue
|
||||
try:
|
||||
payload = fetch_program(name, desc)
|
||||
except Exception as e: # noqa: BLE001 — 실패 기록 후 계속
|
||||
stats["errors"] += 1
|
||||
errors.append(f"{name}\tSOURCE\t{e}")
|
||||
_log(f" ({i}/{len(targets)}) {name} 실패: {e}")
|
||||
continue
|
||||
_write_json(path, payload)
|
||||
stats["fetched"] += 1
|
||||
stats["includes"] += len(payload["INCLUDE_PROGRAM"])
|
||||
_log(f" ({i}/{len(targets)}) {name} include {len(payload['INCLUDE_PROGRAM'])} {desc}")
|
||||
|
||||
if errors and not dry_run:
|
||||
with (out_dir / "_errors.log").open("a", encoding="utf-8") as f:
|
||||
f.write("\n".join(errors) + "\n")
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Stage 0 — SAP(ZAA_ICF) 에서 패키지의 프로그램 소스를 data/raw 로 받는다",
|
||||
epilog="받은 뒤: python -m ingest.normalize && python -m parser.run && python -m index.loader",
|
||||
)
|
||||
ap.add_argument("packages", nargs="*", help="패키지명 (예: ZFI01). 여러 개 가능")
|
||||
ap.add_argument("--pattern", default=None, help="프로그램명 패턴 (예: ZFIR1*). 패키지와 같이 쓰면 그 안에서 필터")
|
||||
ap.add_argument("--subc", default=None, help="프로그램 유형. 생략=1(실행)+M(모듈풀). 1/M/I/F/K")
|
||||
ap.add_argument("--out", default=None, help="저장 디렉토리 (기본 data/raw)")
|
||||
ap.add_argument("--limit", type=int, default=None, help="받을 프로그램 수 상한 (시험용)")
|
||||
ap.add_argument("--force", action="store_true", help="이미 받은 파일도 다시 받는다")
|
||||
ap.add_argument("--dry-run", action="store_true", help="목록만 보고 저장하지 않는다")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.packages and not args.pattern:
|
||||
ap.error("패키지명 또는 --pattern 중 하나는 있어야 합니다")
|
||||
|
||||
from config.settings import settings
|
||||
from sap.sap_client import CONFIG
|
||||
|
||||
if not CONFIG["user"] or not CONFIG["password"]:
|
||||
print("SAP_USER / SAP_PASS 가 비어 있습니다 — .env 에 채우세요 (sap/README.md)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_log(f"SAP: {CONFIG['base_url']} 사용자 {CONFIG['user']}")
|
||||
|
||||
out_dir = Path(args.out) if args.out else settings.data_raw
|
||||
stats = run(args.packages, out_dir, pattern=args.pattern, subc=args.subc,
|
||||
limit=args.limit, force=args.force, dry_run=args.dry_run)
|
||||
print(json.dumps(stats, ensure_ascii=False))
|
||||
if stats["errors"]:
|
||||
print(f"실패 {stats['errors']}건 → {out_dir / '_errors.log'}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user