Files
byeongwook.choiandClaude Fable 5.1 fdae34161e from_sap: 펑션 그룹(펑션 모듈 소스)도 받는다 — SAPL<그룹> + INCLUDE 문 추적
- GET_PROGRAM_LIST 는 TADIR 의 PROG 만 주므로 GET_OBJECT_LIST_BY_PACKAGE(FUGR) 로 그룹을 받아
  SAPL<그룹> 을 프로그램처럼 저장 (설명에 펑션 이름·텍스트 목록)
- 펑션 그룹은 서버 INCLUDE_LIST 가 비어 온다(실측) → 소스의 INCLUDE 문을 깊이 4 까지 따라가며
  고객 인클루드(Z/Y/네임스페이스, L<그룹>…)만 받는다. 표준 생성기 인클루드(LSVIM…)는 제외
- 실측 ZFI01: 펑션 그룹 14, SAPLZFG_FI01 인클루드 47 → 파서 FUNCTION 38 unit, 미인식 0
- --no-fugr 로 끌 수 있다

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:29:46 +09:00

231 lines
12 KiB
Python
Raw Permalink 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.
"""Stage 0 (ingest/from_sap.py) — SAP 응답을 가짜로 넣어 저장 형식과 normalize 연결을 확인한다.
실제 SAP 은 부르지 않는다. 응답 모양은 ../abap-api-tester/tools/spec/samples 의 실측 JSON 과 같다.
"""
from __future__ import annotations
import json
import pytest
from ingest import from_sap
from ingest.normalize import classify, parse_collected_file, run as normalize_run
PROGRAM_LIST = {
"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": "2"},
"RESULT": [
{"OBJ_NAME": "ZFIR0010", "TEXT": "거래처 I/F 이력", "SUBC": "1", "UDAT": "2024-01-02", "UNAM": "U1"},
{"OBJ_NAME": "SAPMZSFT0", "TEXT": "달력 유지보수", "SUBC": "M", "UDAT": "2020-10-16", "UNAM": "U2"},
],
}
SOURCES = {
"ZFIR0010": {
"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": "0"},
"PROGRAM": "ZFIR0010",
"SOURCE_CODE": "REPORT zfir0010.\nINCLUDE zfir0010_top.\nSTART-OF-SELECTION.\n PERFORM main.\n",
"INCLUDE_LIST": [
{"INCL_NAME": "ZFIR0010_TOP", "SOURCE": "DATA gv_bukrs TYPE bukrs.\n"},
{"INCL_NAME": "DB__SSEL", "SOURCE": ""}, # 빈 소스는 버린다
{"INCL_NAME": "ZFIR0010_TOP", "SOURCE": "DATA dup.\n"}, # 중복은 버린다
],
"SCREEN_LIST": "",
"TCODE_LIST": [{"TCODE": "ZFIR0010", "TTEXT": "거래처 I/F 이력조회", "PGMNA": "ZFIR0010"}],
},
"SAPMZSFT0": {
"RETURN": {"TYPE": "E", "MESSAGE": "프로그램 소스를 읽을 수 없습니다", "TOTAL_ROWS": "0"},
"PROGRAM": "SAPMZSFT0", "SOURCE_CODE": "", "INCLUDE_LIST": "", "SCREEN_LIST": "", "TCODE_LIST": "",
},
}
FUGR_LIST = {"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": 1},
"RESULT": [{"OBJECT": "FUGR", "OBJ_NAME": "ZFG_FI01", "AUTHOR": "U1"}]}
FUNC_LIST = {"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": 1},
"RESULT": [{"FUNCNAME": "Z_FI_CHECK", "STEXT": "사업자번호 체크"}]}
# 펑션 그룹: 서버 INCLUDE_LIST 는 비어 오고, 소스의 INCLUDE 문을 따라가야 한다 (실측)
SOURCES["SAPLZFG_FI01"] = {
"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": 0}, "PROGRAM": "SAPLZFG_FI01",
"SOURCE_CODE": "* regenerated\nINCLUDE LZFG_FI01TOP.\nINCLUDE LZFG_FI01UXX.\nINCLUDE LSVIMFXX.\n* INCLUDE LZFG_FI01ZZZ.\n",
"INCLUDE_LIST": [], "SCREEN_LIST": [], "TCODE_LIST": [],
}
SOURCES["LZFG_FI01TOP"] = {"RETURN": {"TYPE": "S", "MESSAGE": ""}, "PROGRAM": "LZFG_FI01TOP",
"SOURCE_CODE": "FUNCTION-POOL zfg_fi01.\nINCLUDE lsvimdat.\n", "INCLUDE_LIST": []}
SOURCES["LZFG_FI01UXX"] = {"RETURN": {"TYPE": "S", "MESSAGE": ""}, "PROGRAM": "LZFG_FI01UXX",
"SOURCE_CODE": "INCLUDE LZFG_FI01U01.\n", "INCLUDE_LIST": []}
SOURCES["LZFG_FI01U01"] = {"RETURN": {"TYPE": "S", "MESSAGE": ""}, "PROGRAM": "LZFG_FI01U01",
"SOURCE_CODE": "FUNCTION z_fi_check.\n SELECT SINGLE * FROM t001.\nENDFUNCTION.\n",
"INCLUDE_LIST": []}
CALLED: list[str] = []
def fake_sap_call(method: str, params: dict, timeout: int = 180) -> dict:
CALLED.append(f"{method}:{params.get('IV_PROGRAM') or params.get('IV_OBJ_TYPE') or params.get('IV_FUNC_GROUP') or ''}")
if method == "GET_PROGRAM_LIST":
assert params["IV_PACKAGE"] == "ZFI01"
data = PROGRAM_LIST
elif method == "GET_OBJECT_LIST_BY_PACKAGE":
assert params["IV_OBJ_TYPE"] == "FUGR"
data = FUGR_LIST
elif method == "GET_FUNCTION_LIST":
data = FUNC_LIST
elif method == "GET_PROGRAM_SOURCE":
name = params["IV_PROGRAM"]
if name not in SOURCES: # 표준 인클루드(LSVIMFXX 등)는 요청 자체가 오면 안 된다
raise AssertionError(f"요청하면 안 되는 소스: {name}")
data = SOURCES[name]
else:
raise AssertionError(method)
ret = data["RETURN"]
return {"method": method, "parsed": data, "error": None,
"return": {"type": ret["TYPE"], "message": ret["MESSAGE"]}}
@pytest.fixture
def sap(monkeypatch):
import sap.sap_client as client
monkeypatch.setattr(client, "sap_call", fake_sap_call)
monkeypatch.setattr(from_sap.time, "sleep", lambda s: None)
def test_run_writes_raw_files_and_records_errors(tmp_path, sap):
stats = from_sap.run(["zfi01"], tmp_path, fugr=False)
assert stats == {"packages": 1, "listed": 2, "function_groups": 0, "fetched": 1, "skipped": 0,
"errors": 1, "includes": 2}
# 패키지 목록: normalize 가 package_list 로 분류하는 모양
pkg = parse_collected_file((tmp_path / "ZFI01.txt").read_text(encoding="utf-8"))
assert classify(pkg) == "package_list"
assert pkg[0] == {"DEVCLASS": "ZFI01", "OBJ_NAME": "ZFIR0010", "TEXT": "거래처 I/F 이력",
"CREATED_ON": "", "CHANGED_ON": "2024-01-02"}
# 프로그램 소스: 메인 + 인클루드(빈 것·중복 제외), 줄바꿈은 JSON 이스케이프로 보존
prog = parse_collected_file((tmp_path / "ZFIR0010.txt").read_text(encoding="utf-8"))
assert classify(prog) == "program_source"
assert prog["DESCRIPTION"] == "거래처 I/F 이력"
assert [i["INCLUDE"] for i in prog["INCLUDE_PROGRAM"]] == ["ZFIR0010", "ZFIR0010_TOP"]
assert prog["INCLUDE_PROGRAM"][0]["SOURCE_CODE"].splitlines()[0] == "REPORT zfir0010."
assert prog["TEXT_SYMBOL"] == []
assert prog["TCODE_LIST"][0]["TCODE"] == "ZFIR0010"
# 실패한 프로그램은 파일 없이 _errors.log 에
assert not (tmp_path / "SAPMZSFT0.txt").exists()
assert "SAPMZSFT0\tSOURCE\tSAP 오류" in (tmp_path / "_errors.log").read_text(encoding="utf-8")
def test_rerun_skips_existing_unless_force(tmp_path, sap):
from_sap.run(["ZFI01"], tmp_path, fugr=False)
again = from_sap.run(["ZFI01"], tmp_path, fugr=False)
assert again["skipped"] == 1 and again["fetched"] == 0
forced = from_sap.run(["ZFI01"], tmp_path, force=True, fugr=False)
assert forced["fetched"] == 1
def test_normalize_consumes_output_and_builds_tcodes(tmp_path, sap):
raw, out = tmp_path / "raw", tmp_path / "normalized"
from_sap.run(["ZFI01"], raw, fugr=False)
stats = normalize_run(raw, out)
assert stats["programs"] == 1 and stats["package_rows"] == 2 and stats["errors"] == 0
assert (out / "ZFIR0010" / "ZFIR0010.abap").read_text(encoding="utf-8").startswith("REPORT zfir0010.\n")
assert (out / "ZFIR0010" / "ZFIR0010_TOP.abap").exists()
meta = json.loads((out / "ZFIR0010" / "ZFIR0010.meta.json").read_text(encoding="utf-8"))
assert meta["description"] == "거래처 I/F 이력" and len(meta["includes"]) == 2
tcodes = [json.loads(l) for l in (out / "tcodes.jsonl").read_text(encoding="utf-8").splitlines()]
assert tcodes == [{"tcode": "ZFIR0010", "program": "ZFIR0010", "text_ko": "거래처 I/F 이력조회"}]
def test_dry_run_writes_nothing(tmp_path, sap):
stats = from_sap.run(["ZFI01"], tmp_path, dry_run=True, fugr=False)
assert stats["listed"] == 2 and stats["fetched"] == 0
assert list(tmp_path.iterdir()) == []
def test_sap_client_parses_json_and_xml_responses():
from sap.sap_client import parse_response
js = parse_response('{"RETURN":{"TYPE":"W","MESSAGE":"잘림","TOTAL_ROWS":177},"RESULT":[{"OBJ_NAME":"ZFI0000"}],"INCLUDE_LIST":[]}'.encode("utf-8"))
assert js["error"] is None and js["return"] == {"type": "W", "message": "잘림"}
assert js["parsed"]["RESULT"][0]["OBJ_NAME"] == "ZFI0000" and js["parsed"]["INCLUDE_LIST"] == []
xml = parse_response(b'\xef\xbb\xbf<?xml version="1.0" encoding="utf-16"?><asx:abap xmlns:asx="x"><asx:values><DATA>'
b'<RETURN><TYPE>S</TYPE><MESSAGE/></RETURN><RESULT/></DATA></asx:values></asx:abap>')
assert xml["error"] is None and xml["return"]["type"] == "S" and xml["parsed"]["RESULT"] == []
bad = parse_response(b"<html>login</html>")
assert bad["return"]["type"] == "X"
def test_sap_call_sends_fields_without_wrapper(monkeypatch):
import sap.sap_client as client
captured = {}
class _Resp:
status = 200
def __enter__(self): return self
def __exit__(self, *a): return False
def read(self): return b'{"RETURN":{"TYPE":"S","MESSAGE":"","TOTAL_ROWS":0},"RESULT":[]}'
def fake_urlopen(req, context=None, timeout=None):
captured["body"] = json.loads(req.data.decode("utf-8"))
captured["url"] = req.full_url
return _Resp()
monkeypatch.setattr(client.urllib.request, "urlopen", fake_urlopen)
res = client.sap_call("GET_PROGRAM_LIST", {"IV_PACKAGE": "ZFI01", "IV_PATTERN": "", "IV_MAX_ROWS": 5})
assert captured["body"] == {"IV_PACKAGE": "ZFI01", "IV_MAX_ROWS": 5} # 래퍼 없음, 빈 값 제외, 숫자 유지
assert captured["url"].endswith("/GET_PROGRAM_LIST")
assert res["return"]["type"] == "S" and res["parsed"]["RESULT"] == []
def test_normalize_survives_non_string_fields_and_bad_file(tmp_path):
"""JSON 규약 응답엔 숫자·null 이 섞인다. 파일 하나가 깨져도 나머지는 계속 처리해야 한다."""
from ingest.normalize import clean_text_field
assert clean_text_field(None) == "" and clean_text_field(20240102) == "20240102" and clean_text_field(0) == "0"
raw, out = tmp_path / "raw", tmp_path / "normalized"
raw.mkdir()
(raw / "ZPKG.txt").write_text(json.dumps([
{"DEVCLASS": "ZPKG", "OBJ_NAME": "ZA", "TEXT": None, "CREATED_ON": "", "CHANGED_ON": 20240102},
{"DEVCLASS": "ZPKG", "OBJ_NAME": "ZB", "TEXT": "b", "CREATED_ON": None, "CHANGED_ON": None},
]), encoding="utf-8")
(raw / "ZBAD.txt").write_text('{"MAIN_PROGRAM": "ZBAD", "INCLUDE_PROGRAM": "not-a-list"}', encoding="utf-8")
(raw / "ZOK.txt").write_text(json.dumps({"MAIN_PROGRAM": "ZOK", "DESCRIPTION": "ok",
"INCLUDE_PROGRAM": [{"INCLUDE": "ZOK", "SOURCE_CODE": "REPORT zok.\n"}]}),
encoding="utf-8")
stats = normalize_run(raw, out)
assert stats["programs"] == 1 and stats["package_rows"] == 2 and stats["errors"] == 1
assert (out / "ZOK" / "ZOK.abap").exists()
rows = [json.loads(l) for l in (out / "packages.jsonl").read_text(encoding="utf-8").splitlines()]
assert rows[0]["changed_on"] == "20240102" and rows[0]["text"] == "" and rows[1]["changed_on"] == ""
assert "ZBAD.txt" in (out / "_errors.log").read_text(encoding="utf-8")
def test_function_groups_are_fetched_via_include_statements(tmp_path, sap):
"""펑션 그룹: FUGR 목록 → SAPL<그룹> 저장, 인클루드는 INCLUDE 문을 따라(표준 인클루드는 제외, 주석은 무시)."""
CALLED.clear()
stats = from_sap.run(["ZFI01"], tmp_path)
assert stats["function_groups"] == 1 and stats["listed"] == 3 and stats["fetched"] == 2
prog = parse_collected_file((tmp_path / "SAPLZFG_FI01.txt").read_text(encoding="utf-8"))
assert prog["MAIN_PROGRAM"] == "SAPLZFG_FI01"
assert prog["DESCRIPTION"] == "펑션 그룹 ZFG_FI01: Z_FI_CHECK (사업자번호 체크)"
assert [i["INCLUDE"] for i in prog["INCLUDE_PROGRAM"]] == [
"SAPLZFG_FI01", "LZFG_FI01TOP", "LZFG_FI01UXX", "LZFG_FI01U01"]
assert "ENDFUNCTION" in prog["INCLUDE_PROGRAM"][3]["SOURCE_CODE"]
assert not any(c.endswith(":LSVIMFXX") or c.endswith(":LSVIMDAT") or c.endswith(":LZFG_FI01ZZZ") for c in CALLED)
# 패키지 목록에도 SAPL 프로그램이 들어간다 (package 테이블 조인용)
pkg = parse_collected_file((tmp_path / "ZFI01.txt").read_text(encoding="utf-8"))
assert any(r["OBJ_NAME"] == "SAPLZFG_FI01" for r in pkg)
def test_is_customer_include():
from ingest.from_sap import is_customer_include
assert is_customer_include("LZFG_FI01U01", "SAPLZFG_FI01")
assert is_customer_include("ZFI_COMMON", "ZFI0000")
assert is_customer_include("/PWC/ZINC", "ZFI0000")
assert not is_customer_include("LSVIMFXX", "SAPLZFG_FI01")
assert not is_customer_include("LZOTHERU01", "SAPLZFG_FI01") # 다른 그룹의 L… 인클루드는 제외
assert not is_customer_include("RSDBRUNT", "ZFI0000")