This commit is contained in:
2026-07-05 21:27:23 +09:00
parent 23d907265a
commit 3abc2edba6
83 changed files with 10351 additions and 1217 deletions
+55
View File
@@ -0,0 +1,55 @@
"""바이너리·npz 파일을 원자적으로 저장하는 공통 유틸리티."""
import os
import tempfile
from pathlib import Path
import numpy as np
def atomic_write_bytes(path: str | Path, payload: bytes) -> None:
"""같은 디렉터리의 임시 파일을 교체하여 바이트를 원자적으로 저장한다."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
) as temporary:
temporary.write(payload)
temporary.flush()
os.fsync(temporary.fileno())
temporary_path = Path(temporary.name)
os.replace(temporary_path, target)
temporary_path = None
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
def atomic_write_npz(path: str | Path, **arrays: np.ndarray) -> None:
"""같은 디렉터리의 임시 파일을 교체하여 압축 npz를 원자적으로 저장한다."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
) as temporary:
np.savez_compressed(temporary, **arrays)
temporary.flush()
os.fsync(temporary.fileno())
temporary_path = Path(temporary.name)
os.replace(temporary_path, target)
temporary_path = None
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
+35
View File
@@ -0,0 +1,35 @@
"""JSON 파일을 안전하게 저장하는 공통 유틸리티."""
import json
import os
import tempfile
from pathlib import Path
from typing import Any
def atomic_write_json(path: str | Path, value: Any) -> None:
"""같은 디렉터리의 임시 파일을 교체하여 JSON을 원자적으로 저장한다."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
) as temporary:
json.dump(value, temporary, ensure_ascii=False, indent=2)
temporary.write("\n")
temporary.flush()
os.fsync(temporary.fileno())
temporary_path = Path(temporary.name)
os.replace(temporary_path, target)
temporary_path = None
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
+36
View File
@@ -0,0 +1,36 @@
"""프로젝트 영구저장소 경로 유틸리티."""
import os
from pathlib import PurePosixPath
from config.config_system import PROJECT_STORAGE_STAGE_DIRS, STORAGE_BASE_DIR
def get_project_stage_path(project_root: str, stage: str) -> str:
"""프로젝트 루트 아래의 허용된 워크플로우 단계 폴더를 생성한다."""
if stage not in PROJECT_STORAGE_STAGE_DIRS:
raise ValueError(f"허용되지 않은 프로젝트 저장 단계입니다: {stage}")
root = os.path.abspath(project_root)
path = os.path.abspath(os.path.join(root, stage))
if os.path.commonpath((root, path)) != root:
raise ValueError("단계 저장 경로가 프로젝트 루트를 벗어났습니다.")
os.makedirs(path, exist_ok=True)
return path
def resolve_stored_project_path(relative_path: str) -> str:
"""DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다."""
normalized = PurePosixPath(relative_path.replace("\\", "/"))
if normalized.is_absolute() or ".." in normalized.parts:
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
if not normalized.parts or normalized.parts[0] != "storage":
raise ValueError("프로젝트 저장 경로는 storage/로 시작해야 합니다.")
storage_root = os.path.abspath(STORAGE_BASE_DIR)
path = os.path.abspath(os.path.join(storage_root, *normalized.parts[1:]))
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
os.makedirs(path, exist_ok=True)
return path
+42
View File
@@ -0,0 +1,42 @@
"""프로젝트 간 공유되는 워크플로우 상태 유틸리티."""
import json
import threading
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
_WORKFLOW_LOCK = threading.Lock()
def load_project_workflow(project_root: str | Path) -> dict[str, Any]:
"""프로젝트 루트의 workflow.json을 읽고 없으면 초기 상태를 반환한다."""
workflow_path = Path(project_root) / "workflow.json"
if not workflow_path.exists():
return {
"current_stage": "scan",
"completed": [],
"stale_from": None,
"stage1_confirmed": None,
}
with workflow_path.open("r", encoding="utf-8") as workflow_file:
workflow = json.load(workflow_file)
if not isinstance(workflow, dict):
raise ValueError("workflow.json의 최상위 값은 객체여야 합니다.")
return workflow
def patch_project_workflow_stale(project_root: str | Path, stale_from: str | None) -> None:
"""기존 workflow.json의 다른 상태를 보존하며 stale_from만 갱신한다."""
workflow_path = Path(project_root) / "workflow.json"
if not workflow_path.exists():
return
with _WORKFLOW_LOCK:
workflow = load_project_workflow(project_root)
if workflow.get("stale_from") == stale_from:
return
workflow["stale_from"] = stale_from
atomic_write_json(workflow_path, workflow)