- 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>
169 lines
6.6 KiB
Python
169 lines
6.6 KiB
Python
#!/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
|