260705_2
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user