Files
ABAP-Indexing/sap/sap_client.py
T
byeongwook.choiandClaude Fable 5.1 921b8ce229 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>
2026-09-21 14:13:14 +09:00

169 lines
6.6 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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