폴더 하나를 처리한 뒤 다음으로 넘어가는 규칙을 독립 예제로 만들었다. 작은 합성 파일을 실제로 복사하고 선언한 완료 결과를 받은 현재 사본만 done으로 옮긴다. 외부 게시와 업로드는 하지 않는다. 완료 표시가 왜 다음 항목을 열어 주는 조건인지 파일과 상태를 함께 읽어 볼 수 있다.

원본과 pending, done을 다른 위치로 둔다

FolderLoop는 호출자가 준 절대 작업 공간 안에 source·pending·done을 둔다. 원본 폴더를 읽어 파일 지문을 만들고 선택한 항목의 작업 사본을 pending에 복사한다. 완료해도 source는 옮기거나 수정하지 않는다. 입력 자료와 실행 중 사본, 완료 사본의 역할을 나눈 예제다.

이름은 짧은 식별자로 제한하고 링크와 재분석 지점을 거절하는 검사를 둔다. 그래도 악의적인 파일 시스템과 모든 동시 경로 변경을 막는 샌드박스는 아니다. 신뢰할 수 있는 소유 작업 공간과 한 명의 쓰기 주체를 전제로 한다. 실제 업체 파일이나 로그인 자료는 예제에 사용하지 않았다.

부족한 수량은 복제하지 않고 거절한다

prepare는 전체 이름 목록과 중복, 요청 수량과 모든 파일 지문을 먼저 검사한다. 횟수를 생략하면 전달한 목록 전체이고 지정하면 앞에서 그 수만 선택한다. 준비한 자료보다 많은 수를 요구하면 사본을 만들기 전에 INSUFFICIENT_ITEMS로 멈춘다. 같은 폴더를 여러 번 복제해 횟수를 채우지 않는다.

예제는 목록100개, 폴더50파일, 파일1MiB라는 작은 한도를 쓴다. 이 값은 원래 북극성 실행기의 한도가 아니다. 큰 미디어 전처리와 실제 매체 실행은 구현하지 않았다. 작은 자료로 순서와 중단 조건을 검토할 수 있게 좁힌 모의 반복기라는 범위를 함께 남겼다.

현재 항목의 완료 선언만 다음 항목을 연다

complete에는 현재 이름과 verified가 참인 짧은 receipt 자료가 필요하다. 두 번째 폴더부터 완료하려 하면 OUT_OF_ORDER다. 현재 결과가 미확정이면 needs_review로 바꾸고 다음 폴더도 넘기지 않는다. 웹 화면에 성공처럼 보이는 문자열을 찾는 기능은 없다.

receipt는 호출자의 선언이지 모듈이 확인한 원격 성공이 아니다. executed_here도 항상 거짓이다. 실제 적용에서는 서비스 결과를 검사하는 연결기가 이 자료를 만들어야 한다. 나는 설명용 verified 필드가 있다는 이유로 전송과 게시가 검증됐다고 말하지 않는다. 이 예제는 완료 자료를 어떻게 소비하는지만 보여 준다.

코드와 구현 살펴보기

독립 합성 전체 구현

"""Owned-directory synthetic folder copies; advancement needs a declared receipt."""
from pathlib import Path
import hashlib,os,re,shutil

class LoopError(ValueError):pass

def _linked(path):
    return path.is_symlink() or bool(getattr(path.lstat(),'st_file_attributes',0)&0x400)

class FolderLoop:
    MAX_FILES=50
    MAX_BYTES=1024*1024
    def __init__(self,root):
        raw=Path(root)
        if not raw.is_absolute():raise LoopError('ABSOLUTE_ROOT_REQUIRED')
        self.root=raw.resolve();self.root.mkdir(parents=True,exist_ok=True)
        self.source=self.root/'source';self.pending=self.root/'pending';self.done=self.root/'done'
        for folder in [self.source,self.pending,self.done]:
            if folder.exists() and _linked(folder):raise LoopError('LINK_DENIED')
            if not folder.resolve().is_relative_to(self.root):raise LoopError('OUTSIDE_WORKSPACE')
            folder.mkdir(exist_ok=True)
        self.items=[];self.index=0;self.state='new'

    def _child(self,parent,name):
        if not isinstance(name,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,79}',name):raise LoopError('INVALID_NAME')
        if _linked(parent) or not parent.resolve().is_relative_to(self.root):raise LoopError('OUTSIDE_WORKSPACE')
        path=parent/name
        if path.exists() and _linked(path):raise LoopError('LINK_DENIED')
        if not path.resolve().is_relative_to(parent.resolve()):raise LoopError('OUTSIDE_WORKSPACE')
        return path

    def _snapshot(self,folder):
        if not folder.is_dir() or _linked(folder):raise LoopError('SOURCE_FOLDER_REQUIRED')
        result={}
        for directory,names,files in os.walk(folder,topdown=True,followlinks=False):
            for name in names:
                if _linked(Path(directory)/name):raise LoopError('LINK_DENIED')
            for name in files:
                path=Path(directory)/name
                if _linked(path) or not path.is_file():raise LoopError('REGULAR_FILE_REQUIRED')
                with path.open('rb') as stream:data=stream.read(self.MAX_BYTES+1)
                if len(data)>self.MAX_BYTES:raise LoopError('FILE_TOO_LARGE')
                result[path.relative_to(folder).as_posix()]=hashlib.sha256(data).hexdigest()
                if len(result)>self.MAX_FILES:raise LoopError('TOO_MANY_FILES')
        if not result:raise LoopError('EMPTY_SOURCE')
        return dict(sorted(result.items()))

    def prepare(self,names,count=None):
        if self.state!='new':raise LoopError('ALREADY_PREPARED')
        if type(names) is not list or not 1<=len(names)<=100:raise LoopError('INVALID_ITEMS')
        for name in names:self._child(self.source,name)
        if len(set(names))!=len(names):raise LoopError('DUPLICATE_ITEM')
        if count is None:count=len(names)
        if type(count) is not int or not 1<=count<=100:raise LoopError('INVALID_COUNT')
        if count>len(names):raise LoopError('INSUFFICIENT_ITEMS')
        snapshots={name:self._snapshot(self._child(self.source,name)) for name in names}
        selected=names[:count]
        for name in selected:
            if self._child(self.pending,name).exists() or self._child(self.done,name).exists():raise LoopError('COPY_COLLISION')
        try:
            for name in selected:
                destination=self._child(self.pending,name)
                shutil.copytree(self._child(self.source,name),destination)
                if self._snapshot(destination)!=snapshots[name]:raise LoopError('COPY_CHANGED')
                self.items.append({'name':name,'snapshot':snapshots[name],'status':'queued','receipt':None})
        except Exception:
            self.state='needs_review';raise
        self.state='ready'
        return self.current()

    def current(self):
        item=self.items[self.index] if self.index<len(self.items) else None
        return {'state':self.state,'name':item['name'] if item else None,'completed':self.index,'total':len(self.items),'executed_here':False}

    def complete(self,name,receipt):
        if self.state!='ready':raise LoopError('BATCH_NOT_READY')
        if name!=self.items[self.index]['name']:raise LoopError('OUT_OF_ORDER')
        if type(receipt) is not dict or set(receipt)!={'verified','receipt'} or receipt['verified'] is not True or not isinstance(receipt['receipt'],str) or not receipt['receipt'].strip() or len(receipt['receipt'])>120:
            self.state='needs_review';raise LoopError('UNVERIFIED_RESULT')
        item=self.items[self.index]
        try:
            source=self._child(self.pending,name);target=self._child(self.done,name)
            if target.exists():raise LoopError('DONE_COLLISION')
            if self._snapshot(source)!=item['snapshot']:raise LoopError('WORK_COPY_CHANGED')
            # Both resolved endpoints are checked within this owned workspace
            # before moving a directory; original source folders never move.
            if not source.resolve().is_relative_to(self.pending.resolve()) or not target.resolve().is_relative_to(self.done.resolve()):raise LoopError('OUTSIDE_WORKSPACE')
            os.replace(source,target)
        except Exception:
            self.state='needs_review';raise
        item.update(status='done',receipt=receipt['receipt']);self.index+=1
        if self.index==len(self.items):self.state='completed'
        return self.current()

    def stop(self):
        if self.state=='ready':self.state='stopped'
        return self.current()

사본을 다시 맞추고 이동 범위를 확인한다

완료 직전에 pending 사본의 파일 지문을 준비 때와 다시 비교한다. 바뀌었거나 done 위치에 같은 항목이 있으면 멈춘다. 기존 완료 폴더를 덮지 않는다. 디렉터리 이동 전에는 양쪽의 해석된 절대 위치가 소유한 pending과 done 안인지 다시 검사한다. source 위치를 이동 대상으로 쓰지 않는다.

복사 중 오류가 나면 전체 배치는 확인 필요가 되고 일부 사본이 남을 수 있다. 이동 오류도 자동으로 다음 항목을 열지 않는다. 이 구현은 전체 묶음의 원자적 복사나 자동 복원을 보장하지 않는다. 남은 사본을 검토할 자료로 남긴다는 것과 완전히 복구됐다는 것은 다른 결과다.

24개 시험과 다섯 파일 기록을 공유한다

시험은 원본 바이트 보존과 요청 수량, 순서 위반과 미확정 결과, 사본 변경과 완료 위치 충돌을 확인한다. 실제 이동 함수에 오류를 주입해 pending과 원본이 남고 배치가 멈추는지도 본다. 반환한 상태 사본을 고쳐도 내부 진행이 바뀌지 않아야 한다.

전체24개 시험은 ZIP 추출 폴더에서도 실행한다. 아래 뷰어는 실제 합성 폴더 예제에서 만든 다섯 기록만 읽는다. 한영 선택 동작은 DOM 대역에서 확인하며 실제 웹브라우저와 원래 실행기는 시험하지 않았다. 이 확인을 외부 플랫폼의 반복 업로드 성공이나 정확히 한 번 처리의 증거로 확대하지 않는다.

합성 로컬 기록의 읽기 뷰어

영속 원장과 실제 검증은 별도로 연결한다

공개 ZIP의 전체 코드·시험·예제·기록 생성기와 README는 수정·재사용할 수 있다. 시험과 예제의 임시 자료는 패키지 옆에서 만들고 정리한다. 배치 진행 상태는 메모리 기반이며 프로세스를 껐다 켜서 중단 업무를 안전하게 복원하는 기능은 없다. 폴더가 남았다고 자동 재실행하면 안 된다.

실제 업무에는 파일 원장과 현재 권한, 서비스 결과 확인, 취소와 시간 조건을 더 연결해야 한다. 원래 북극성은 별도의 실행기와 제출 기록을 사용한다. 나는 작은 사본 반복기를 그대로 운영 자동화라고 소개하기보다 원본 보존과 다음 항목의 조건을 검토하는 부품으로 공유한다. 완료를 믿는 기준을 드러내는 것이 재사용의 시작이다.

코드·시험·실행 기록 ZIP 받기
Python 표준 라이브러리 · 합성 자료 · 수정과 재사용 가능