첨부 목록 CSV와 실제 파일을 상대 경로로 대조하기

fixtures/main.csv의 합성 입력을 선택한 fixtures/root 기준으로 대조한 설명용 도해이며 실제 앱 실행 화면이 아닙니다. 존재·누락·조회 규칙 밖이 각 1행입니다. 첨부 내용을 열거나 해시하지 않으며 내용 검증이나 보안 보증을 뜻하지 않습니다.

첨부 목록에 파일명이 있다고 실제 파일까지 준비된 것은 아닙니다. 먼저 어느 폴더를 기준으로 한 경로인지 정하고, 각 행의 ID와 실제 일반 파일을 대조하세요. 이번 가상 세 행의 기대값은 존재 1·누락 1·범위 밖 1입니다. 같은 경로를 여러 행이 참조한 경우도 행을 지우지 않고 따로 표시합니다.

Python 표준 라이브러리로 직접 선택한 로컬 폴더만 확인합니다. 첨부 내용을 열거나 해시·전송하지 않으며 원본 목록과 파일은 변경하지 않습니다. 입력 CSV 바이트는 읽고 해시를 기록하고, 결과는 root 밖의 새 JSON에 저장합니다. Python 기초와 터미널 실행을 아는 독자를 위한 실습입니다.

첨부 경로 대조 실습 ZIP 다운로드

1 목록 경로의 기준을 한 폴더로 고정합니다

ZIP을 새 빈 로컬 연습 폴더에 풀면 입력 CSV는 fixtures/main.csv, 첨부 기준 폴더는 fixtures/root입니다. CSV의 docs/a.txt는 CSV가 있는 위치가 아니라 --root로 지정한 폴더 아래의 docs/a.txt를 뜻합니다.

fixtures/
  main.csv
  root/
    docs/
      a.txt
      b.txt
    unrelated.txt

main.csv의 전체 내용은 다음과 같습니다. ID·경로와 파일 내용은 모두 합성 자료입니다.

id,attachment_path
A01,docs/a.txt
A02,docs/missing.txt
A03,../private.txt
ID원래 경로독립 기대 판정이유
A01docs/a.txtpresent지정 root 아래 일반 파일 있음, 24바이트
A02docs/missing.txtmissing허용한 상대 경로지만 그 위치에서 못 찾음
A03../private.txtout_of_scope상위 이동 구성요소, 실제 경로를 조회하지 않음
합성 입력 A01 docs/a.txt는 fixtures/root 아래 present, A02 docs/missing.txt는 missing, A03 ../private.txt는 조회 전 out_of_scope로 남기는 설명용 도해. 각 1행이며 첨부 내용 열기·첨부 해시는 0이고 실제 앱 실행 화면이 아닙니다.
fixtures/main.csv와 expected/main.json의 합성 3행을 설명한 도해이며 실제 앱 실행 화면이 아닙니다. A01은 선택한 fixtures/root 아래 일반 파일 존재, A02는 해당 위치에서 누락, A03의 ../private.txt는 상위 이동 구성요소 때문에 실제 경로를 조회하지 않습니다. present=1, missing=1, out_of_scope=1이며 첨부 내용 열기·첨부 해시는 0입니다. 내용 적합성·안전성 승인 도구가 아닙니다.

1+1+1+0=3행입니다. 마지막 0은 링크·폴더 등으로 보류한 hold 행 수입니다. A03을 찾아보려고 root 바깥 폴더를 조회하지 않습니다. PC 전체에서 파일이 없다는 결론도 내리지 않습니다.

제공 첨부는 실제 짧은 TXT입니다. a.txt의 Synthetic attachment A.는 ASCII 23자이고 마지막 LF가 1바이트라 24바이트입니다. b.txt도 24바이트이며 주 입력에는 참조되지 않습니다. unrelated.txt는 37바이트지만 본체가 이름을 열거하거나 메타데이터를 검사하지 않습니다.

PDF를 포함한 문서 형식·내용 검사는 없습니다. 경로 검사를 연습하기 위해 TXT를 사용합니다. 확장자를 PDF로 바꾸어도 PDF 정상 여부, 문서 버전, 해당 행의 증빙 내용까지 승인할 수 없습니다.

흐린 가상 파일 화면의 노트북 옆 목록 메모와 문서 폴더를 놓은 AI 연출 사진
AI로 만든 연출 이미지. 파일 목록을 준비하는 업무 맥락이며, 실제 경로 검사·첨부 내용 확인·검증 결과를 나타내지 않습니다.

2 CSV 구조와 경로 규칙을 먼저 확인합니다

입력 계약은 아래와 같습니다. 실제 업무 자료에 적용할 일반 규정이 아니라 이 작은 실습의 고정 기준입니다.

  • CSV는 UTF-8 또는 시작 UTF-8 BOM, 쉼표 구분, 정확한 id,attachment_path 머리글과 두 필드입니다
  • 파일은 1,000,000바이트 이하, 데이터는 1~10,000논리 레코드, 파서 필드 한도는 131072자입니다
  • ID는 ASCII 영문으로 시작하여 영문·숫자·밑줄·하이픈만 1~32자, 대소문자 구분, 중복 금지입니다
  • 입력 CSV는 첨부 root 밖에 둡니다. --root는 생략할 수 없고 기본 업무 폴더도 없습니다
  • 첨부 경로는 /로 나눈 POSIX 상대 표기이며 1~240자, 최대 20구성요소입니다
  • 절대경로·드라이브·역슬래시·콜론·빈 구성요소·.·..·제어 및 모호한 Unicode 문자는 거부합니다
  • 구성요소 앞뒤 공백·끝 점과 <>"|?*를 거부하고, CON·PRN·AUX·NUL·COM/LPT의 1~9와 ¹²³ 및 그 이름의 확장자도 보수적으로 거부합니다

공백 제거나 대소문자·Unicode·경로 자동 정규화는 하지 않습니다. 문법이 맞지 않는 경로는 원래 문자열을 보존한 out_of_scope 행으로 남깁니다. 이는 실제 위치가 항상 root 바깥이라는 증명이 아니라 이 실습의 조회 허용 규칙 밖이라는 뜻입니다.

Python csv.reader는 기본 필드를 문자열로 반환합니다. 보고서의 attachment_path도 CSV 파서가 읽은 필드 문자열이며, 구분용 따옴표까지 포함한 바이트 원문은 아닙니다. strict=True의 구문 검사에 더해 헤더·필드 수·ID·행 수를 별도로 확인합니다.

CSV 전체의 구조 검사를 끝내고 나서 첨부 메타데이터를 확인합니다. 중복 ID, 열 수 오류, 후반에 닫히지 않은 따옴표가 있으면 앞의 정상 행만 최종 보고서로 저장하지 않습니다. CSV strict와 형식 옵션

3 시험을 먼저 실행하고 세 행을 검사합니다

README.txt가 있는 압축 해제 루트에서 다음 명령을 한 줄씩 실행합니다. 별도 패키지나 API 키는 필요하지 않습니다. 검수한 환경은 CPython 3.12.14, Linux 6.18.44, x86_64이며 Windows·macOS·탐색기·Excel·별도 문서 앱은 실행하지 않았습니다.

python --version
python tests/verify_checksums.py
python -m unittest discover -s tests -v
python tests/run_fixture_checks.py
python code/attachment_paths.py --input fixtures/main.csv --root fixtures/root --out main_result.json

python3가 같은 인터프리터를 가리킨다면 첫 단어만 바꿀 수 있습니다. 자동시험은 Ran 53 tests와 OK, fixture 재검산은 scenario_count=22, all_passed=true, all_source_bytes_unchanged=true가 기대값입니다. 다른 환경에서는 제공 시험을 먼저 실행하고 실패한 상태에서 자신의 자료를 넣지 마세요. unittest discover

주 명령의 콘솔은 다음과 같고 종료코드는 1입니다. 확인할 경로가 남은 완성 보고서를 생성했다는 뜻입니다.

complete rows=3 present=1 missing=1 out_of_scope=1 hold=0 duplicate_reference_rows=0 attachment_content_opens=0

main_result.json에서 다음 값을 대조합니다. 전체 독립 기대값은 expected/main.json에 있습니다.

{
  "status": "complete",
  "input_rows": 3,
  "all_references_clear": false,
  "summary": {
    "present": 1,
    "missing": 1,
    "out_of_scope": 1,
    "hold": 0,
    "duplicate_reference_rows": 0
  },
  "duplicate_groups": [],
  "attachment_content_read": false,
  "attachment_content_hashed": false,
  "external_transmissions": 0,
  "source_changes_written": 0
}

위 JSON은 집계 부분입니다. 행 목록의 ID·원래 경로·판정·이유·크기와 source_record까지 전체 기대값과 비교하세요. source_record는 헤더를 1로 센 논리 레코드 번호입니다. root에는 명령으로 선택하고 확인한 첨부 폴더의 실제 절대 경로가 기록됩니다. 실행한 --root의 절대 경로와 같은지 별도로 대조하세요. 보고서가 놓인 폴더를 기준으로 경로를 다시 해석하지 않습니다.

source_sha256과 root는 위치와 원본 바이트에 따른 동적 값이라 고정 기대 JSON에서 제외합니다. root는 실제 선택한 절대 경로와, source_sha256은 해당 CSV 바이트의 지문과 따로 대조합니다. 본체는 최종 저장 전에 CSV를 다시 읽어 지문이 달라지면 중단합니다. 이것이 첨부 내용의 해시나 파일 잠금은 아닙니다. hashlib SHA-256

4 같은 경로의 중복 참조는 모든 행에 남깁니다

fixtures/duplicates.csv는 다음 세 행입니다.

id,attachment_path
D01,docs/a.txt
D02,docs/a.txt
D03,docs/b.txt
python code/attachment_paths.py --input fixtures/duplicates.csv --root fixtures/root --out duplicate_result.json
python code/attachment_paths.py --input fixtures/normal.csv --root fixtures/root --out normal_result.json

첫 명령은 present 3·중복 경고 행 2, 종료 1입니다. 다음 그룹과 세 원래 행이 모두 남아야 합니다.

{
  "path": "docs/a.txt",
  "record_ids": ["D01", "D02"]
}

D01과 D02는 각각 duplicate_reference=true, D03은 false입니다. 중복 경고 2는 present 3 안에 포함된 보조 표시입니다. 입력 행 수를 3+2=5로 계산하지 않습니다. 같은 증빙을 여러 행에 연결하는 것이 맞는지는 담당자가 확인할 일이며 코드가 한 행을 삭제하거나 하나의 ID로 합치지 않습니다.

normal.csv는 N01→docs/a.txt, N02→docs/b.txt로 두 경로가 모두 있고 경고가 없습니다. 두 번째 명령은 present 2, all_references_clear=true, 종료 0입니다.

이 실습은 문법을 통과한 정확한 같은 경로 문자열만 중복 참조로 묶습니다. docs/a.txt와 docs/A.txt는 같은 문자열이 아니며, 제공 case_distinct.csv의 Linux 기대값은 present 1·missing 1·중복 0입니다. 대소문자나 Unicode 표현이 다른 경로를 동일 파일로 판정하거나, 다른 이름의 내용이 같은지 비교하지 않습니다.

5 파일이 아닌 경로와 접근 실패를 구분합니다

허용한 상대 경로는 구성요소를 차례로 lstat()하여 확인합니다. Path.lstat 문서는 심볼릭 링크라면 대상 대신 링크 자체의 정보를 반환한다고 설명합니다. 링크를 발견하면 뒤 경로에 들어가지 않습니다. exists()만 확인하면 폴더도 통과할 수 있으므로 일반 파일 종류는 stat.S_ISREG로 구분합니다.

관찰행 상태 또는 중단다음 확인
허용 경로에서 파일 또는 상위 폴더를 못 찾음missing / not_found정확한 root와 상대 경로를 확인
.. 등 경로 문법 우려out_of_scope / 경로 이유원문을 보존하고 승인된 상대 경로 확인
파일·폴더 링크, 반환된 reparse 표식hold / path_link링크를 따라가지 않고 일반 실습 사본 확인
마지막 경로가 폴더·FIFO·특수 파일hold / not_regular_file행이 일반 파일을 참조하는지 확인
중간 구성요소가 폴더가 아님hold / parent_not_directory경로 계층 확인
일반 파일의 하드 링크 수가 1이 아님hold / hardlink_or_unknown_count직접 복사한 일반 실습 파일 확인
PermissionError 등 실제 조회 실패전체 중단, 종료 2접근 가능한 자기 소유 실습 사본에서 다시 실행

st_nlink는 하드 링크 수입니다. 이 코드는 수가 1인 일반 첨부만 present로 받는 보수적인 정책을 둡니다. 하드 링크라는 사실만으로 잘못된 증빙이라고 단정하지 않습니다. os.stat_result의 st_nlink

누락과 접근 실패를 같은 것으로 처리하지 않습니다. 앞의 파일 하나를 확인했어도 뒤의 메타데이터 조회가 권한 오류를 내면 최종 JSON은 만들지 않습니다. 이 글의 권한·저장 실패 시험은 통제된 오류 주입이며 실제 업무 장애 경험을 제시한 것이 아닙니다.

Windows의 다른 종류 reparse point는 OS에서 해석될 수 있다고 Python os.lstat가 설명합니다. 반환된 표식을 거부하는 코드를 Windows의 모든 경로 처리를 조회 전에 막는 보안 보증으로 사용하지 않습니다. Windows 실환경의 junction·잠금·권한 동작은 시험하지 않았습니다.

6 ZIP과 동일한 전체 코드

다음은 첨부 code/attachment_paths.py 전체입니다. 첨부 내용 열기 함수, 폴더 전체 순회, 이동·삭제·업로드 기능은 없습니다. 읽기 모드로 여는 입력은 CSV이며 보고서를 준비한 임시 JSON도 재읽기합니다.

"""Check record-to-attachment paths without opening attachment contents."""
import argparse
from collections import defaultdict
import csv
import hashlib
import io
import json
import os
from pathlib import Path, PureWindowsPath
import re
import stat
import sys
import tempfile
import unicodedata

HEADER = ['id', 'attachment_path']
ID = re.compile(r'[A-Za-z][A-Za-z0-9_-]{0,31}')
MAX_BYTES, MAX_ROWS, MAX_FIELD = 1_000_000, 10_000, 131_072
MAX_PATH_CHARS, MAX_COMPONENTS = 240, 20
RESERVED = {'CON', 'PRN', 'AUX', 'NUL'}
RESERVED |= {f'{prefix}{n}' for prefix in ('COM', 'LPT') for n in '123456789¹²³'}


def absolute(value):
    text = os.fspath(value)
    if not isinstance(text, str) or not text or '\x00' in text:
        raise ValueError('PATH_ARGUMENT: specify a nonempty path')
    parts = text.replace('\\', '/').split('/')
    if any(part in ('.', '..') for part in parts) or '//' in text.replace('\\', '/'):
        raise ValueError('PATH_ALIAS: do not use ., .. or repeated separators')
    return Path(text).absolute()


def link_metadata(info):
    return (stat.S_ISLNK(info.st_mode)
            or bool(getattr(info, 'st_file_attributes', 0) & 0x400)
            or bool(getattr(info, 'st_reparse_tag', 0)))


def plain_path(value, directory=False):
    path = absolute(value)
    for node in [*reversed(path.parents), path]:
        info = node.lstat()
        if link_metadata(info):
            raise ValueError('PATH_LINK: links and reparse points are rejected')
        want_directory = node != path or directory
        if not (stat.S_ISDIR(info.st_mode) if want_directory else stat.S_ISREG(info.st_mode)):
            raise ValueError('PLAIN_PATH_REQUIRED: ordinary file or directory needed')
        if not want_directory and info.st_nlink != 1:
            raise ValueError('INPUT_HARDLINK: use a directly copied regular CSV')
    return path


def stamp(info):
    return (info.st_dev, info.st_ino, info.st_mode, info.st_nlink,
            info.st_size, info.st_mtime_ns, info.st_ctime_ns)


def read_source(value):
    path = plain_path(value)
    observed = path.lstat()
    if observed.st_size > MAX_BYTES:
        raise ValueError('CSV_BYTE_LIMIT: maximum is 1,000,000 bytes')
    flags = os.O_RDONLY | getattr(os, 'O_BINARY', 0)
    flags |= getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
    with os.fdopen(os.open(path, flags), 'rb') as handle:
        opened = os.fstat(handle.fileno())
        if stamp(opened) != stamp(observed):
            raise ValueError('CSV_CHANGED: source changed before reading')
        raw = handle.read(MAX_BYTES + 1)
        after = os.fstat(handle.fileno())
    if len(raw) > MAX_BYTES:
        raise ValueError('CSV_BYTE_LIMIT: maximum is 1,000,000 bytes')
    if stamp(opened) != stamp(after) or len(raw) != opened.st_size:
        raise ValueError('CSV_CHANGED: source changed while reading')
    return raw


def load_records(raw):
    if len(raw) > MAX_BYTES:
        raise ValueError('CSV_BYTE_LIMIT')
    try:
        text = raw.decode('utf-8-sig', errors='strict')
    except UnicodeDecodeError as error:
        raise ValueError('UTF8_ERROR: do not guess or replace encoding') from error
    rows, ids = [], set()
    previous = csv.field_size_limit(MAX_FIELD)
    try:
        reader = csv.reader(io.StringIO(text, newline=''), delimiter=',',
                            quotechar='"', doublequote=True, skipinitialspace=False,
                            strict=True)
        if next(reader, None) != HEADER:
            raise ValueError('HEADER_ERROR: expected id,attachment_path')
        for record, fields in enumerate(reader, 2):
            if record - 1 > MAX_ROWS:
                raise ValueError('CSV_ROW_LIMIT: maximum is 10,000 records')
            if len(fields) != 2:
                raise ValueError(f'COLUMN_ERROR: record {record} needs two fields')
            ident, path = fields
            if not ID.fullmatch(ident):
                raise ValueError(f'ID_ERROR: record {record} has an invalid ID')
            if ident in ids:
                raise ValueError(f'DUPLICATE_ID: {ident}; no rows were discarded')
            ids.add(ident)
            rows.append((ident, path))
    except csv.Error as error:
        raise ValueError('CSV_SYNTAX_OR_FIELD_LIMIT: ' + str(error)) from error
    finally:
        csv.field_size_limit(previous)
    if not rows:
        raise ValueError('EMPTY_RECORDS: at least one data record is required')
    return rows


def path_concern(value):
    if not value:
        return 'empty_path'
    if len(value) > MAX_PATH_CHARS:
        return 'path_length_limit'
    if value.startswith('/') or PureWindowsPath(value).drive:
        return 'absolute_or_drive'
    if '\\' in value or ':' in value:
        return 'non_posix_separator_or_colon'
    if any(unicodedata.category(char).startswith('C') for char in value):
        return 'control_or_ambiguous_unicode'
    parts = value.split('/')
    if '..' in parts:
        return 'parent_component'
    if len(parts) > MAX_COMPONENTS:
        return 'component_count_limit'
    for part in parts:
        if part in ('', '.'):
            return 'empty_or_dot_component'
        if (part[0].isspace() or part[-1].isspace() or part.endswith('.')
                or any(char in part for char in '<>"|?*')):
            return 'ambiguous_component'
        if part.split('.')[0].upper() in RESERVED:
            return 'reserved_component'
    return None


def probe(root, value):
    """Called only after lexical validation; inspect referenced components only."""
    current = root
    parts = value.split('/')
    for index, part in enumerate(parts):
        current = current / part
        try:
            info = current.lstat()
        except FileNotFoundError:
            return 'missing', 'not_found', None
        if link_metadata(info):
            return 'hold', 'path_link', None
        if index < len(parts) - 1:
            if not stat.S_ISDIR(info.st_mode):
                return 'hold', 'parent_not_directory', None
        else:
            if not stat.S_ISREG(info.st_mode):
                return 'hold', 'not_regular_file', None
            if info.st_nlink != 1:
                return 'hold', 'hardlink_or_unknown_count', None
            return 'present', None, info.st_size
    raise ValueError('INVALID_PROBE_PATH')


def inspect(source, root):
    source, root = absolute(source), absolute(root)
    if source.is_relative_to(root):
        raise ValueError('CSV_INSIDE_ROOT: keep the input list outside attachment root')
    raw = read_source(source)
    records = load_records(raw)  # Finish all structural validation before attachment checks.
    root = plain_path(root, directory=True)
    groups, rows = defaultdict(list), []
    for record, (ident, value) in enumerate(records, 2):
        concern = path_concern(value)
        if concern:
            status, reason, size = 'out_of_scope', concern, None
        else:
            groups[value].append(ident)  # Exact valid spelling only; no normalization.
            status, reason, size = probe(root, value)
        rows.append({'source_record': record, 'id': ident, 'attachment_path': value,
                     'status': status, 'reason': reason, 'size_bytes': size,
                     'duplicate_reference': False})
    duplicates = [{'path': path, 'record_ids': ids}
                  for path, ids in groups.items() if len(ids) > 1]
    duplicate_paths = {group['path'] for group in duplicates}
    for row in rows:
        row['duplicate_reference'] = row['attachment_path'] in duplicate_paths
    counts = {state: sum(row['status'] == state for row in rows)
              for state in ('present', 'missing', 'out_of_scope', 'hold')}
    counts['duplicate_reference_rows'] = sum(row['duplicate_reference'] for row in rows)
    return {'schema_version': 1, 'status': 'complete', 'root': str(root),
            'policy': {'relative_paths': 'fixed_posix_v1',
                       'duplicates': 'exact_valid_reference_warning',
                       'links': 'not_followed', 'hardlinks': 'hold',
                       'max_csv_bytes': MAX_BYTES, 'max_rows': MAX_ROWS},
            'input_rows': len(rows),
            'all_references_clear': counts['present'] == len(rows) and not duplicates,
            'summary': counts, 'duplicate_groups': duplicates,
            'attachment_content_read': False, 'attachment_content_hashed': False,
            'external_transmissions': 0, 'source_changes_written': 0,
            'rows': rows, 'source_sha256': hashlib.sha256(raw).hexdigest()}


def publish_new(target, report):
    stage, identity, published = None, None, False
    try:
        with tempfile.NamedTemporaryFile('w', encoding='utf-8', newline='\n',
                dir=target.parent, prefix='.p25-', suffix='.tmp', delete=False) as handle:
            stage = Path(handle.name)
            info = os.fstat(handle.fileno())
            identity = (info.st_dev, info.st_ino)
            handle.write(json.dumps(report, ensure_ascii=True, indent=2) + '\n')
            handle.flush()
            os.fsync(handle.fileno())
        if json.loads(stage.read_text(encoding='utf-8')) != report:
            raise ValueError('OUTPUT_READBACK: staged JSON differs')
        os.link(stage, target)  # New name only; no replace or overwrite fallback.
        published = True
    finally:
        if stage is not None:
            try:
                info = stage.lstat()
                if (info.st_dev, info.st_ino) != identity or not stat.S_ISREG(info.st_mode):
                    raise OSError('temporary file identity changed; cleanup refused')
                stage.unlink()
            except OSError as error:
                if not published:
                    raise
                print('TEMP_CLEANUP_WARNING: complete report retained; ' + str(error),
                      file=sys.stderr)


def run(source, root, output):
    if sys.version_info < (3, 12):
        raise ValueError('PYTHON_VERSION: Python 3.12 or later is required')
    source, root, target = absolute(source), absolute(root), absolute(output)
    if target == source or target.is_relative_to(root):
        raise ValueError('OUTPUT_SCOPE: choose a new JSON outside source and attachment root')
    if target.suffix.lower() != '.json':
        raise ValueError('OUTPUT_EXTENSION: choose a new .json file')
    if os.path.lexists(target):
        raise FileExistsError('OUTPUT_EXISTS: preserve it and choose a new name')
    plain_path(target.parent, directory=True)  # Existing parent only; no mkdir.
    report = inspect(source, root)
    if hashlib.sha256(read_source(source)).hexdigest() != report['source_sha256']:
        raise ValueError('CSV_CHANGED: retry from a stable practice copy')
    plain_path(target.parent, directory=True)
    publish_new(target, report)
    return report


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--input', required=True, help='CSV list outside attachment root')
    parser.add_argument('--root', required=True, help='explicit local attachment directory')
    parser.add_argument('--out', required=True, help='new JSON outside attachment root')
    args = parser.parse_args(argv)
    try:
        report = run(args.input, args.root, args.out)
    except (ValueError, OSError) as error:
        print('ERROR: ' + str(error), file=sys.stderr)
        return 2
    s = report['summary']
    print(f"complete rows={report['input_rows']} present={s['present']} missing={s['missing']} "
          f"out_of_scope={s['out_of_scope']} hold={s['hold']} "
          f"duplicate_reference_rows={s['duplicate_reference_rows']} attachment_content_opens=0")
    return 0 if report['all_references_clear'] else 1


if __name__ == '__main__':
    raise SystemExit(main())

경로 문법은 Path로 결합하기 전에 검사합니다. Path는 반복 구분자와 단일 점을 축약할 수 있으므로 원래 문자열의 모호함을 먼저 확인합니다. pathlib의 경로 구성 규칙

CLI의 CSV·root·출력 경로에서도 .·..·반복 구분자 별칭을 받지 않습니다. 예를 들어 fixtures/root를 쓰고 ./fixtures/root로 우회하지 않습니다. 입력·root·출력 부모의 링크와 관측 가능한 reparse 표식도 거부하며 입력 CSV의 하드 링크도 받지 않습니다. 출력 부모는 이미 존재해야 합니다.

최종 보고서는 같은 출력 폴더의 NamedTemporaryFile에 모두 쓰고 동기화·JSON 재읽기 대조한 뒤 os.link로 새 최종 이름을 만듭니다. 기존 파일을 replace하지 않습니다. 하드 링크를 지원하지 않는 저장소에서는 중단하며 덮어쓰기 방식으로 바꾸지 않습니다.

7 오류를 복구한 뒤 새 이름으로 다시 검사합니다

종료코드새 최종 보고서의미
0있음모든 행 present, 정확한 유효 경로 중복 경고 없음
1있음missing·out_of_scope·hold 또는 중복 참조 경고가 남음
2이번 실행의 새 최종 보고서 없음옵션·구조·인코딩·접근·경로·저장 오류

status="complete"는 CSV 전체를 이 정책으로 검사한 보고서라는 뜻입니다. 종료 1 보고서도 complete일 수 있습니다. 모든 파일의 내용이나 업무 적합성까지 승인된 표시로 읽지 마세요.

다음은 의도한 전체 중단 명령입니다. 각 명령은 종료 2이며 지정한 새 JSON이 없어야 합니다.

python code/attachment_paths.py --input fixtures/failures/duplicate_id.csv --root fixtures/root --out failed_id.json
python code/attachment_paths.py --input fixtures/failures/wrong_header.csv --root fixtures/root --out failed_header.json
python code/attachment_paths.py --input fixtures/failures/late_unclosed.csv --root fixtures/root --out failed_quote.json
python code/attachment_paths.py --input fixtures/failures/invalid_utf8.csv --root fixtures/root --out failed_encoding.json
python code/attachment_paths.py --input fixtures/missing.csv --root fixtures/root --out failed_missing.json
python code/attachment_paths.py --input fixtures/main.csv --root fixtures/missing_root --out failed_root.json
  • 중복 ID·헤더·열 수 오류: 제공 파일의 식별 근거와 정확한 두 열을 확인한 별도 사본으로 전체 재검사합니다
  • 후반 인용문 오류: 원본을 보존하고 CSV 구문을 확인합니다. 먼저 읽힌 행만 완료로 취급하지 않습니다
  • UTF-8 오류: 제공자의 인코딩을 확인합니다. 깨진 문자를 무시하거나 대체 문자로 통과시키지 않습니다
  • 입력·root 없음: 현재 터미널 위치와 이름을 확인합니다. 다른 비슷한 폴더를 자동 선택하지 않습니다
  • 링크·권한·저장 오류: 허용된 일반 로컬 연습 사본으로 확인합니다. 관리자 실행이나 보안 설정 변경으로 우회하지 않습니다

시작 UTF-8 BOM은 utf-8-sig codec으로 처리합니다. 필수 옵션을 빠뜨리거나 지원하지 않는 옵션을 넣으면 argparse가 사용법과 종료 2를 반환합니다.

이미 main_result.json을 만들었다면 같은 이름 재실행은 기존 출력 오류로 멈춥니다. 기존 보고서를 지우지 않고 새 이름을 지정하세요.

python code/attachment_paths.py --input fixtures/main.csv --root fixtures/root --out main_result.json
python code/attachment_paths.py --input fixtures/main.csv --root fixtures/root --out main_second.json

첫 명령은 기존 보고서를 보존한 종료 2, 두 번째는 새 이름일 때 주 기대값과 같은 종료 1입니다. 쉘의 >는 기존 파일을 먼저 덮을 수 있으므로 저장에는 --out을 씁니다. 종료 1을 예상한 명령 뒤 &&를 붙이면 다음 명령이 실행되지 않을 수 있습니다.

처리된 저장 실패에서는 새 최종 보고서가 생기지 않습니다. 확정 뒤 임시 파일 정리만 실패하면 완성 JSON은 보존하고 TEMP_CLEANUP_WARNING을 냅니다. .p25-로 시작하는 임시 파일을 완성 보고서로 사용하지 마세요. 강제 종료·정전·정리 권한 상실에서는 임시 파일이 남을 수 있습니다.

8 검수한 범위와 남은 한계를 확인합니다

Linux CPython 3.12.14에서 53개 자동시험, 실패 0·오류 0·건너뜀 0을 확인했습니다. 별도 CLI 대조 22건은 독립 기대 JSON·종료코드·원본 보존·기존 출력 보존·실패 시 최종 출력 부재를 확인합니다. 제공 fixture 전체의 실행 전후 SHA-256도 같습니다.

시험에는 경로 문자·길이·구성요소, 10,000/10,001레코드, 정확히 1,000,000바이트와 한 바이트 초과, 필드 길이·ID·BOM·CRLF·구조 오류, 링크·하드 링크·폴더·FIFO, 후반 메타데이터 접근 실패와 저장 오류가 포함됩니다. partial write·flush·fsync·재읽기·최종 이름 충돌은 통제된 주입 시험입니다. Windows reparse 표식 판단도 Linux에서 메타데이터를 만든 모의시험입니다.

본체 실행 중 첨부 root 안의 content open이 하나라도 시도되면 실패하는 시험으로 내용 미개방을 확인했습니다. 폴더 열거 함수와 참조하지 않은 파일의 lstat도 차단해 무관한 범위를 보지 않는지 대조했습니다. 체크섬 검사와 보존 검산은 별도로 가상 fixture 바이트를 읽습니다. 이 패키지 검산을 본체가 업무 첨부 내용을 읽는 기능과 혼동하지 마세요.

ZIP에는 전체 코드, 같은 CSV와 TXT, 구조·인코딩 반례, 독립 기대 JSON 7개와 직접 검산표, 53개 시험과 CLI 재검산기, README, fixture 메타데이터, 체크섬만 담았습니다. 실제 실행 출력과 검수 로그는 독자 ZIP에 미리 넣지 않았습니다. 새 ZIP을 별도 폴더에 풀어 같은 시험을 재실행하고 본문·HTML·ZIP의 전체 코드가 같음을 확인했습니다.

다른 프로그램이 쓰지 않는 자기 소유의 격리된 로컬 연습 폴더에서 한 번에 한 작업만 실행하세요. 메타데이터 조회는 파일 내용을 읽을 권한이 있다는 확인도 아닙니다. 보고서 작성 후 변경, 악의적인 경로 교체, 네트워크·동기화 폴더의 경쟁, 강제 종료·정전까지 보장하지 않습니다. 경로 확인과 CSV 재해시는 원자적 스냅샷·잠금·보안 격리기·백업을 대신하지 않습니다.

한도는 연습 자료에 맞춘 상한이며 대용량 성능은 측정하지 않았습니다. 읽기 전용도 OS의 접근시각 갱신까지 고정한다는 뜻은 아닙니다. 결과에는 원래 ID와 경로가 남으므로 실제 자료로 만든 보고서는 해당 정보의 공유 범위를 따로 확인하세요.

자주 묻는 질문

missing이면 증빙이 삭제된 것인가요

이번 root의 해당 경로를 검사한 시점에 못 찾았다는 뜻입니다. 다른 폴더·다른 이름·다른 장치까지 찾은 것이 아니므로 삭제를 단정하지 않습니다.

같은 파일을 여러 행이 참조하면 한 행을 지워야 하나요

그 결정은 코드가 하지 않습니다. 정확한 같은 경로를 참조한 모든 ID에 경고를 남깁니다. 해당 업무에서 공통 증빙이 허용되는지 확인하세요.

present면 정상 PDF나 업무 증빙으로 볼 수 있나요

일반 파일 경로와 하드 링크 수를 관찰한 결과입니다. 이 실습은 TXT를 사용하고 내용·PDF 형식·악성코드·작성자·업무상 연결 관계는 검사하지 않습니다.

전체 폴더에서 목록에 없는 첨부도 찾아주나요

찾지 않습니다. CSV에 있는 경로의 구성요소만 조회합니다. 미참조 파일 전체 목록이 필요하면 검사 범위와 열거 목표를 따로 정해야 합니다.

실습 자료와 이어서 볼 글

목록을 읽는 오류와 첨부가 없는 상태를 먼저 나누세요. 파일명이 쉼표 때문에 갈라지거나 열 수가 맞지 않으면 CSV 구분자·인용 규칙 확인으로 돌아갑니다. 경로가 모두 present여도 금액의 계산 관계는 확인되지 않으므로 숫자 자료의 검토는 행별 금액 검산과 따로 진행합니다.

구분자 예제는 id·qty·memo, 이 예제는 id·attachment_path를 요구합니다. 다른 글의 출력 JSON이나 CSV를 그대로 넣지 말고 이 첨부의 가상 목록과 root로 먼저 확인하세요. 구조 오류는 목록 전체의 재검사가 필요하고, missing은 선택한 root의 해당 경로를 확인하는 문제입니다. 경로 존재와 증빙 내용의 적합성을 같은 완료 조건으로 삼지 않습니다.

P25_Practice_Attachment_Path_Check.zip 다운로드

폴더 전체를 깊이별로 열거하는 파일 목록과 미검사 범위 기록은 이번 행별 참조 대조와 다른 목표입니다. 승인한 목록의 파일을 실제로 읽어 배포 ZIP을 만드는 과정은 필요한 파일만 ZIP에 넣기에서 이어 볼 수 있습니다. 이번 결과는 ZIP 작성이나 전송 승인 없이 각 CSV 행의 참조 상태를 남기는 데까지입니다.

공식 기술 문서 확인일은 2026-10-07 UTC입니다. Python 3.12 계열 페이지의 표제는 확인 당시 3.12.15이며 실제 실행 인터프리터는 CPython 3.12.14입니다. 공식 API 설명, 합성 자료의 실행 검수와 미실행 앱 동작을 구분했습니다.

게시 전 재검수는 2026-10-07 KST에 했습니다. 기존 준비 원고·실습 ZIP을 보존하고 Linux CPython 3.12.14에서 53개 자동시험과 22건 CLI 대조, 34개 배포 체크섬을 다시 통과했습니다. 같은 날 Python 3.12 공식 문서를 재확인했으며 표시 게시일과 실제 자료 확인·재검수일은 구분합니다.

작성자 소개