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:
byeongwook.choi
2026-09-21 14:13:14 +09:00
co-authored by Claude Fable 5.1
parent 11ae3629b2
commit 921b8ce229
13 changed files with 1595 additions and 1 deletions
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""ZAA_ICF (ZCL_ZAA_AGENT_API) 호출 클라이언트. MCP 서버(mcp_server.py)와 웹 테스터(../abap-api-tester/server.py)가 공유한다.
- 표준 라이브러리만 사용 (Python 3.10+)
- 접속정보: .env 파일 또는 환경변수 SAP_URL / SAP_USER / SAP_PASS / SAP_VERIFY_SSL
호출 규약 (실측, README 참고):
POST {SAP_URL}/{METHOD}, Content-Type: application/json, 바디 {"INPUT": {"IV_...": ...}}
응답 asXML. XML 선언은 utf-16 이지만 실제 UTF-8 → BOM/선언 제거 후 파싱. 소스 안 제어문자(0x0C) 제거.
"""
import base64
import json
import os
import re
import ssl
import time
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
try: # abap-indexing: 패키지(sap.sap_client)로도, 스크립트(mcp_server.py 옆)로도 import 되게
from .catalog import LIST_FIELDS
except ImportError:
from catalog import LIST_FIELDS
ROOT = Path(__file__).resolve().parent
DEFAULT_URL = "https://pwcs4h.pwcc.co.kr:44310/sap/bc/ZAA_ICF"
def load_env():
# abap-indexing: sap/.env 다음에 저장소 루트 .env 도 본다 (LLM 키와 같은 파일에 SAP_* 를 둔다)
for p in (ROOT / ".env", ROOT.parent / ".env"):
if not p.exists():
continue
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
load_env()
CONFIG = {
"base_url": os.environ.get("SAP_URL", DEFAULT_URL).rstrip("/"),
"user": os.environ.get("SAP_USER", ""),
"password": os.environ.get("SAP_PASS", ""),
"verify_ssl": os.environ.get("SAP_VERIFY_SSL", "0") == "1",
}
def ssl_context():
ctx = ssl.create_default_context()
if not CONFIG["verify_ssl"]:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
# ------------------------------------------------------------------ XML -> JSON
def xml_to_data(el):
"""asXML 요소를 dict/list/str 로 변환.
행 반복은 list 로: 보통 <item> 이지만, 행 타입이 DDIC 구조인 테이블(RPY 의 파라미터 목록 등)은
<RSIMP>, <RSEXP>, <RSTBL> 처럼 구조 이름이 행 태그가 된다. 같은 태그가 반복되거나
부모가 알려진 테이블 필드(LIST_FIELDS)면 list 로 본다. (2026-09-04: 마지막 행만 남던 버그 수정)
"""
children = list(el)
if not children:
return el.text or ""
tags = {c.tag for c in children}
if len(tags) == 1 and (children[0].tag == "item" or len(children) > 1 or el.tag in LIST_FIELDS):
return [xml_to_data(c) for c in children]
out = {}
for c in children:
out[c.tag] = xml_to_data(c)
return out
def coerce_lists(obj):
"""빈 테이블 <RESULT/> 은 "" 로 오므로 알려진 테이블 필드는 [] 로 통일."""
if isinstance(obj, dict):
for k, v in list(obj.items()):
if k in LIST_FIELDS and v == "":
obj[k] = []
else:
obj[k] = coerce_lists(v)
elif isinstance(obj, list):
obj = [coerce_lists(x) for x in obj]
return obj
def normalize_xml(raw: bytes) -> str:
"""서버 응답 정규화: BOM 제거, 잘못된 utf-16 선언 제거, XML 1.0 비허용 제어문자 제거."""
text = raw.decode("utf-8", errors="replace").lstrip("")
return normalize_xml_text(text)
def normalize_xml_text(text: str) -> str:
"""이미 문자열로 디코딩된 응답에 같은 정규화를 적용 (Bruno 가 보내온 본문용)."""
text = text.lstrip("")
text = re.sub(r"^<\?xml[^>]*\?>", "", text)
return "".join(ch for ch in text if ord(ch) >= 0x20 or ch in "\t\n\r")
def parse_asxml(text: str) -> dict:
"""정규화된 asXML 텍스트 -> {parsed, return, error}. sap_call 과 /api/render 가 공유."""
out = {"parsed": None, "return": {"type": "?", "message": ""}, "error": None}
try:
root = ET.fromstring(text)
except ET.ParseError as e:
out["error"] = f"XML 파싱 실패: {e}"
out["return"] = {"type": "X", "message": out["error"]}
return out
if root.tag == "error": # ICF 핸들러 에러 봉투 (Method 없음 / ABAP 덤프)
err = xml_to_data(root)
out["parsed"] = {"error": err}
out["error"] = f"{err.get('code')}: {err.get('message')}"
out["return"] = {"type": "X", "message": out["error"]}
return out
data_el = root.find(".//DATA")
data = coerce_lists(xml_to_data(data_el)) if data_el is not None else {}
out["parsed"] = data
ret = data.get("RETURN") if isinstance(data, dict) else None
if isinstance(ret, dict):
out["return"] = {"type": ret.get("TYPE") or "-", "message": ret.get("MESSAGE") or ""}
return out
# ------------------------------------------------------------------ SAP 호출
def sap_call(method: str, params: dict, timeout: int = 180) -> dict:
url = f"{CONFIG['base_url']}/{method}"
body_obj = {"INPUT": {k: v for k, v in params.items() if v not in ("", None)}}
body = json.dumps(body_obj, ensure_ascii=False)
auth = "Basic " + base64.b64encode(f"{CONFIG['user']}:{CONFIG['password']}".encode()).decode()
req = urllib.request.Request(
url, data=body.encode("utf-8"), method="POST",
headers={"Content-Type": "application/json", "Authorization": auth},
)
curl = (f"curl -sk -u '{CONFIG['user']}:****' -H 'Content-Type: application/json' "
f"--data '{body}' '{url}'")
result = {
"method": method,
"request": {"url": url, "body": body_obj, "curl": curl},
"http_status": None, "ms": None, "raw": "", "parsed": None,
"return": {"type": "?", "message": ""}, "error": None,
}
t0 = time.time()
try:
with urllib.request.urlopen(req, context=ssl_context(), timeout=timeout) as r:
raw, status = r.read(), r.status
except urllib.error.HTTPError as e:
raw, status = e.read(), e.code
except Exception as e: # 네트워크/인증 등
result["ms"] = int((time.time() - t0) * 1000)
result["error"] = f"{type(e).__name__}: {e}"
result["return"] = {"type": "X", "message": result["error"]}
return result
result["ms"] = int((time.time() - t0) * 1000)
result["http_status"] = status
raw_text = raw.decode("utf-8", errors="replace")
result["raw"] = raw_text
result["raw_size"] = len(raw)
result.update(parse_asxml(normalize_xml(raw)))
return result