fix(B08): 거리이정표 자동 그림 캡처 = 자식 파이썬 프로세스(249-1)
서버(uvicorn · Windows Selector 루프) 스레드 안 Playwright 드라이버 기동 실패 해소 · 입력 JSON stdin · 결과 JSON 한 줄 · 25초 넘김 · 잠금 그대로 · Selector 정책 시험 추가 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -8,12 +8,17 @@
|
||||
page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · vite 안 떠도 됨).
|
||||
경로 좌표는 그림 만든 뒤 버림(저장 = 그림 · km) · 동시 요청 = 하나씩(잠금) · 실패 = 까닭 한 줄 ·
|
||||
키 값은 돌려주지도 적지도 않음.
|
||||
캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 ·
|
||||
키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가
|
||||
드라이버 하위 프로세스를 못 만듦(249-1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
@@ -26,6 +31,9 @@ DEFAULT_ORIGIN = "http://localhost:5173"
|
||||
PAGE = "/__route_capture"
|
||||
WIDTH, HEIGHT = 1200, 800
|
||||
TIMEOUT_S = 20
|
||||
CHILD_TIMEOUT_S = 25 # 자식 프로세스 전체(파이썬 · 드라이버 기동 포함)
|
||||
CHILD = __file__ # 자식으로 돌릴 스크립트
|
||||
NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
|
||||
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
|
||||
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
|
||||
TAIL = " — 그림 올리기를 쓰세요."
|
||||
@@ -90,7 +98,7 @@ def shoot(html: str, origin: str) -> bytes:
|
||||
except PwError:
|
||||
continue
|
||||
if browser is None:
|
||||
raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움")
|
||||
raise CaptureError(NO_BROWSER)
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT})
|
||||
page.route(
|
||||
@@ -114,7 +122,41 @@ def shoot(html: str, origin: str) -> bytes:
|
||||
except PwTimeout as error:
|
||||
raise CaptureError(f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남") from error
|
||||
except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음
|
||||
raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") from error
|
||||
raise CaptureError(NO_BROWSER) from error
|
||||
|
||||
|
||||
def capture(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""자식 프로세스 몸통 — 그림 찍어 `<out>/<route_id>.png` 저장 → {saved, reason}."""
|
||||
key = os.environ.get(KAKAO_JS, "").strip()
|
||||
try:
|
||||
png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"])
|
||||
except CaptureError as error:
|
||||
return {"saved": False, "reason": str(error)}
|
||||
out_dir = Path(job["out"])
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
new = out_dir / f".{job['route_id']}.png.new"
|
||||
new.write_bytes(png)
|
||||
new.replace(out_dir / f"{job['route_id']}.png")
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
|
||||
def spawn(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""자식 파이썬 프로세스로 capture — 서버 이벤트 루프 정책과 무관(기본 Proactor)."""
|
||||
try:
|
||||
done = subprocess.run(
|
||||
[sys.executable, CHILD],
|
||||
input=json.dumps(job).encode(),
|
||||
capture_output=True,
|
||||
timeout=CHILD_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"saved": False, "reason": f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남"}
|
||||
except OSError:
|
||||
return {"saved": False, "reason": NO_BROWSER}
|
||||
try:
|
||||
return json.loads(done.stdout.decode().strip().splitlines()[-1])
|
||||
except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음
|
||||
return {"saved": False, "reason": NO_BROWSER}
|
||||
|
||||
|
||||
def make_route_image(
|
||||
@@ -130,13 +172,13 @@ def make_route_image(
|
||||
if not any(lines) and not marks:
|
||||
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"}
|
||||
origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/")
|
||||
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks}
|
||||
with _lock:
|
||||
try:
|
||||
png = shoot(page_html(key, lines, marks), origin)
|
||||
except CaptureError as error:
|
||||
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {error}{TAIL}"}
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
new = out_dir / f".{route_id}.png.new"
|
||||
new.write_bytes(png)
|
||||
new.replace(out_dir / f"{route_id}.png")
|
||||
got = spawn({**job, "origin": origin})
|
||||
if not got.get("saved"):
|
||||
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"}
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(capture(json.loads(sys.stdin.buffer.read()))))
|
||||
|
||||
@@ -115,6 +115,9 @@ def fake_playwright(box):
|
||||
|
||||
def _setup(tmp_path, monkeypatch, box, origin=None):
|
||||
monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_playwright(box))
|
||||
monkeypatch.setattr(
|
||||
cap, "spawn", cap.capture
|
||||
) # 가짜 브라우저 = 같은 프로세스(249-1 자식은 별도 시험)
|
||||
monkeypatch.setenv(cap.KAKAO_JS, KEY)
|
||||
if origin is None:
|
||||
monkeypatch.delenv(cap.ORIGIN_KEY, raising=False)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""249-1 B08 거리이정표 자동 그림 = 자식 프로세스 캡처(사용자 148번).
|
||||
|
||||
서버(uvicorn · Windows) = WindowsSelectorEventLoopPolicy → 스레드 안 sync_playwright 가
|
||||
드라이버 하위 프로세스를 못 만듦. 같은 정책 · 스레드에서:
|
||||
① 옛 길(같은 프로세스 shoot) = 「브라우저 못 띄움」 재현
|
||||
② make_route_image(자식 파이썬 프로세스) = 실제 Edge 로 PNG 생김 · 키 값 응답 0
|
||||
③ 자식 시간 넘김 · 자식 죽음 = 까닭 한 줄
|
||||
카카오 SDK 대신 단색 페이지(이 파일을 자식 스크립트로 · page_html 만 바꿈) — 네트워크 없음.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402
|
||||
|
||||
HTML = (
|
||||
'<!doctype html><body style="margin:0"><div id="m" style="width:1200px;height:800px;'
|
||||
'background:#E60046"></div><script>window.__done = true</script></body>'
|
||||
)
|
||||
KEY = "fake-js-key-249"
|
||||
LINES = [[[35.6866, 127.9095], [35.66, 127.85]]]
|
||||
MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}]
|
||||
|
||||
if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로
|
||||
cap.page_html = lambda key, lines, marks: HTML
|
||||
print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read()))))
|
||||
sys.exit(0)
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows Selector 루프 재현")
|
||||
|
||||
|
||||
def in_server_thread(fn):
|
||||
"""uvicorn 처럼 Selector 정책을 건 채 스레드(asyncio.to_thread 자리)에서 fn()."""
|
||||
got = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
got["value"] = fn()
|
||||
except BaseException as error: # noqa: BLE001 — 시험에서 그대로 봄
|
||||
got["error"] = error
|
||||
|
||||
before = asyncio.get_event_loop_policy()
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
try:
|
||||
job = threading.Thread(target=run)
|
||||
job.start()
|
||||
job.join(60)
|
||||
finally:
|
||||
asyncio.set_event_loop_policy(before)
|
||||
return got
|
||||
|
||||
|
||||
def test_옛_길_같은_프로세스는_못_띄움():
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
got = in_server_thread(lambda: cap.shoot(HTML, cap.DEFAULT_ORIGIN))
|
||||
assert isinstance(got.get("error"), cap.CaptureError)
|
||||
assert str(got["error"]) == cap.NO_BROWSER
|
||||
|
||||
|
||||
def test_자식_프로세스로_PNG_생김(tmp_path, monkeypatch):
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
monkeypatch.setattr(cap, "CHILD", __file__)
|
||||
monkeypatch.setenv(cap.KAKAO_JS, KEY)
|
||||
monkeypatch.delenv(cap.ORIGIN_KEY, raising=False)
|
||||
out = tmp_path / "auto"
|
||||
got = in_server_thread(lambda: cap.make_route_image(out, "r1", LINES, MARKS))
|
||||
assert got.get("value") == {"saved": True, "reason": None}, got
|
||||
png = (out / "r1.png").read_bytes()
|
||||
assert png[:8] == b"\x89PNG\r\n\x1a\n" and len(png) > 1000
|
||||
assert sorted(p.name for p in out.iterdir()) == ["r1.png"] # .new 남지 않음
|
||||
|
||||
|
||||
def test_자식_시간_넘김_죽음_까닭(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(cap.KAKAO_JS, KEY)
|
||||
tail = " — 그림 올리기를 쓰세요."
|
||||
head = "경로 그림 자동 캡처 실패 — "
|
||||
hang = tmp_path / "hang.py"
|
||||
hang.write_text("import time\ntime.sleep(30)\n", encoding="utf-8")
|
||||
monkeypatch.setattr(cap, "CHILD", str(hang))
|
||||
monkeypatch.setattr(cap, "CHILD_TIMEOUT_S", 1)
|
||||
got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS)
|
||||
assert got == {"saved": False, "reason": head + "지도 그림이 20초 안에 안 끝남" + tail}
|
||||
dead = tmp_path / "dead.py"
|
||||
dead.write_text(f"raise SystemExit('boom {KEY}')\n", encoding="utf-8")
|
||||
monkeypatch.setattr(cap, "CHILD", str(dead))
|
||||
got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS)
|
||||
assert got == {"saved": False, "reason": head + cap.NO_BROWSER + tail}
|
||||
assert not (tmp_path / "auto").exists()
|
||||
Reference in New Issue
Block a user