Files
Aislo/common_util/common_util_node_bundle.py
T
eomsangdon 1f3b30e698 @
feat(B06): 구조물 면적·유토곡선 서버 계산 자리 마련 + 상단측 저장 누락 수정

계산 자리 일원화(CLAUDE.md 5장) — 브라우저에서만 돌던 두 계산을 서버가 같은 TS 로
한 번 더 돌려 정본에 얹음. 파이썬 포팅 금지(기하가 두 벌이 되면 그림과 수량이 갈림).

- B06_Section_Server_Calc_Node.ts 신설 — 구조물 폐회로 면적 계산 후 그 위에서
  유토곡선을 쌓음(화면과 같은 순서). balloon 위치는 서버가 만들지 않음.
- B06_Section_Structure_Layouts.ts 신설 — 정본만 읽는 제어기 흉내를 B07 도면에서
  떼어 공용화. B07·서버가 같은 한 벌을 씀.
- common_util_node_bundle.py 신설 — 번들 빌드·실행 배관 공용화(코리도도 이걸 씀).
- 전처리 체인(초기값 스냅샷 앞)·[저장]·[확정]에서 서버 재계산 호출.
- 상단측(측구 방향) 변경이 B05 [임시저장]에만 실리던 것을 B06 [저장]·[확정]에도
  실음 — flushUphillOverrides.
- 죽은 세션 등록 항목 pipes 제거(읽는 곳도 쓰는 곳도 없었음).

검증: tsc --noEmit 통과, pytest 386 passed, tmp/tests/test_b06_server_calc_node.mjs 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-09-06 14:20:54 +09:00

104 lines
3.9 KiB
Python

"""브라우저용 TS 를 **서버에서 그대로 실행**하기 위한 공통 배관(2026-09-06 분리).
CLAUDE.md 5장 「계산 자리」 — 같은 계산을 파이썬으로 다시 짜지 않고, 화면이 쓰는 TS 를
Node 진입점으로 감싸 서버가 부른다. 코리도(`B05_Profile_Corridor_Prebuild`)가 첫 사례고
구조물 면적(`B06_Section_Structure_Areas_Prebuild`)이 뒤따르면서, 번들 빌드·실행 배관이
두 벌이 되어 여기로 모았다. **계산은 이 파일에 없다** — 실행 껍데기만 있다.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[1]
# 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더.
SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util")
# 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다.
BUILD_TIMEOUT_S = 300
RUN_TIMEOUT_S = 600
def node_env() -> dict[str, str]:
"""config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약)."""
env = os.environ.copy()
node_modules = ROOT / "config" / "node_modules"
env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}"
env["NODE_PATH"] = str(node_modules)
return env
def bundle_stale(bundle: Path) -> bool:
"""번들이 없거나 TS 원본보다 오래됐으면 참.
번들이 낡으면 서버와 화면이 **다른 값**을 만든다 — 이 판정이 그것을 막는 유일한
장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다.
"""
if not bundle.is_file():
return True
built_at = bundle.stat().st_mtime
for directory in SOURCE_DIRS:
for path in (ROOT / directory).rglob("*.ts"):
if path.stat().st_mtime > built_at:
return True
return False
def build_bundle(npm_script: str) -> bool:
result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음
f"npm run {npm_script}",
shell=True,
cwd=str(ROOT),
env=node_env(),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=BUILD_TIMEOUT_S,
)
if result.returncode != 0:
logger.error("Node 번들 빌드 실패(%s):\n%s", npm_script, result.stderr)
return False
return True
def run_node(bundle: Path, input_path: Path, output_path: Path) -> int:
result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐
["node", str(bundle), str(input_path), str(output_path)],
cwd=str(ROOT),
env=node_env(),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=RUN_TIMEOUT_S,
)
if result.returncode != 0:
logger.warning(
"Node 실행 실패(%s, 끝 코드 %s): %s", bundle.name, result.returncode, result.stderr
)
return result.returncode
def run_bundle_json(bundle: Path, npm_script: str, payload: dict[str, Any]) -> Any | None:
"""입력을 JSON 으로 넘겨 실행하고 결과 JSON 을 돌려준다. 실패는 None.
결과가 작을 때만 쓸 것 — 코리도처럼 큰 산출물은 파일로 받아 그대로 옮겨야 한다.
"""
if bundle_stale(bundle) and not build_bundle(npm_script):
return None
with tempfile.TemporaryDirectory(prefix="node_bundle_") as workdir:
source = Path(workdir) / "input.json"
result = Path(workdir) / "output.json"
source.write_text(json.dumps(payload, default=float), encoding="utf-8")
if run_node(bundle, source, result) != 0:
return None
return json.loads(result.read_text(encoding="utf-8"))